Files
TREK/server/tests/unit/nest/storage/storage-release.job.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

86 lines
3.8 KiB
TypeScript

/**
* StorageReleaseJob: every ten minutes, the due rows of the release queue.
* It hands the registrar the tick's own promise, so the lease covers the whole
* tick, logs at debug only, and contains a failure in one error line.
* RELJ-001 to RELJ-004.
*/
import { logDebug, logError } from '../../../../src/nest/audit/audit-log.logger';
import type { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
import { StorageReleaseJob } from '../../../../src/nest/storage/storage-release.job';
import type { ReleaseTickResult, StorageReleaseService } from '../../../../src/nest/storage/storage-release.service';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
function makeJob(processDue: () => Promise<ReleaseTickResult>, enabled = true) {
const registrar = {
isEnabled: vi.fn(() => enabled),
register: vi.fn((_name: string, _expression: string, _onTick: () => void | Promise<void>) => enabled),
};
const release = { processDue: vi.fn(processDue) };
const job = new StorageReleaseJob(
registrar as unknown as CronRegistrarService,
release as unknown as StorageReleaseService,
);
return { job, registrar, release };
}
const ran = (deleted: number, kept: number): ReleaseTickResult => ({ skipped: null, deleted, kept });
beforeEach(() => vi.clearAllMocks());
describe('StorageReleaseJob', () => {
it('RELJ-001: registers every ten minutes and hands the registrar the tick promise itself', async () => {
let finish: (value: ReleaseTickResult) => void = () => undefined;
const { job, registrar, release } = makeJob(() => new Promise((resolve) => (finish = resolve)));
job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('storage-release', '*/10 * * * *', expect.any(Function));
const [call] = registrar.register.mock.calls;
if (!call) throw new Error('the job registered nothing');
const ticking = call[2]();
expect(ticking).toBeInstanceOf(Promise);
let settled = false;
void (ticking as Promise<void>).then(() => (settled = true));
await Promise.resolve();
expect(settled).toBe(false);
finish(ran(0, 0));
await ticking;
expect(release.processDue).toHaveBeenCalledTimes(1);
});
it('RELJ-002: does not register under the test gate', () => {
const { job, registrar, release } = makeJob(async () => ran(0, 0), false);
job.onApplicationBootstrap();
expect(registrar.register).not.toHaveBeenCalled();
expect(release.processDue).not.toHaveBeenCalled();
});
it('RELJ-003: says at debug what a tick did or why it skipped, and nothing for an empty one', async () => {
const quiet = makeJob(async () => ran(0, 0));
await quiet.job.tick(new Date(5));
expect(quiet.release.processDue).toHaveBeenCalledWith(new Date(5));
expect(logDebug).not.toHaveBeenCalled();
await makeJob(async () => ran(3, 1)).job.tick();
expect(logDebug).toHaveBeenCalledWith('Storage release: deleted 3, kept 1 still named');
await makeJob(async () => ({ skipped: 'busy', deleted: 0, kept: 0 })).job.tick();
expect(logDebug).toHaveBeenCalledWith('Storage release skipped: busy');
expect(logError).not.toHaveBeenCalled();
});
it('RELJ-004: a failure is contained in one error line', async () => {
await makeJob(async () => {
throw new Error('registry not initialized');
}).job.tick();
expect(logError).toHaveBeenCalledWith('Storage release: registry not initialized');
await makeJob(vi.fn<() => Promise<ReleaseTickResult>>().mockRejectedValue('plain')).job.tick();
expect(logError).toHaveBeenCalledWith('Storage release: plain');
});
});