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); });