Assessor_2.0/tests/server/gpo-backup-management-servi...

95 lines
5.1 KiB
TypeScript

import { createHash } from 'node:crypto';
import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { FileSystemBackendArtifactStore } from '../../server/artifacts/backend-artifact-store';
import { GpoArtifactReconciler } from '../../server/gpo/gpo-artifact-reconciler';
import { GpoBackupManagementService } from '../../server/gpo/gpo-backup-management-service';
import { GpoBackupSubmissionService } from '../../server/gpo/gpo-backup-submission-service';
import { directGpoInventoryExecutor } from '../../server/gpo/gpo-inventory-executor';
import type { ArtifactDeletionRequest } from '../../src/persistence/assessor-repository';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import { syntheticGpoBackupZip } from './gpo-synthetic-fixture';
const initial = Date.parse('2026-07-21T16:00:00.000Z');
const project = { id: 'gpo-management-project', name: 'Synthetic GPO management', createdAt: new Date(initial).toISOString(), updatedAt: new Date(initial).toISOString(), attributes: {} };
describe('dead-letter GPO management', () => {
let directory: string;
let repository: LocalAssessorRepository;
let store: FileSystemBackendArtifactStore;
let artifactId: string;
let jobId: string;
let currentTime: number;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-gpo-management-'));
repository = new LocalAssessorRepository();
store = new FileSystemBackendArtifactStore(directory);
currentTime = initial;
await repository.saveProject(project);
const submitted = await new GpoBackupSubmissionService(repository, store, directGpoInventoryExecutor, 1024 * 1024).submit({
projectId: project.id, fileName: 'failed.zip', bytes: syntheticGpoBackupZip(), receivedAt: new Date(currentTime).toISOString()
});
artifactId = submitted.artifactId;
jobId = submitted.jobId;
for (let attempt = 0; attempt < 3; attempt += 1) {
const timestamp = new Date(currentTime).toISOString();
await repository.claimNextIngestionJob({ workerId: 'synthetic-worker', now: timestamp, leaseDurationMs: 60_000, kinds: ['analyze-gpo-backup'] });
currentTime += 1_000;
const failedAt = new Date(currentTime).toISOString();
await repository.failIngestionJob(jobId, 'synthetic-worker', failedAt, 'synthetic-memory-limit', failedAt);
}
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('requeues only a dead-letter GPO job and resets its attempt state', async () => {
const management = new GpoBackupManagementService(repository, store, () => new Date(currentTime + 1_000));
await expect(management.retry(project.id, artifactId)).resolves.toMatchObject({ status: 'queued', attempts: 0, jobId });
const jobs = await repository.listIngestionJobs(project.id);
expect(jobs).toMatchObject([{ id: jobId, status: 'queued', attempts: 0 }]);
expect(jobs[0]).not.toHaveProperty('lastErrorCode');
await expect(management.retry(project.id, artifactId)).rejects.toThrow('Nur ein endgültig fehlgeschlagener');
});
it('deletes database history and the retained package through the persistent outbox', async () => {
const management = new GpoBackupManagementService(repository, store, () => new Date(currentTime + 1_000));
await expect(management.deleteFailed(project.id, artifactId)).resolves.toMatchObject({
artifactId, deleted: true, storageRemoval: 'complete'
});
await expect(repository.listImportArtifacts(project.id)).resolves.toEqual([]);
await expect(repository.listIngestionJobs(project.id)).resolves.toEqual([]);
await expect(repository.listArtifactDeletionRequests(new Date(currentTime + 2_000).toISOString(), 10)).resolves.toEqual([]);
await expect(readdir(join(directory, 'objects'))).resolves.toEqual([]);
});
it('finishes a deletion left between the database commit and storage removal after restart', async () => {
const artifact = (await repository.listImportArtifacts(project.id))[0];
if (artifact.storage.kind !== 'backend-controlled') throw new Error('Synthetic artifact storage missing.');
const timestamp = new Date(currentTime + 1_000).toISOString();
const request: ArtifactDeletionRequest = {
version: 1,
id: `artifact-delete-${createHash('sha256').update(artifact.storage.storageKey).digest('hex').slice(0, 32)}`,
projectId: project.id,
artifactId,
storageKey: artifact.storage.storageKey,
attempts: 0,
nextAttemptAt: timestamp,
createdAt: timestamp
};
await repository.deleteFailedGpoArtifact(project.id, artifactId, request);
await expect(readdir(join(directory, 'objects'))).resolves.toHaveLength(1);
const result = await new GpoArtifactReconciler(repository, store, {
gracePeriodMs: 60_000, maximumRemovals: 10, now: () => new Date(currentTime + 2_000)
}).run();
expect(result).toMatchObject({ completedDeletionRequests: 1, pendingDeletionRequests: 0 });
await expect(readdir(join(directory, 'objects'))).resolves.toEqual([]);
});
});