import { Pool } from 'pg'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { AssessmentProject } from '../../src/domain/canonical-model'; import type { ImportArtifact, IngestionJob } from '../../src/domain/ingestion-persistence'; import type { ArtifactDeletionRequest } from '../../src/persistence/assessor-repository'; import { runDatabaseMigrations } from '../../server/postgres/migration-runner'; import { PostgresAssessorRepository } from '../../server/postgres/postgres-assessor-repository'; const connectionString = process.env.ASSESSOR_TEST_DATABASE_URL; const describePostgres = connectionString ? describe : describe.skip; const timestamp = '2026-07-21T12:00:00.000Z'; describePostgres('PostgreSQL failed GPO backup management', () => { let pool: Pool; let repository: PostgresAssessorRepository; beforeAll(async () => { pool = new Pool({ connectionString, max: 2, connectionTimeoutMillis: 5_000 }); await runDatabaseMigrations(pool); repository = await PostgresAssessorRepository.create(pool); }, 30_000); afterAll(async () => { await pool?.end(); }); it('requeues dead-letter work and deletes another failed artifact through the durable outbox', async () => { const project: AssessmentProject = { id: 'pg-gpo-management-project', name: 'Synthetic GPO management', createdAt: timestamp, updatedAt: timestamp, attributes: {} }; await pool.query('DELETE FROM assessor_projects WHERE id = $1', [project.id]); await repository.saveProject(project); const artifact = (suffix: string): ImportArtifact => ({ version: 1, id: `pg-gpo-management-artifact-${suffix}`, projectId: project.id, kind: 'source-file', relativePath: `gpo-backups/synthetic-${suffix}.zip`, sha256: suffix.repeat(64).slice(0, 64), sizeBytes: 10, sensitivity: 'secret', parseStatus: 'failed', storage: { kind: 'backend-controlled', storageKey: `synthetic-gpo-management-${suffix}` }, createdAt: timestamp }); const job = (source: ImportArtifact, suffix: string): IngestionJob => ({ version: 1, id: `pg-gpo-management-job-${suffix}`, projectId: project.id, artifactId: source.id, kind: 'analyze-gpo-backup', idempotencyKey: `synthetic-gpo-management-${suffix}`, status: 'queued', attempts: 0, maxAttempts: 3, availableAt: timestamp, createdAt: timestamp, updatedAt: timestamp }); const exhaust = async (jobId: string) => { for (let attempt = 1; attempt <= 3; attempt += 1) { const now = `2026-07-21T12:00:0${attempt}.000Z`; await repository.claimNextIngestionJob({ workerId: 'pg-gpo-management-worker', now, leaseDurationMs: 1_000, kinds: ['analyze-gpo-backup'] }); await repository.failIngestionJob(jobId, 'pg-gpo-management-worker', now, 'gpo-inventory-memory-limit', now); } }; const retryArtifact = artifact('a'); await repository.saveImportArtifactAndEnqueueJob(retryArtifact, job(retryArtifact, 'a')); await exhaust('pg-gpo-management-job-a'); await expect(repository.retryIngestionJob(project.id, 'pg-gpo-management-job-a', '2026-07-21T12:01:00.000Z')) .resolves.toMatchObject({ status: 'queued', attempts: 0, availableAt: '2026-07-21T12:01:00.000Z' }); const deleteArtifact = artifact('b'); await repository.saveImportArtifactAndEnqueueJob(deleteArtifact, job(deleteArtifact, 'b')); await exhaust('pg-gpo-management-job-b'); const request: ArtifactDeletionRequest = { version: 1, id: 'pg-gpo-management-delete-b', projectId: project.id, artifactId: deleteArtifact.id, storageKey: 'synthetic-gpo-management-b', attempts: 0, nextAttemptAt: timestamp, createdAt: timestamp }; await expect(repository.deleteFailedGpoArtifact(project.id, deleteArtifact.id, request)) .resolves.toMatchObject({ artifact: { id: deleteArtifact.id }, request: { id: request.id } }); await expect(repository.listImportArtifacts(project.id)).resolves.toEqual([retryArtifact]); await expect(repository.listArtifactDeletionRequests(timestamp, 10)).resolves.toEqual([request]); await repository.completeArtifactDeletionRequest(request.id); await expect(repository.listArtifactDeletionRequests('2026-07-21T12:02:00.000Z', 10)).resolves.toEqual([]); await repository.deleteProject(project.id, { projectId: project.id, cascade: true }); }, 30_000); });