Files
TREK/server/tests/unit/nest/storage/storage-reclaim.controller.test.ts
T
Maurice 1c6a175ebc fix(storage): queue what a delete left unnamed past every undo, and tell a copied database apart
A delete or a replace no longer deletes inside the request. The owner's call
queues the keys in storage_release_queue, due after RELEASE_DELAY_MS (26 h:
the assistant's 24 h approval window plus a margin), and StorageReleaseJob
takes the due rows every ten minutes, at most 200 a tick and 50 keys a
question, asks every reference source again and deletes what none names.
It refuses what the nightly pass refuses (StorageReclaimGuardService: the
ledger, the required sources, a shared location, another installation's
owner marker, cached per category), and waits while a restore or a backup
runs or another storage job holds the slot. An assistant undo or a late
write that names the old value again keeps its object.

The install id lives in data/.trek-install-id, outside any backup and never
written by a restore. At boot and after every reopen the database is
reconciled with it: a database naming another installation (a boot restore,
a restart after a failed restore, a copied travel.db) loses its switch,
confirmations, reports, marks and queue before anything reads them.

A confirmation now stores the count the admin was shown and when; it covers
up to one and a half times that count (at least 100 more) for 14 days, is
used up once the share is back under a fifth, and a count the last report
no longer holds is refused as reclaim_confirm_stale. The admin state folds
a live confirmation in, so the prompt goes at once.

A pass reads each source once for every category and drops each category's
keys once judged; right before a batch it reads the category's sources
again, whole and in memory, instead of a LIKE per key. A narrowed read holds
at most 50 LIKE terms. A pass held off by a backup reports backup_running,
and a category the ledger never reclaims reports not_reclaimed even after a
stop.
2026-11-01 18:11:43 +01:00

132 lines
5.9 KiB
TypeScript

/**
* StorageReclaimController over a stub service: what it forwards, what it
* audits, and that a refusal of the service reaches the client unchanged.
* RCLC-001 to RCLC-007.
*/
import type { AuditService } from '../../../../src/nest/audit/audit.service';
import { DomainError } from '../../../../src/nest/common/domain-error';
import type {
StorageReclaimConfirmDto,
StorageReclaimSettingsDto,
} from '../../../../src/nest/storage/storage-admin.dto';
import { StorageReclaimController } from '../../../../src/nest/storage/storage-reclaim.controller';
import type { StorageReclaimService } from '../../../../src/nest/storage/storage-reclaim.service';
import { StorageModule } from '../../../../src/nest/storage/storage.module';
import type { User } from '../../../../src/types';
import { expectRegisteredController } from '../../../helpers/module-providers';
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimReport, type StorageReclaimState } from '@trek/shared';
import type { Request } from 'express';
import { describe, expect, it, vi } from 'vitest';
const user = { id: 7 } as User;
const req = { headers: {}, socket: { remoteAddress: '127.0.0.1' } } as unknown as Request;
function report(): StorageReclaimReport {
const none = { objects: 0, bytes: 0 };
const row = {
objects: 0,
unreferenced: none,
eligible: none,
deleted: none,
confirmationRequired: false,
skipped: null,
};
return {
startedAt: 1,
finishedAt: 2,
mode: 'report',
stopped: null,
categories: {
...(Object.fromEntries(STORAGE_RECLAIM_CATEGORIES.map((c) => [c, row])) as StorageReclaimReport['categories']),
covers: { ...row, objects: 9, unreferenced: { objects: 4, bytes: 40 }, confirmationRequired: true },
},
};
}
function state(over: Partial<StorageReclaimState> = {}): StorageReclaimState {
return { deleteEnabled: false, running: false, lastRun: null, lastDeleteRun: null, ...over };
}
function makeController(over: Partial<Record<keyof StorageReclaimService, unknown>> = {}) {
const writeAudit = vi.fn();
const service = {
setDeleteEnabled: vi.fn(async (enabled: boolean) => state({ deleteEnabled: enabled })),
start: vi.fn(async () => undefined),
confirm: vi.fn(async () => state({ lastRun: report() })),
...over,
} as unknown as StorageReclaimService;
const controller = new StorageReclaimController(service, { writeAudit } as unknown as AuditService);
return { controller, service, writeAudit };
}
describe('StorageReclaimController', () => {
it('RCLC-001: is registered in StorageModule', () => {
expectRegisteredController(StorageModule, StorageReclaimController);
});
it('RCLC-002: PUT stores the switch, answers the fresh state and audits the value', async () => {
const { controller, service, writeAudit } = makeController();
const body = { deleteEnabled: true } as StorageReclaimSettingsDto;
expect(await controller.update(user, body, req)).toEqual(state({ deleteEnabled: true }));
expect(service.setDeleteEnabled).toHaveBeenCalledWith(true);
expect(writeAudit).toHaveBeenCalledWith(
expect.objectContaining({ userId: 7, action: 'admin.storage_reclaim_update', details: { deleteEnabled: true } }),
);
});
it('RCLC-003: POST run starts the check in the background, answers at once and audits the start', async () => {
const { controller, service, writeAudit } = makeController();
expect(await controller.run(user, req)).toEqual({ started: true });
expect(service.start).toHaveBeenCalledTimes(1);
expect(writeAudit).toHaveBeenCalledWith(
expect.objectContaining({ userId: 7, action: 'admin.storage_reclaim_run', details: { mode: 'report' } }),
);
});
it('RCLC-004: a refused start propagates as the DomainError and writes no audit row', async () => {
const refusal = new DomainError(409, 'another storage job is running, one storage job at a time', {
code: 'reclaim_busy',
});
const { controller, writeAudit } = makeController({
start: vi.fn().mockRejectedValue(refusal),
});
await expect(controller.run(user, req)).rejects.toBe(refusal);
expect(writeAudit).not.toHaveBeenCalled();
});
it('RCLC-005: POST confirm forwards the category and the count shown, answers the state and audits that count', async () => {
const { controller, service, writeAudit } = makeController();
const body = { category: 'covers', unreferenced: 4 } as StorageReclaimConfirmDto;
const answer = await controller.confirm(user, body, req);
expect(answer.lastRun?.categories.covers.confirmationRequired).toBe(true);
expect(service.confirm).toHaveBeenCalledWith('covers', 4);
expect(writeAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'admin.storage_reclaim_confirm',
details: { category: 'covers', unreferenced: 4 },
}),
);
});
it('RCLC-006: a confirmation with nothing waiting propagates its 409 and writes no audit row', async () => {
const refusal = new DomainError(409, 'nothing in this category waits for a confirmation', {
code: 'reclaim_nothing_to_confirm',
});
const { controller, writeAudit } = makeController({ confirm: vi.fn().mockRejectedValue(refusal) });
const body = { category: 'files', unreferenced: 3 } as StorageReclaimConfirmDto;
await expect(controller.confirm(user, body, req)).rejects.toBe(refusal);
expect(writeAudit).not.toHaveBeenCalled();
});
it('RCLC-007: a count the last report no longer holds propagates its stale 409 and writes no audit row', async () => {
const refusal = new DomainError(409, 'the last check found another count since, reload and confirm again', {
code: 'reclaim_confirm_stale',
});
const { controller, writeAudit } = makeController({ confirm: vi.fn().mockRejectedValue(refusal) });
const body = { category: 'covers', unreferenced: 400 } as StorageReclaimConfirmDto;
await expect(controller.confirm(user, body, req)).rejects.toBe(refusal);
expect(writeAudit).not.toHaveBeenCalled();
});
});