Files
mercury 4ac71915fb
Quality gates / quality (push) Successful in 29m22s
fix(portability): report only the upgrade in the import notice (#35)
The import screen already states what it restored, and the migration notice
restated the same count, so the on-screen notice read as two stacked sentences
saying one thing. The notice now only describes the upgrade, and a unit test
fails if it starts restating the restored count again.
2026-10-22 12:20:31 +00:00

153 lines
6.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { LEDGER_EXPORT_VERSION, LEDGER_IMPORT_LIMITS, exportLedger, exportLedgerBackup, importLedger, importLedgerBackup } from '../src/domain.js';
// Issue #35 — a backup is the person's own record. Provenance is part of what they own: a log that
// was saved from a confirmed or corrected AI suggestion has to come back out of the export saying
// exactly that, instead of silently reverting to looking like a manual entry.
const confirmedByUser = {
id: 'demo-corrected',
occurredAt: '2026-08-18T10:00:00.000Z',
bristolType: 2,
color: 'brown',
urgency: 1,
discomfort: 0,
note: 'synthetic',
photoDataUrl: '',
symptoms: {},
provenance: {
source: 'ai-corrected',
model: 'SmolVLM2-2.2B-Instruct',
suggestedBristolType: 4,
suggestedColor: 'brown',
confirmedAt: '2026-08-18T09:59:00.000Z',
},
};
test('an export stamps the current schema version and keeps confirmed AI provenance', () => {
const parsed = JSON.parse(exportLedger([confirmedByUser], '2026-08-18T11:00:00.000Z'));
assert.equal(LEDGER_EXPORT_VERSION, 2);
assert.equal(parsed.schemaVersion, 2);
assert.equal(parsed.entries.length, 1);
assert.deepEqual(parsed.entries[0].provenance, confirmedByUser.provenance);
assert.equal(parsed.entries[0].bristolType, 2, 'the corrected value stays the saved value');
});
// A version 1 file was written before entries recorded provenance. Importing it must restore the
// confirmed values and say plainly that this backup predates provenance, rather than inventing a
// source for logs that never had one.
const legacyBackup = JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: 1,
exportedAt: '2026-08-01T09:00:00.000Z',
entries: [
{
id: 'legacy-entry',
occurredAt: '2026-08-01T08:00:00.000Z',
bristolType: 4,
color: 'brown',
urgency: 2,
discomfort: 1,
note: '',
photoDataUrl: '',
symptoms: { blood: false },
},
],
});
test('a version 1 backup imports its confirmed values and reports the upgrade', () => {
const result = importLedgerBackup(legacyBackup);
assert.equal(result.fromVersion, 1);
assert.equal(result.migrated, true);
assert.equal(result.entries.length, 1);
assert.equal(result.entries[0].id, 'legacy-entry');
assert.equal(result.entries[0].bristolType, 4);
assert.equal(result.entries[0].provenance, null, 'a pre-provenance backup must not be given a source it never recorded');
assert.match(result.notices.join(' '), /version 1/i);
assert.match(result.notices.join(' '), /upgraded/i);
// The import screen already reports what was restored; a notice that restates the count renders as
// a duplicated sentence on screen, so the upgrade notice must only describe the upgrade.
assert.doesNotMatch(result.notices.join(' '), /Restored \d+ logs?/);
assert.equal(importLedger(legacyBackup).length, 1, 'the plain entry import still works');
});
// A training contribution receipt is user-owned data too. A backup that restored the journal but
// dropped the receipts would tell someone their opt-in never happened, so version 2 carries them —
// with the withdrawal tombstone intact and nothing else copied out of the device.
const withdrawnReceipt = {
contributionId: 'contrib-1',
entryId: 'demo-corrected',
consentVersion: 'training-consent-v1',
consentedAt: '2026-08-18T10:05:00.000Z',
labels: { bristolType: 2, color: 'brown' },
state: 'withdrawn',
withdrawnAt: '2026-08-18T10:30:00.000Z',
artifacts: [
{ artifact: 'raw-image', contributionId: 'contrib-1', state: 'withdrawn' },
{ artifact: 'confirmed-label', contributionId: 'contrib-1', state: 'withdrawn' },
],
// A crafted file must not be able to hand a credential back to the app.
agentAccessCode: 'leak-me-please',
};
test('a backup carries contribution receipts and their tombstones without copying secrets', () => {
const text = exportLedgerBackup({
entries: [{ ...confirmedByUser, agentAccessCode: 'leak-me-please' }],
contributions: [withdrawnReceipt],
exportedAt: '2026-08-18T12:00:00.000Z',
});
const parsed = JSON.parse(text);
assert.equal(parsed.schemaVersion, 2);
assert.equal(parsed.contributions.length, 1);
assert.equal(parsed.contributions[0].state, 'withdrawn');
assert.deepEqual(parsed.contributions[0].artifacts, withdrawnReceipt.artifacts);
assert.equal(parsed.entries[0].agentAccessCode, undefined);
assert.doesNotMatch(text, /leak-me-please/);
const restored = importLedgerBackup(text);
assert.equal(restored.migrated, false);
assert.equal(restored.fromVersion, 2);
assert.equal(restored.contributions.length, 1);
assert.equal(restored.contributions[0].contributionId, 'contrib-1');
assert.equal(restored.contributions[0].state, 'withdrawn');
assert.deepEqual(restored.contributions[0].artifacts, withdrawnReceipt.artifacts);
assert.equal(restored.contributions[0].agentAccessCode, undefined);
assert.deepEqual(restored.entries[0].provenance, confirmedByUser.provenance);
});
test('a backup from a newer Timmy is refused with an instruction instead of a guess', () => {
const future = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 3, entries: [], contributions: [] });
assert.throws(() => importLedgerBackup(future), /newer version of Timmy/i);
});
test('a file that is not a readable Timmy backup is refused', () => {
assert.throws(() => importLedgerBackup('{not json'), /not readable JSON/i);
assert.throws(() => importLedgerBackup(JSON.stringify({ hello: 'world' })), /not a supported Timmy backup/i);
assert.throws(() => importLedgerBackup(JSON.stringify({ schemaVersion: 0, entries: [] })), /not a supported Timmy backup/i);
});
test('an oversized backup or one with too many logs is refused before anything is imported', () => {
const huge = `{"schemaVersion":2,"entries":[]}${' '.repeat(LEDGER_IMPORT_LIMITS.maxTextLength)}`;
assert.throws(() => importLedgerBackup(huge), /too large/i);
const many = { schemaVersion: 2, entries: Array.from({ length: LEDGER_IMPORT_LIMITS.maxEntries + 1 }, () => ({ id: 'x', bristolType: 4 })) };
assert.throws(() => importLedgerBackup(JSON.stringify(many)), /more logs than Timmy will import/i);
});
test('an unreadable contribution receipt is skipped and reported, never silently dropped', () => {
const text = JSON.stringify({
schemaVersion: 2,
entries: [{ id: 'kept', bristolType: 4 }],
contributions: [{ entryId: 'kept', state: 'queued-local' }],
});
const result = importLedgerBackup(text);
assert.equal(result.contributions.length, 0);
assert.match(result.notices.join(' '), /could not be read and was skipped/i);
});