test(260717-v6c): mirror test coverage for accidental-report service functions

- remediation-service.test.ts: D-04 guard rejection, successful status flip
  + audit event, note-post-failure-still-commits path for
  markCampaignAccidentalReport
- triage-note-service.test.ts: noteType 18/publish 1 posting, per-ticket
  isolation, zero-reports path for generateAndPostAccidentalReportNote
This commit is contained in:
lorentz 2026-07-17 22:35:04 -04:00
parent 74e43e23c4
commit 0d6cd25008
2 changed files with 149 additions and 1 deletions

View file

@ -16,8 +16,10 @@ vi.mock('./phishing-audit', () => ({
}));
const generateAndPostAcknowledgmentMock = vi.fn();
const generateAndPostAccidentalReportNoteMock = vi.fn();
vi.mock('./triage-note-service', () => ({
generateAndPostAcknowledgment: (...args: unknown[]) => generateAndPostAcknowledgmentMock(...args),
generateAndPostAccidentalReportNote: (...args: unknown[]) => generateAndPostAccidentalReportNoteMock(...args),
}));
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
@ -25,6 +27,7 @@ import {
approveRemediationActions,
remediateApprovedActions,
markCampaignFalsePositive,
markCampaignAccidentalReport,
autoPostAcknowledgment,
RemediationValidationError,
RemediationConflictError,
@ -97,6 +100,8 @@ beforeEach(() => {
writeAuditEventMock.mockResolvedValue('audit-id');
generateAndPostAcknowledgmentMock.mockReset();
generateAndPostAcknowledgmentMock.mockResolvedValue({ noteText: 'thanks', tickets: [] });
generateAndPostAccidentalReportNoteMock.mockReset();
generateAndPostAccidentalReportNoteMock.mockResolvedValue({ noteText: 'no action needed', tickets: [] });
clientCalls = [];
});
@ -346,6 +351,79 @@ describe('markCampaignFalsePositive', () => {
});
});
// =============================================================================
// markCampaignAccidentalReport (D-04 guard, quick task 260717-v6c)
// =============================================================================
describe('markCampaignAccidentalReport', () => {
it('throws RemediationConflictError when any remediation_actions row has status approved/completed', async () => {
stage({
guardRows: [{ id: 'action-1' }],
});
await expect(
markCampaignAccidentalReport('campaign-1', 'operator@example.com')
).rejects.toThrow(RemediationConflictError);
expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(0);
expect(generateAndPostAccidentalReportNoteMock).not.toHaveBeenCalled();
});
it('sets campaigns.status to accidental_report and writes one audit row when there are no approved/completed rows', async () => {
stage({
guardRows: [],
campaign: [{ status: 'open' }],
});
const result = await markCampaignAccidentalReport('campaign-1', 'operator@example.com', 'reported by mistake');
expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(1);
expect(writeAuditEventMock).toHaveBeenCalledTimes(1);
const [auditArgs] = writeAuditEventMock.mock.calls[0];
expect(auditArgs).toMatchObject({
campaignId: 'campaign-1',
eventType: 'campaign_marked_accidental_report',
payload: { previousStatus: 'open', reason: 'reported by mistake' },
});
expect(result).toMatchObject({
campaignId: 'campaign-1',
status: 'accidental_report',
notePosted: true,
});
expect(generateAndPostAccidentalReportNoteMock).toHaveBeenCalledTimes(1);
expect(generateAndPostAccidentalReportNoteMock).toHaveBeenCalledWith('campaign-1');
});
it('throws RemediationValidationError when the campaign does not exist', async () => {
stage({
guardRows: [],
campaign: [],
});
await expect(
markCampaignAccidentalReport('campaign-missing', 'operator@example.com')
).rejects.toThrow(RemediationValidationError);
expect(generateAndPostAccidentalReportNoteMock).not.toHaveBeenCalled();
});
it('still returns the committed status change with notePosted:false and noteError set when the note post fails', async () => {
stage({
guardRows: [],
campaign: [{ status: 'open' }],
});
generateAndPostAccidentalReportNoteMock.mockRejectedValueOnce(new Error('Autotask unavailable'));
const result = await markCampaignAccidentalReport('campaign-1', 'operator@example.com');
expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(1);
expect(result).toMatchObject({
campaignId: 'campaign-1',
status: 'accidental_report',
notePosted: false,
noteError: 'Autotask unavailable',
});
});
});
// =============================================================================
// autoPostAcknowledgment (AUTOGATE-03 gap closure — CR-01 / WR-01)
// =============================================================================

