|
|
@@ -4,7 +4,10 @@ import { createServer } from 'node:http';
|
|
|
import * as path from 'node:path';
|
|
|
import { InterviewConfigSchema } from '../config/schema';
|
|
|
import { createInterviewServer } from './server';
|
|
|
-import { createInterviewService as createRealInterviewService } from './service';
|
|
|
+import {
|
|
|
+ createInterviewService as createRealInterviewService,
|
|
|
+ MAX_RETAINED_ABANDONED,
|
|
|
+} from './service';
|
|
|
import type { InterviewAnswer } from './types';
|
|
|
import { renderInterviewPage } from './ui';
|
|
|
|
|
|
@@ -1894,8 +1897,7 @@ describe('InterviewConfigSchema port validation', () => {
|
|
|
});
|
|
|
|
|
|
describe('interview service abandoned-record retention', () => {
|
|
|
- // Mirrors MAX_RETAINED_ABANDONED in service.ts.
|
|
|
- const RETENTION_CAP = 50;
|
|
|
+ const RETENTION_CAP = MAX_RETAINED_ABANDONED;
|
|
|
|
|
|
async function createInterviewOnSession(
|
|
|
service: ReturnType<typeof createInterviewService>,
|
|
|
@@ -1918,56 +1920,107 @@ describe('interview service abandoned-record retention', () => {
|
|
|
|
|
|
test('evicts oldest abandoned records once the retention cap is exceeded', async () => {
|
|
|
const tempDir = await fs.mkdtemp('/tmp/interview-test-');
|
|
|
- const ctx = createMockContext({ directory: tempDir });
|
|
|
- const service = createInterviewService(ctx);
|
|
|
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
|
|
|
-
|
|
|
- const ids: string[] = [];
|
|
|
- for (let i = 0; i < RETENTION_CAP + 2; i++) {
|
|
|
- ids.push(await createInterviewOnSession(service, ctx, i));
|
|
|
- // Deleting the session abandons the interview, triggering pruning.
|
|
|
+ try {
|
|
|
+ const ctx = createMockContext({ directory: tempDir });
|
|
|
+ const service = createInterviewService(ctx);
|
|
|
+ service.setBaseUrlResolver(async () => 'http://localhost:9999');
|
|
|
+
|
|
|
+ const ids: string[] = [];
|
|
|
+ for (let i = 0; i < RETENTION_CAP + 2; i++) {
|
|
|
+ ids.push(await createInterviewOnSession(service, ctx, i));
|
|
|
+ // Deleting the session abandons the interview, triggering pruning.
|
|
|
+ await service.handleEvent({
|
|
|
+ event: {
|
|
|
+ type: 'session.deleted',
|
|
|
+ properties: { sessionID: `session-${i}` },
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // The two oldest abandoned records are evicted from the registry.
|
|
|
+ await expect(service.getInterviewState(ids[0])).rejects.toThrow(
|
|
|
+ 'Interview not found',
|
|
|
+ );
|
|
|
+ await expect(service.getInterviewState(ids[1])).rejects.toThrow(
|
|
|
+ 'Interview not found',
|
|
|
+ );
|
|
|
+
|
|
|
+ // The most recent abandoned record is retained and still renders.
|
|
|
+ const retained = await service.getInterviewState(ids[ids.length - 1]);
|
|
|
+ expect(retained.mode).toBe('abandoned');
|
|
|
+ } finally {
|
|
|
+ await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('prunes by abandonment order instead of creation order', async () => {
|
|
|
+ const tempDir = await fs.mkdtemp('/tmp/interview-test-');
|
|
|
+ try {
|
|
|
+ const ctx = createMockContext({ directory: tempDir });
|
|
|
+ const service = createInterviewService(ctx);
|
|
|
+ service.setBaseUrlResolver(async () => 'http://localhost:9999');
|
|
|
+
|
|
|
+ const oldActiveId = await createInterviewOnSession(service, ctx, 0);
|
|
|
+ const abandonedIds: string[] = [];
|
|
|
+
|
|
|
+ for (let i = 1; i <= RETENTION_CAP; i++) {
|
|
|
+ const id = await createInterviewOnSession(service, ctx, i);
|
|
|
+ abandonedIds.push(id);
|
|
|
+ await service.handleEvent({
|
|
|
+ event: {
|
|
|
+ type: 'session.deleted',
|
|
|
+ properties: { sessionID: `session-${i}` },
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Abandoning the old active interview after the cap is full should retain
|
|
|
+ // that newly abandoned record and prune the earliest previously abandoned
|
|
|
+ // record. Its older createdAt must not make it the eviction candidate.
|
|
|
await service.handleEvent({
|
|
|
event: {
|
|
|
type: 'session.deleted',
|
|
|
- properties: { sessionID: `session-${i}` },
|
|
|
+ properties: { sessionID: 'session-0' },
|
|
|
},
|
|
|
});
|
|
|
- }
|
|
|
|
|
|
- // The two oldest abandoned records are evicted from the registry.
|
|
|
- await expect(service.getInterviewState(ids[0])).rejects.toThrow(
|
|
|
- 'Interview not found',
|
|
|
- );
|
|
|
- await expect(service.getInterviewState(ids[1])).rejects.toThrow(
|
|
|
- 'Interview not found',
|
|
|
- );
|
|
|
+ await expect(service.getInterviewState(abandonedIds[0])).rejects.toThrow(
|
|
|
+ 'Interview not found',
|
|
|
+ );
|
|
|
|
|
|
- // The most recent abandoned record is retained and still renders.
|
|
|
- const retained = await service.getInterviewState(ids[ids.length - 1]);
|
|
|
- expect(retained.mode).toBe('abandoned');
|
|
|
+ const oldActiveState = await service.getInterviewState(oldActiveId);
|
|
|
+ expect(oldActiveState.mode).toBe('abandoned');
|
|
|
|
|
|
- await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
+ const latestPreviouslyAbandoned = await service.getInterviewState(
|
|
|
+ abandonedIds[abandonedIds.length - 1],
|
|
|
+ );
|
|
|
+ expect(latestPreviouslyAbandoned.mode).toBe('abandoned');
|
|
|
+ } finally {
|
|
|
+ await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
+ }
|
|
|
});
|
|
|
|
|
|
test('retains abandoned records that stay within the cap', async () => {
|
|
|
const tempDir = await fs.mkdtemp('/tmp/interview-test-');
|
|
|
- const ctx = createMockContext({ directory: tempDir });
|
|
|
- const service = createInterviewService(ctx);
|
|
|
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
|
|
|
-
|
|
|
- const id = await createInterviewOnSession(service, ctx, 0);
|
|
|
- await service.handleEvent({
|
|
|
- event: {
|
|
|
- type: 'session.deleted',
|
|
|
- properties: { sessionID: 'session-0' },
|
|
|
- },
|
|
|
- });
|
|
|
+ try {
|
|
|
+ const ctx = createMockContext({ directory: tempDir });
|
|
|
+ const service = createInterviewService(ctx);
|
|
|
+ service.setBaseUrlResolver(async () => 'http://localhost:9999');
|
|
|
|
|
|
- // Below the cap, the abandoned record is kept so an open tab can still
|
|
|
- // render its final state.
|
|
|
- const state = await service.getInterviewState(id);
|
|
|
- expect(state.mode).toBe('abandoned');
|
|
|
+ const id = await createInterviewOnSession(service, ctx, 0);
|
|
|
+ await service.handleEvent({
|
|
|
+ event: {
|
|
|
+ type: 'session.deleted',
|
|
|
+ properties: { sessionID: 'session-0' },
|
|
|
+ },
|
|
|
+ });
|
|
|
|
|
|
- await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
+ // Below the cap, the abandoned record is kept so an open tab can still
|
|
|
+ // render its final state.
|
|
|
+ const state = await service.getInterviewState(id);
|
|
|
+ expect(state.mode).toBe('abandoned');
|
|
|
+ } finally {
|
|
|
+ await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
+ }
|
|
|
});
|
|
|
});
|