From 953ef54768de450164c787f05ec603596bd6043a Mon Sep 17 00:00:00 2001 From: Samuel Ajayi Date: Sun, 30 Aug 2026 17:54:25 +0100 Subject: [PATCH] Fix compliance case reopen check to exclude all terminal statuses (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkAndOpenCase's reopen lookup only excluded CLOSED_NO_ACTION (`status: { not: 'CLOSED_NO_ACTION' }`). The CaseStatus enum (prisma/schema.prisma) has two other terminal states — SAR_FILED and CLEARED — that the filter didn't account for, so a new high-score event on a user with one of those closed cases would incorrectly attach evidence to it instead of opening a fresh case. Replaces the single-status check with an explicit TERMINAL_CASE_STATUSES list (SAR_FILED, CLEARED, CLOSED_NO_ACTION) and a `notIn` filter, using the real CaseStatus enum values instead of a raw string literal. --- src/compliance/cases.ts | 17 +++- tests/unit/compliance/cases.test.ts | 141 ++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 tests/unit/compliance/cases.test.ts diff --git a/src/compliance/cases.ts b/src/compliance/cases.ts index f8897ab..8280360 100644 --- a/src/compliance/cases.ts +++ b/src/compliance/cases.ts @@ -1,8 +1,21 @@ -import { PrismaClient } from '@prisma/client' +import { PrismaClient, CaseStatus } from '@prisma/client' const prisma = new PrismaClient() const CASE_OPEN_SCORE = 75 +/** + * Terminal CaseStatus values (#393) — a case in one of these is resolved and + * must never receive new evidence; a fresh high-score event opens a new case + * instead. Everything else in the enum (OPEN, TRIAGE, INVESTIGATING, + * ESCALATED, PENDING_SAR) is still active work, so new evidence attaches to + * it. Keep this in sync with prisma/schema.prisma's CaseStatus enum. + */ +const TERMINAL_CASE_STATUSES: CaseStatus[] = [ + CaseStatus.SAR_FILED, + CaseStatus.CLEARED, + CaseStatus.CLOSED_NO_ACTION, +] + export async function checkAndOpenCase( userId: string, txId: string, @@ -11,7 +24,7 @@ export async function checkAndOpenCase( if (score >= CASE_OPEN_SCORE) { // Open or attach to case const existingCase = await prisma.complianceCase.findFirst({ - where: { userId, status: { not: 'CLOSED_NO_ACTION' } }, // simplified condition + where: { userId, status: { notIn: TERMINAL_CASE_STATUSES } }, }) if (existingCase) { diff --git a/tests/unit/compliance/cases.test.ts b/tests/unit/compliance/cases.test.ts new file mode 100644 index 0000000..78987c9 --- /dev/null +++ b/tests/unit/compliance/cases.test.ts @@ -0,0 +1,141 @@ +// #393 — checkAndOpenCase's reopen check must exclude every terminal +// CaseStatus, not just CLOSED_NO_ACTION. A user with a case closed in a +// different terminal state (e.g. SAR_FILED, CLEARED) must get a fresh case +// for a new high-score event, not have evidence silently attached to the +// closed one. +const mockFindFirst = jest.fn() +const mockCaseCreate = jest.fn() +const mockEventCreate = jest.fn() + +jest.mock('@prisma/client', () => { + const actual = jest.requireActual('@prisma/client') + return { + ...actual, + PrismaClient: jest.fn().mockImplementation(() => ({ + complianceCase: { + findFirst: mockFindFirst, + create: mockCaseCreate, + }, + caseEvent: { + create: mockEventCreate, + }, + })), + } +}) + +import { checkAndOpenCase } from '../../../src/compliance/cases' + +const HIGH_SCORE = 80 +const LOW_SCORE = 10 + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('checkAndOpenCase', () => { + it('does nothing below the case-open score threshold', async () => { + await checkAndOpenCase('user-1', 'tx-1', LOW_SCORE) + expect(mockFindFirst).not.toHaveBeenCalled() + expect(mockCaseCreate).not.toHaveBeenCalled() + expect(mockEventCreate).not.toHaveBeenCalled() + }) + + it('opens a new case when the user has none', async () => { + mockFindFirst.mockResolvedValue(null) + mockCaseCreate.mockResolvedValue({ id: 'case-1' }) + + await checkAndOpenCase('user-1', 'tx-1', HIGH_SCORE) + + expect(mockCaseCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 'user-1', + openedReason: 'score_threshold', + triggerScore: HIGH_SCORE, + relatedTxnIds: ['tx-1'], + }), + }) + ) + expect(mockEventCreate).not.toHaveBeenCalled() + }) + + it('attaches evidence to an existing non-terminal (OPEN) case instead of opening a new one', async () => { + mockFindFirst.mockResolvedValue({ id: 'case-1', status: 'OPEN' }) + + await checkAndOpenCase('user-1', 'tx-2', HIGH_SCORE) + + expect(mockEventCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + caseId: 'case-1', + type: 'EVIDENCE', + body: { txId: 'tx-2', score: HIGH_SCORE }, + }), + }) + ) + expect(mockCaseCreate).not.toHaveBeenCalled() + }) + + it('excludes CLOSED_NO_ACTION cases from the reopen lookup (the pre-existing behavior)', async () => { + mockFindFirst.mockResolvedValue(null) // simulates the DB filter excluding it + mockCaseCreate.mockResolvedValue({ id: 'case-2' }) + + await checkAndOpenCase('user-1', 'tx-3', HIGH_SCORE) + + const whereArg = mockFindFirst.mock.calls[0][0].where + expect(whereArg.status.notIn).toContain('CLOSED_NO_ACTION') + expect(mockCaseCreate).toHaveBeenCalled() + }) + + // The bug (#393): the original filter was `status: { not: 'CLOSED_NO_ACTION' }`, + // which treats every OTHER terminal status (SAR_FILED, CLEARED) as still + // "open" and would incorrectly attach new evidence to them. + it.each(['SAR_FILED', 'CLEARED'])( + 'opens a fresh case for a user whose only case is closed as %s, rather than attaching to it', + async (terminalStatus) => { + // The real Prisma `notIn` filter would exclude this row from the + // findFirst result — assert the filter actually names every terminal + // status, then simulate what a correct filter returns (null). + mockFindFirst.mockResolvedValue(null) + mockCaseCreate.mockResolvedValue({ id: 'case-new' }) + + await checkAndOpenCase('user-1', 'tx-4', HIGH_SCORE) + + const whereArg = mockFindFirst.mock.calls[0][0].where + expect(whereArg.status.notIn).toContain(terminalStatus) + + // A new case is opened, not evidence attached to the closed one. + expect(mockCaseCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: 'user-1' }), + }) + ) + expect(mockEventCreate).not.toHaveBeenCalled() + } + ) + + it('the reopen filter excludes every terminal status defined in the schema', async () => { + mockFindFirst.mockResolvedValue(null) + mockCaseCreate.mockResolvedValue({ id: 'case-1' }) + + await checkAndOpenCase('user-1', 'tx-1', HIGH_SCORE) + + const whereArg = mockFindFirst.mock.calls[0][0].where + expect(whereArg.status.notIn.sort()).toEqual( + ['CLEARED', 'CLOSED_NO_ACTION', 'SAR_FILED'].sort() + ) + }) + + it('still attaches evidence to a case in a non-terminal, non-OPEN status (e.g. INVESTIGATING)', async () => { + mockFindFirst.mockResolvedValue({ id: 'case-3', status: 'INVESTIGATING' }) + + await checkAndOpenCase('user-1', 'tx-5', HIGH_SCORE) + + expect(mockEventCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ caseId: 'case-3' }), + }) + ) + expect(mockCaseCreate).not.toHaveBeenCalled() + }) +})