import { randomUUID } from 'node:crypto'; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { PrismaClient } from '@prisma/client'; import { makeFakePorts } from '@insignia/iios-testkit'; import { resetDb } from '../test-utils/reset-db'; import { ActorResolver } from '../identity/actor.resolver'; import { IngestService } from '../interactions/ingest.service'; import { RetentionService } from './retention.service'; import type { PrismaService } from '../prisma/prisma.service'; const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; const prisma = new PrismaClient({ datasources: { db: { url } } }); const asService = prisma as unknown as PrismaService; const actors = new ActorResolver(asService); const svc = () => new RetentionService(asService); async function ingest(text: string, orgId = 'org_demo'): Promise<{ interactionId: string; scopeId: string }> { const res = await new IngestService(asService, makeFakePorts(), actors).ingest( { scope: { orgId, appId: 'portal-demo' }, channel: { type: 'WEBHOOK', externalChannelId: 'webhook' }, source: { handleKind: 'EXTERNAL_VISITOR', externalId: 'ext-1' }, kind: 'MESSAGE', thread: { externalThreadId: `wh-${randomUUID().slice(0, 6)}` }, parts: [{ kind: 'TEXT', bodyText: text }], occurredAt: new Date().toISOString(), providerEventId: `pe-${randomUUID()}`, }, `ing-${randomUUID()}`, ); const it = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId } }); return { interactionId: res.interactionId, scopeId: it.scopeId }; } const past = new Date(Date.now() - 86_400_000); const future = new Date(Date.now() + 86_400_000); const setSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) => prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'interaction', targetId } }, data }); const bodyOf = async (interactionId: string) => (await prisma.iiosMessagePart.findFirstOrThrow({ where: { interactionId } })).bodyText; beforeAll(async () => { await prisma.$connect(); }); afterAll(async () => { await prisma.$disconnect(); }); beforeEach(async () => { await resetDb(prisma); }); describe('RetentionService (P9 — retention sweep)', () => { it('ensureSnapshots captures one ACTIVE snapshot per interaction with archive < delete', async () => { const { interactionId } = await ingest('hello'); await svc().ensureSnapshots(); const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'interaction', targetId: interactionId } } }); expect(snap.status).toBe('ACTIVE'); expect(snap.dataClass).toBe('internal'); expect(snap.archiveAfter.getTime()).toBeLessThan(snap.deleteAfter.getTime()); }); it('redacts an interaction whose delete window has passed', async () => { const { interactionId } = await ingest('my secret'); await svc().ensureSnapshots(); await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past }); const res = await svc().applySweep(); expect(res.redacted).toBe(1); expect(await bodyOf(interactionId)).toBe('[redacted]'); expect((await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: interactionId } })).status).toBe('REDACTED'); expect((await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'interaction', targetId: interactionId } } })).status).toBe('REDACTED'); expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted' } })).toBe(1); }); it('archives (not deletes) when only the archive window has passed, then deletes later', async () => { const { interactionId } = await ingest('still needed'); await svc().ensureSnapshots(); await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: future }); let res = await svc().applySweep(); expect(res).toMatchObject({ archived: 1, redacted: 0 }); expect(await bodyOf(interactionId)).toBe('still needed'); // content untouched by archive await setSnapshot(interactionId, { deleteAfter: past }); res = await svc().applySweep(); expect(res.redacted).toBe(1); expect(await bodyOf(interactionId)).toBe('[redacted]'); }); it('an active compliance hold blocks the sweep until released', async () => { const { interactionId, scopeId } = await ingest('held content'); await svc().ensureSnapshots(); await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past }); const hold = await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'message', targetId: interactionId, holdReason: 'legal' } }); let res = await svc().applySweep(); expect(res).toMatchObject({ redacted: 0, skippedHeld: 1 }); expect(await bodyOf(interactionId)).toBe('held content'); await prisma.iiosComplianceHold.update({ where: { id: hold.id }, data: { status: 'RELEASED' } }); res = await svc().applySweep(); expect(res.redacted).toBe(1); expect(await bodyOf(interactionId)).toBe('[redacted]'); }); it('is idempotent — a REDACTED snapshot is terminal', async () => { const { interactionId } = await ingest('x'); await svc().ensureSnapshots(); await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past }); await svc().applySweep(); expect(await svc().applySweep()).toMatchObject({ archived: 0, redacted: 0, skippedHeld: 0 }); }); it('sweep is tenant-scoped', async () => { const a = await ingest('tenant a', 'org_A'); const b = await ingest('tenant b', 'org_B'); await svc().ensureSnapshots(); await setSnapshot(a.interactionId, { archiveAfter: past, deleteAfter: past }); await setSnapshot(b.interactionId, { archiveAfter: past, deleteAfter: past }); const res = await svc().applySweep(a.scopeId); expect(res.redacted).toBe(1); expect(await bodyOf(a.interactionId)).toBe('[redacted]'); expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched }); });