View file

@ -36,7 +36,11 @@ vi.mock('./triage-note-format', async (importOriginal) => {
});
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
import { generateAndPostTriageNote, generateAndPostAcknowledgment } from './triage-note-service';
import {
generateAndPostTriageNote,
generateAndPostAcknowledgment,
generateAndPostAccidentalReportNote,
} from './triage-note-service';
interface MockRows {
reports?: unknown[];
@ -323,3 +327,69 @@ describe('generateAndPostAcknowledgment', () => {
expect(createEntityMock).not.toHaveBeenCalled();
});
});
describe('generateAndPostAccidentalReportNote', () => {
it('posts a customer-visible (noteType 18, publish 1) reviewed-report note to every linked ticket', async () => {
stage({
reports: [
report({ id: 'r1', ticket_id: '1001' }),
report({ id: 'r2', ticket_id: '1002' }),
],
});
const result = await generateAndPostAccidentalReportNote('campaign-1');
expect(createEntityMock).toHaveBeenCalledTimes(2);
for (const [entityName, data] of createEntityMock.mock.calls) {
expect(entityName).toBe('TicketNotes');
expect(data).toMatchObject({
description: result.noteText,
noteType: 18,
publish: 1,
});
expect(typeof (data as { ticketID: unknown }).ticketID).toBe('number');
}
expect(createEntityMock.mock.calls.map((c) => (c[1] as { ticketID: number }).ticketID)).toEqual([1001, 1002]);
expect(result.tickets).toEqual([
{ ticketId: '1001', posted: true },
{ ticketId: '1002', posted: true },
]);
expect(result.noteText.length).toBeGreaterThan(0);
// Fixed template, zero evidence/URL/classification interpolation (T-23-01).
expect(result.noteText).not.toContain('Blast Radius');
expect(result.noteText).not.toContain('Recommended Actions');
});
it('isolates a single ticket write failure without aborting the remaining writes', async () => {
stage({
reports: [
report({ id: 'r1', ticket_id: '1001' }),
report({ id: 'r2', ticket_id: '1002' }),
report({ id: 'r3', ticket_id: '1003' }),
],
});
createEntityMock
.mockResolvedValueOnce({ id: 1 })
.mockRejectedValueOnce(new Error('Autotask API unavailable'))
.mockResolvedValueOnce({ id: 3 });
const result = await generateAndPostAccidentalReportNote('campaign-1');
expect(createEntityMock).toHaveBeenCalledTimes(3);
expect(result.tickets[0]).toEqual({ ticketId: '1001', posted: true });
expect(result.tickets[1]).toMatchObject({ ticketId: '1002', posted: false });
expect(result.tickets[1].error).toBe('Autotask API unavailable');
expect(result.tickets[2]).toEqual({ ticketId: '1003', posted: true });
});
it('resolves { noteText, tickets: [] } for a campaign with zero linked reports, without throwing', async () => {
stage({ reports: [] });
const result = await generateAndPostAccidentalReportNote('campaign-empty');
expect(result.tickets).toEqual([]);
expect(typeof result.noteText).toBe('string');
expect(result.noteText.length).toBeGreaterThan(0);
expect(createEntityMock).not.toHaveBeenCalled();
});
});