Tier 3 is the only tier automatic (webhook-triggered) grouping ever reaches, since grouping runs before message parsing. It was scoped to reports.requester_contact_id, so the same campaign reported by different employees at the same company never consolidated into one campaign — each report's evidence/blast-radius view silently under-reported the campaign's true recipients. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
587 lines
23 KiB
TypeScript
587 lines
23 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock postgresClient BEFORE importing the module under test.
|
|
const queryMock = vi.fn();
|
|
const transactionMock = vi.fn();
|
|
vi.mock('./postgres-client', () => ({
|
|
postgresClient: {
|
|
query: (...args: unknown[]) => queryMock(...args),
|
|
transaction: (...args: unknown[]) => transactionMock(...args),
|
|
},
|
|
}));
|
|
|
|
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
|
|
import { normalizeSubject, extractUrlDomain, groupReportIntoCampaign } from './campaign-grouping-service';
|
|
|
|
describe('normalizeSubject', () => {
|
|
it('strips a single Re: prefix, lowercases, trims', () => {
|
|
expect(normalizeSubject('Re: Your Invoice ')).toBe('your invoice');
|
|
});
|
|
|
|
it('strips repeated Re:/Fwd:/Fw: prefixes case-insensitively', () => {
|
|
expect(normalizeSubject('FW: Re: fwd: Urgent Payment')).toBe('urgent payment');
|
|
});
|
|
|
|
it('handles null', () => {
|
|
expect(normalizeSubject(null)).toBe('');
|
|
});
|
|
|
|
it('handles empty string', () => {
|
|
expect(normalizeSubject('')).toBe('');
|
|
});
|
|
|
|
it('lowercases and trims a subject with no prefix', () => {
|
|
expect(normalizeSubject(' Urgent Payment ')).toBe('urgent payment');
|
|
});
|
|
});
|
|
|
|
describe('extractUrlDomain', () => {
|
|
it('extracts hostname from a full URL', () => {
|
|
expect(extractUrlDomain('https://evil.example.com/path?x=1')).toBe('evil.example.com');
|
|
});
|
|
|
|
it('returns null for a malformed URL instead of throwing', () => {
|
|
expect(extractUrlDomain('not-a-url')).toBeNull();
|
|
});
|
|
|
|
it('extracts hostname regardless of scheme', () => {
|
|
expect(extractUrlDomain('http://another.example.net')).toBe('another.example.net');
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// groupReportIntoCampaign — mocked-DB behavior (CAMP-01, CAMP-02, D-08)
|
|
//
|
|
// `transactionMock` invokes its callback with a fake `client` whose `query`
|
|
// method routes to staged rows based on a distinguishing SQL substring for
|
|
// each of the implementation's queries (own report / own message / tier 1 /
|
|
// own indicators / tier 2 candidates / candidate indicators / tier 3 /
|
|
// update campaigns / update reports / insert campaign). This mirrors
|
|
// phishing-eml-service.test.ts's `callsContaining()` discipline but resolves
|
|
// per-call instead of asserting only after the fact, since groupReportInto
|
|
// Campaign's control flow branches on intermediate query results.
|
|
// =============================================================================
|
|
|
|
interface MockRows {
|
|
ownReport?: unknown[];
|
|
ownMessage?: unknown[];
|
|
tier1?: unknown[];
|
|
ownIndicators?: unknown[];
|
|
tier2Candidates?: unknown[];
|
|
candidateIndicators?: unknown[];
|
|
tier3?: unknown[];
|
|
/** CR-03: rows for the own-campaign revalidation SELECT (campaigns WHERE id = ownReport.campaign_id). */
|
|
ownCampaign?: unknown[];
|
|
insertCampaign?: unknown[];
|
|
}
|
|
|
|
/** Records every SQL call made through the fake transaction client. */
|
|
let clientCalls: Array<{ sql: string; params: unknown[] }> = [];
|
|
|
|
function makeClient(rows: MockRows) {
|
|
return {
|
|
query: vi.fn(async (sql: string, params?: unknown[]) => {
|
|
clientCalls.push({ sql, params: params ?? [] });
|
|
|
|
if (sql.includes('SELECT title, company_id, created_at')) {
|
|
return { rows: rows.ownReport ?? [], rowCount: rows.ownReport?.length ?? 0 };
|
|
}
|
|
if (sql.includes('FROM messages') && sql.includes('WHERE report_id = $1')) {
|
|
return { rows: rows.ownMessage ?? [], rowCount: rows.ownMessage?.length ?? 0 };
|
|
}
|
|
if (sql.includes('WHERE m.message_id = $1')) {
|
|
return { rows: rows.tier1 ?? [], rowCount: rows.tier1?.length ?? 0 };
|
|
}
|
|
if (sql.includes('FROM indicators') && sql.includes('WHERE message_id = $1')) {
|
|
return { rows: rows.ownIndicators ?? [], rowCount: rows.ownIndicators?.length ?? 0 };
|
|
}
|
|
if (sql.includes('BETWEEN $2::timestamptz')) {
|
|
return { rows: rows.tier2Candidates ?? [], rowCount: rows.tier2Candidates?.length ?? 0 };
|
|
}
|
|
if (sql.includes('message_id = ANY')) {
|
|
return { rows: rows.candidateIndicators ?? [], rowCount: rows.candidateIndicators?.length ?? 0 };
|
|
}
|
|
if (sql.includes('BETWEEN $3::timestamptz') && sql.includes('FROM reports r')) {
|
|
return { rows: rows.tier3 ?? [], rowCount: rows.tier3?.length ?? 0 };
|
|
}
|
|
if (sql.includes('SELECT campaign_key')) {
|
|
return { rows: rows.ownCampaign ?? [], rowCount: rows.ownCampaign?.length ?? 0 };
|
|
}
|
|
if (sql.includes('UPDATE campaigns')) {
|
|
return { rows: [], rowCount: 1 };
|
|
}
|
|
if (sql.includes('UPDATE reports SET campaign_id')) {
|
|
return { rows: [], rowCount: 1 };
|
|
}
|
|
if (sql.includes('INSERT INTO campaigns')) {
|
|
return { rows: rows.insertCampaign ?? [{ id: 'unstaged-campaign-id' }], rowCount: 1 };
|
|
}
|
|
throw new Error(`Unstaged query in test mock: ${sql}`);
|
|
}),
|
|
};
|
|
}
|
|
|
|
function callsContaining(needle: string) {
|
|
return clientCalls.filter((c) => c.sql.includes(needle));
|
|
}
|
|
|
|
describe('groupReportIntoCampaign', () => {
|
|
beforeEach(() => {
|
|
queryMock.mockReset();
|
|
transactionMock.mockReset();
|
|
clientCalls = [];
|
|
});
|
|
|
|
function stage(rows: MockRows) {
|
|
transactionMock.mockImplementation(async (callback: (client: unknown) => Promise<unknown>) =>
|
|
callback(makeClient(rows))
|
|
);
|
|
}
|
|
|
|
const REPORT_ROW = {
|
|
title: 'Re: Invoice Alert',
|
|
company_id: 10,
|
|
created_at: '2026-07-15T10:00:00Z',
|
|
};
|
|
|
|
it('Tier 3 match increments existing campaign report_count, updates last_seen_at, links campaign_id, and never creates a second campaign', async () => {
|
|
stage({
|
|
ownReport: [REPORT_ROW],
|
|
ownMessage: [],
|
|
tier3: [{ campaign_id: 'campaign-1', title: 'Invoice Alert' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-2');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'campaign-1',
|
|
groupMethod: 'sender_subject_client',
|
|
created: false,
|
|
});
|
|
|
|
const updateCampaignCalls = callsContaining('UPDATE campaigns');
|
|
expect(updateCampaignCalls).toHaveLength(1);
|
|
expect(updateCampaignCalls[0].sql).toContain('report_count = report_count + 1');
|
|
expect(updateCampaignCalls[0].sql).toContain('last_seen_at = NOW()');
|
|
expect(updateCampaignCalls[0].params).toEqual(['campaign-1']);
|
|
|
|
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
|
|
expect(updateReportCalls).toHaveLength(1);
|
|
expect(updateReportCalls[0].params).toEqual(['campaign-1', 'report-2']);
|
|
|
|
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
|
|
});
|
|
|
|
it('bug fix (phishing-recipient-seubert): Tier 3 matches a sibling report from a DIFFERENT reporting contact at the same company — company-wide, not contact-scoped', async () => {
|
|
// Regression coverage for the fix: Tier 3 previously required
|
|
// r.requester_contact_id = $1, which meant two different employees at
|
|
// the same company reporting the identical campaign (same normalized
|
|
// subject, same company, within 24h) could never be consolidated into
|
|
// one campaign. The query itself must not filter or join on any
|
|
// contact/requester column, and must scope only by company_id.
|
|
stage({
|
|
ownReport: [REPORT_ROW], // this report's own reporter is irrelevant to the match now
|
|
ownMessage: [],
|
|
tier3: [{ campaign_id: 'shared-campaign', title: 'Invoice Alert' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-different-reporter');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'shared-campaign',
|
|
groupMethod: 'sender_subject_client',
|
|
created: false,
|
|
});
|
|
|
|
const tier3Calls = clientCalls.filter(
|
|
(c) => c.sql.includes('FROM reports r') && c.sql.includes('r.company_id = $1')
|
|
);
|
|
expect(tier3Calls).toHaveLength(1);
|
|
expect(tier3Calls[0].sql).not.toContain('requester_contact_id');
|
|
expect(tier3Calls[0].sql).not.toContain('JOIN contacts');
|
|
expect(tier3Calls[0].params).toEqual([10, 'report-different-reporter', REPORT_ROW.created_at]);
|
|
});
|
|
|
|
it('creates exactly one new campaign when no tier matches anything', async () => {
|
|
stage({
|
|
ownReport: [REPORT_ROW],
|
|
ownMessage: [],
|
|
tier3: [],
|
|
insertCampaign: [{ id: 'new-campaign-id' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-3');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'new-campaign-id',
|
|
groupMethod: 'sender_subject_client',
|
|
created: true,
|
|
});
|
|
|
|
const insertCalls = callsContaining('INSERT INTO campaigns');
|
|
expect(insertCalls).toHaveLength(1);
|
|
expect(insertCalls[0].params).toEqual([
|
|
'sender_subject_client:invoice alert:10',
|
|
'sender_subject_client',
|
|
]);
|
|
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
|
|
});
|
|
|
|
it('skipIfAlreadyGrouped:true returns null when the pre-check SELECT reports a non-null campaign_id (D-08)', async () => {
|
|
queryMock.mockResolvedValueOnce({ rows: [{ campaign_id: 'already-grouped' }], rowCount: 1 });
|
|
|
|
const result = await groupReportIntoCampaign('report-4', { skipIfAlreadyGrouped: true });
|
|
|
|
expect(result).toBeNull();
|
|
expect(transactionMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skipIfAlreadyGrouped:true proceeds to full matching when campaign_id is null', async () => {
|
|
queryMock.mockResolvedValueOnce({ rows: [{ campaign_id: null }], rowCount: 1 });
|
|
stage({
|
|
ownReport: [REPORT_ROW],
|
|
ownMessage: [],
|
|
tier3: [{ campaign_id: 'campaign-9', title: 'Invoice Alert' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-5', { skipIfAlreadyGrouped: true });
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'campaign-9',
|
|
groupMethod: 'sender_subject_client',
|
|
created: false,
|
|
});
|
|
});
|
|
|
|
it('Tier 1 (Message-ID) matches before Tier 2/3 are ever queried', async () => {
|
|
stage({
|
|
ownReport: [REPORT_ROW],
|
|
ownMessage: [{ id: 'own-msg-id', message_id: 'shared-message-id-123' }],
|
|
tier1: [{ campaign_id: 'campaign-t1' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-6');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'campaign-t1',
|
|
groupMethod: 'message_id',
|
|
created: false,
|
|
});
|
|
expect(callsContaining('FROM indicators')).toHaveLength(0);
|
|
expect(callsContaining('JOIN contacts c')).toHaveLength(0);
|
|
});
|
|
|
|
it('Tier 2 (attachment-hash/URL-domain + subject + sender + 24h) matches when Tier 1 finds nothing', async () => {
|
|
stage({
|
|
ownReport: [REPORT_ROW],
|
|
ownMessage: [{ id: 'own-msg-id', message_id: 'unique-message-id-no-other-match' }],
|
|
tier1: [], // own message_id matches no other already-grouped report
|
|
ownIndicators: [
|
|
{ indicator_type: 'attachment_hash', value: 'hash123' },
|
|
{ indicator_type: 'sender', value: 'attacker@evil.example.com' },
|
|
],
|
|
tier2Candidates: [
|
|
{
|
|
report_id: 'other-report',
|
|
campaign_id: 'campaign-t2',
|
|
title: 'Invoice Alert',
|
|
message_id: 'other-msg-id',
|
|
},
|
|
],
|
|
candidateIndicators: [
|
|
{ message_id: 'other-msg-id', indicator_type: 'attachment_hash', value: 'hash123' },
|
|
{ message_id: 'other-msg-id', indicator_type: 'sender', value: 'attacker@evil.example.com' },
|
|
],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-7');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'campaign-t2',
|
|
groupMethod: 'attachment_or_url',
|
|
created: false,
|
|
});
|
|
});
|
|
|
|
it('extracts URL-domain (not raw URL string) when matching Tier 2 via url indicators', async () => {
|
|
stage({
|
|
ownReport: [REPORT_ROW],
|
|
ownMessage: [{ id: 'own-msg-id', message_id: 'unique-message-id-no-other-match-2' }],
|
|
tier1: [],
|
|
ownIndicators: [
|
|
{ indicator_type: 'url', value: 'https://evil.example.com/phish?x=1' },
|
|
{ indicator_type: 'sender', value: 'attacker@evil.example.com' },
|
|
],
|
|
tier2Candidates: [
|
|
{
|
|
report_id: 'other-report',
|
|
campaign_id: 'campaign-t2-url',
|
|
title: 'Invoice Alert',
|
|
message_id: 'other-msg-id-2',
|
|
},
|
|
],
|
|
candidateIndicators: [
|
|
// Different raw URL string, same domain — must match via extractUrlDomain, not exact string equality.
|
|
{ message_id: 'other-msg-id-2', indicator_type: 'url', value: 'https://evil.example.com/different-path' },
|
|
{ message_id: 'other-msg-id-2', indicator_type: 'sender', value: 'attacker@evil.example.com' },
|
|
],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-8');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'campaign-t2-url',
|
|
groupMethod: 'attachment_or_url',
|
|
created: false,
|
|
});
|
|
});
|
|
|
|
it('re-running on an already-grouped report whose own messages/indicators would otherwise self-match does not increment report_count a second time (self-exclusion)', async () => {
|
|
// Own message_id and own indicators exist (this report has already been
|
|
// through parseAndStoreMessage), but NO other report shares them — the
|
|
// only thing that *could* match is this report's own row, which every
|
|
// tier query excludes via `r.id != <this report's id>`.
|
|
stage({
|
|
ownReport: [REPORT_ROW],
|
|
ownMessage: [{ id: 'own-msg-id', message_id: 'self-message-id' }],
|
|
tier1: [], // self-exclusion means the report's own linked campaign never surfaces here
|
|
ownIndicators: [
|
|
{ indicator_type: 'attachment_hash', value: 'self-hash' },
|
|
{ indicator_type: 'sender', value: 'attacker@evil.example.com' },
|
|
],
|
|
tier2Candidates: [], // no other report in the 24h window
|
|
tier3: [], // no other report matches sender+subject+client either
|
|
insertCampaign: [{ id: 'fresh-campaign-id' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('already-grouped-report');
|
|
|
|
// Tier 1's own message_id exists, so the new campaign is created with
|
|
// the strongest available key (message_id) — see computeTier1Key.
|
|
expect(result).toEqual({
|
|
campaignId: 'fresh-campaign-id',
|
|
groupMethod: 'message_id',
|
|
created: true,
|
|
});
|
|
// The critical assertion: no existing campaign's report_count is bumped
|
|
// a second time for this report re-run.
|
|
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
|
|
});
|
|
|
|
it('Test G (CR-02 guard against over-firing): same-campaign re-match remains a pure no-op — the origin decrement must NOT fire when the match resolves back to the report\'s own campaign', async () => {
|
|
// Own report is already linked to campaign-1. A SIBLING report (not this
|
|
// report's own excluded row) also belongs to campaign-1 and matches via
|
|
// Tier 3 (sender + subject + client). This is the CAMP-02/CR-01 gap:
|
|
// tier queries only exclude the report's OWN row, not siblings already
|
|
// in the same campaign, so a naive re-match would double-increment.
|
|
stage({
|
|
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-1' }],
|
|
ownMessage: [],
|
|
tier3: [{ campaign_id: 'campaign-1', title: 'Invoice Alert' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-self');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'campaign-1',
|
|
groupMethod: 'sender_subject_client',
|
|
created: false,
|
|
});
|
|
|
|
// Critical assertions: no double-increment, no phantom new campaign, no
|
|
// redundant re-link.
|
|
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
|
|
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
|
|
expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// CR-03 (gap closure, 18-05 Task 1): own-campaign revalidation guard.
|
|
//
|
|
// Root cause: every tier query above self-excludes the report's OWN row
|
|
// (`r.id != $x`). For a single-report campaign (no sibling report exists
|
|
// yet), all three tiers return zero rows on re-analyze, matchCampaignId
|
|
// stays null, and — pre-fix — control fell straight into "create new
|
|
// campaign", producing a SECOND campaigns row with the identical
|
|
// campaign_key. Tests A/B below reproduce that exact scenario and FAILED
|
|
// against the pre-fix code (confirmed during RED).
|
|
// ===========================================================================
|
|
|
|
it('Test A (CR-03 core): single-report re-analyze via Tier-1 stored key reuses the report\'s own existing campaign with zero mutating queries', async () => {
|
|
stage({
|
|
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }],
|
|
ownMessage: [{ id: 'own-msg-id', message_id: 'lone-message-id' }],
|
|
tier1: [], // no sibling shares this message_id — single-report campaign
|
|
ownIndicators: [],
|
|
tier2Candidates: [],
|
|
tier3: [],
|
|
ownCampaign: [{ campaign_key: 'message_id:lone-message-id', group_method: 'message_id' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-own-a');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'campaign-own',
|
|
groupMethod: 'message_id',
|
|
created: false,
|
|
});
|
|
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
|
|
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
|
|
expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0);
|
|
});
|
|
|
|
it('Test B (CR-03 Tier-3-only variant): single-report re-analyze via Tier-3 stored key reuses the report\'s own existing campaign with zero mutating queries', async () => {
|
|
stage({
|
|
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }],
|
|
ownMessage: [],
|
|
tier3: [],
|
|
ownCampaign: [
|
|
{ campaign_key: 'sender_subject_client:invoice alert:10', group_method: 'sender_subject_client' },
|
|
],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-own-b');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'campaign-own',
|
|
groupMethod: 'sender_subject_client',
|
|
created: false,
|
|
});
|
|
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
|
|
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
|
|
expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0);
|
|
});
|
|
|
|
it('Test C (no regression): campaign_id=null still creates exactly one campaign and never runs the own-campaign guard', async () => {
|
|
stage({
|
|
ownReport: [REPORT_ROW], // no campaign_id field => null/undefined, never grouped
|
|
ownMessage: [],
|
|
tier3: [],
|
|
insertCampaign: [{ id: 'brand-new-campaign' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-own-c');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'brand-new-campaign',
|
|
groupMethod: 'sender_subject_client',
|
|
created: true,
|
|
});
|
|
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1);
|
|
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
|
|
// The own-campaign guard must not fire when there is no own campaign_id.
|
|
expect(callsContaining('SELECT campaign_key')).toHaveLength(0);
|
|
});
|
|
|
|
it('Test D (signal genuinely diverged): stored own-campaign key no longer matches current signal, falls through to create-new', async () => {
|
|
stage({
|
|
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }],
|
|
ownMessage: [],
|
|
tier3: [],
|
|
ownCampaign: [
|
|
{ campaign_key: 'sender_subject_client:old subject:10', group_method: 'sender_subject_client' },
|
|
],
|
|
insertCampaign: [{ id: 'diverged-new-campaign' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-own-d');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'diverged-new-campaign',
|
|
groupMethod: 'sender_subject_client',
|
|
created: true,
|
|
});
|
|
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// CR-02 (gap closure, 18-05 Task 2): decrement the origin campaign's
|
|
// report_count on cross-campaign migration, AND on the signal-diverged
|
|
// create-new fall-through added by Task 1 (CR-03) above — both are the
|
|
// same "abandoning an origin campaign" situation, just landing on an
|
|
// existing sibling's campaign vs. a brand-new one. Tests E and H below
|
|
// FAILED against the pre-Task-2 code (confirmed during RED).
|
|
// ===========================================================================
|
|
|
|
it('Test E (CR-02 migration decrements origin): cross-campaign migration decrements the origin and increments the destination exactly once each', async () => {
|
|
stage({
|
|
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-A' }],
|
|
ownMessage: [],
|
|
tier3: [{ campaign_id: 'campaign-B', title: 'Invoice Alert' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-e');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'campaign-B',
|
|
groupMethod: 'sender_subject_client',
|
|
created: false,
|
|
});
|
|
|
|
const updateCampaignCalls = callsContaining('UPDATE campaigns');
|
|
expect(updateCampaignCalls).toHaveLength(2);
|
|
const incrementCall = updateCampaignCalls.find((c) => c.sql.includes('report_count + 1'));
|
|
const decrementCall = updateCampaignCalls.find((c) => c.sql.includes('report_count - 1'));
|
|
expect(incrementCall?.params).toEqual(['campaign-B']);
|
|
expect(decrementCall?.params).toEqual(['campaign-A']);
|
|
|
|
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
|
|
expect(updateReportCalls).toHaveLength(1);
|
|
expect(updateReportCalls[0].params).toEqual(['campaign-B', 'report-e']);
|
|
});
|
|
|
|
it('Test F (D-08 upgrade preserved, no origin to decrement): a never-grouped report upgrading to a sibling campaign increments the destination only', async () => {
|
|
stage({
|
|
ownReport: [REPORT_ROW], // campaign_id null/undefined — never grouped before
|
|
ownMessage: [{ id: 'own-msg-id', message_id: 'shared-message-id-upgrade' }],
|
|
tier1: [{ campaign_id: 'campaign-B' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-f');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'campaign-B',
|
|
groupMethod: 'message_id',
|
|
created: false,
|
|
});
|
|
|
|
const updateCampaignCalls = callsContaining('UPDATE campaigns');
|
|
expect(updateCampaignCalls).toHaveLength(1);
|
|
expect(updateCampaignCalls[0].sql).toContain('report_count + 1');
|
|
expect(updateCampaignCalls[0].params).toEqual(['campaign-B']);
|
|
|
|
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
|
|
expect(updateReportCalls).toHaveLength(1);
|
|
expect(updateReportCalls[0].params).toEqual(['campaign-B', 'report-f']);
|
|
});
|
|
|
|
it('Test H (plan-checker-flagged divergence case): signal-diverged create-new also decrements the abandoned origin campaign exactly once', async () => {
|
|
stage({
|
|
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-A' }],
|
|
ownMessage: [],
|
|
tier3: [],
|
|
ownCampaign: [
|
|
{ campaign_key: 'sender_subject_client:old subject:10', group_method: 'sender_subject_client' },
|
|
],
|
|
insertCampaign: [{ id: 'diverged-new-campaign-h' }],
|
|
});
|
|
|
|
const result = await groupReportIntoCampaign('report-h');
|
|
|
|
expect(result).toEqual({
|
|
campaignId: 'diverged-new-campaign-h',
|
|
groupMethod: 'sender_subject_client',
|
|
created: true,
|
|
});
|
|
|
|
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1);
|
|
const updateCampaignCalls = callsContaining('UPDATE campaigns');
|
|
expect(updateCampaignCalls).toHaveLength(1);
|
|
expect(updateCampaignCalls[0].sql).toContain('report_count - 1');
|
|
expect(updateCampaignCalls[0].params).toEqual(['campaign-A']);
|
|
|
|
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
|
|
expect(updateReportCalls).toHaveLength(1);
|
|
expect(updateReportCalls[0].params).toEqual(['diverged-new-campaign-h', 'report-h']);
|
|
});
|
|
});
|