mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
1c6a175ebc
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.
132 lines
5.9 KiB
TypeScript
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();
|
|
});
|
|
});
|