import test from 'node:test'; import assert from 'node:assert/strict'; import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, estimateLedgerBytes, MAX_IMPORT_BYTES, mergeLedgers, migrateStoredLedger, utf8ByteLength, photoQualityMessage, sanitizeEntry, } from '../src/domain.js'; test('maps Bristol types to clinically grounded buckets', () => { assert.equal(bucketForBristolType(1), 'constipation'); assert.equal(bucketForBristolType(2), 'constipation'); assert.equal(bucketForBristolType(3), 'typical'); assert.equal(bucketForBristolType(4), 'typical'); assert.equal(bucketForBristolType(5), 'loose'); assert.equal(bucketForBristolType(7), 'loose'); assert.equal(bucketForBristolType(0), 'unknown'); }); test('escalates reported blood, black stool, severe pain, vomiting, fever, or inability to pass gas', () => { const result = detectUrgentFlags({ blood: true, blackOrDarkRed: false, severePain: true, vomiting: false, fever: false, cannotPassGas: false, }); assert.equal(result.urgent, true); assert.deepEqual(result.flags, ['blood', 'severePain']); assert.match(result.message, /medical care/i); }); test('does not invent reassurance when no urgent flags are reported', () => { const result = detectUrgentFlags({}); assert.equal(result.urgent, false); assert.deepEqual(result.flags, []); assert.match(result.message, /not a diagnosis/i); }); test('detects common urgent symptom language without matching unrelated blood wording', () => { for (const message of [ 'I have rectal bleeding', 'There is blood in my stool', 'My stool is black', 'I have severe stomach pain', 'I am throwing up and have a fever', 'I threw up', 'I puked twice', 'I am barfing', 'I barfed', 'I hurled', 'She hurls', 'I am upchucking', 'I spewed', 'She spews', 'I tossed my cookies', 'She tosses her cookies', 'He tossed his cookies', 'Someone is tossing their cookies', 'I lost my lunch', 'She loses her lunch', 'He lost his lunch', 'Someone is losing their lunch', 'I have emesis', 'I am unable to pass gas', ]) assert.equal(detectUrgentText(message).urgent, true, message); assert.equal(detectUrgentText('My blood pressure was checked').urgent, false); for (const nonVomiting of [ 'She hurled the javelin across the field.', 'He hurls insults when angry.', 'They are hurling rocks at the wall.', 'He spewed hateful rhetoric.', 'The volcano spews ash.', 'The pipe is spewing water.', ]) assert.equal(detectUrgentText(nonVomiting).urgent, false, nonVomiting); }); test('detects urgent flags or language in confirmed ledger context', () => { assert.equal(hasUrgentLedgerContext([{ symptoms: { cannotPassGas: true }, note: '' }]), true); assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'I have rectal bleeding' }]), true); assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'ordinary entry' }]), false); }); test('Timmy summary reports patterns without clearing food or diagnosing disease', () => { const entries = [ { bristolType: 3, occurredAt: '2026-08-15T08:00:00.000Z' }, { bristolType: 4, occurredAt: '2026-08-16T08:00:00.000Z' }, { bristolType: 6, occurredAt: '2026-08-17T08:00:00.000Z' }, ]; const summary = buildTimmySummary(entries); assert.match(summary, /3 logs/); assert.match(summary, /2 typical/); assert.doesNotMatch(summary, /safe|diagnos|Taco Bell|clear/i); }); test('sanitizes a user entry to the MVP data contract', () => { const entry = sanitizeEntry({ id: 'abc', occurredAt: '2026-08-17T12:00:00.000Z', bristolType: 4, color: 'brown', urgency: 2, discomfort: 1, note: 'After lunch', photoDataUrl: 'data:image/jpeg;base64,abc', unexpected: 'drop me', }); assert.deepEqual(Object.keys(entry).sort(), [ 'bristolType', 'color', 'discomfort', 'id', 'note', 'occurredAt', 'photoDataUrl', 'symptoms', 'urgency' ].sort()); assert.equal(entry.unexpected, undefined); }); test('keeps only a bounded provenance origin and strips smuggled secrets', () => { const entry = sanitizeEntry({ id: 'prov-1', bristolType: 4, provenance: { origin: 'ai-suggestion', suggestedBristolType: 4, apiToken: 'sk-secret-value', sessionId: 'hermes-session-x' }, }); assert.deepEqual(entry.provenance, { origin: 'ai-suggestion' }); assert.doesNotMatch(JSON.stringify(entry), /secret|session/i); }); test('omits provenance entirely when none was recorded', () => { const entry = sanitizeEntry({ id: 'plain-1', bristolType: 3 }); assert.equal(entry.provenance, undefined); }); test('rejects provenance origins outside the recorded vocabulary', () => { for (const bogus of ['clinician', 'self-diagnosis', '']) { const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: bogus } }); assert.equal(entry.provenance, undefined, bogus); } }); test('provenance membership is own-property safe: inherited Object names are not origins', () => { for (const poisoned of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf', 'isPrototypeOf']) { const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: poisoned } }); assert.equal(entry.provenance, undefined, poisoned); assert.doesNotMatch(JSON.stringify(entry), new RegExp(poisoned), poisoned); } // Even a null-prototype provenance carrying a real origin stays acceptable. const nullProto = sanitizeEntry({ id: 'y', bristolType: 4, provenance: Object.assign(Object.create(null), { origin: 'user' }) }); assert.deepEqual(nullProto.provenance, { origin: 'user' }); }); test('photo quality guidance is deterministic and does not claim visual diagnosis', () => { assert.match(photoQualityMessage({ width: 300, height: 300, brightness: 0.5 }), /closer/i); assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.02 }), /light/i); assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.5 }), /review/i); assert.doesNotMatch(photoQualityMessage({ width: 1200, height: 900, brightness: 0.5 }), /type [1-7]|disease|diagnos/i); }); test('export ledger is portable JSON with version and entries', () => { const text = exportLedger([{ id: 'a', bristolType: 4 }], '2026-08-18T00:00:00.000Z'); const parsed = JSON.parse(text); assert.equal(parsed.schemaVersion, 1); assert.equal(parsed.exportedAt, '2026-08-18T00:00:00.000Z'); assert.equal(parsed.entries.length, 1); }); test('import migrates the legacy bare-array ledger to the current versioned envelope', () => { const legacy = JSON.stringify([ { id: 'legacy-1', occurredAt: '2026-08-17T12:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'older export' }, { id: 'legacy-2', bristolType: 7 }, ]); const entries = importLedger(legacy); assert.equal(entries.length, 2); assert.equal(entries[0].id, 'legacy-1'); assert.equal(entries[0].bristolType, 2); assert.equal(entries[0].note, 'older export'); }); test('import accepts every prior schema version and migrates entries forward', () => { for (const version of [0, 1]) { const payload = version === 0 ? [{ id: `v${version}`, bristolType: 3 }] : { product: 'Timmy the Talking Turd', schemaVersion: version, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: `v${version}`, bristolType: 3 }] }; const entries = importLedger(JSON.stringify(payload)); assert.equal(entries.length, 1, `schemaVersion ${version}`); assert.equal(entries[0].bristolType, 3, `schemaVersion ${version}`); } }); test('import fails safely on a newer schema version instead of guessing', () => { for (const schemaVersion of [2, 99]) { assert.throws( () => importLedger(JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion, entries: [{ id: 'x' }] })), error => error instanceof RangeError && /newer Timmy app/i.test(error.message), `schemaVersion ${schemaVersion}`, ); } }); test('import fails safely on malformed or wrong-shaped payloads', () => { for (const payload of [ 'not json', '{"schemaVersion":1,"entries":{}}', '{"entries":[]}', '{"product":"Other App","schemaVersion":1,"entries":[]}', null, 42, ]) { assert.throws(() => importLedger(payload), /not a supported Timmy export/, JSON.stringify(String(payload)).slice(0, 40)); } }); test('import rejects oversized ledgers before parsing user data', () => { const huge = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 1, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: 'x', note: 'n'.repeat(MAX_IMPORT_BYTES + 1024) }] }); assert.ok(huge.length > MAX_IMPORT_BYTES); assert.throws(() => importLedger(huge), RangeError); }); test('utf8ByteLength measures UTF-8 bytes, not UTF-16 code units', () => { assert.equal(utf8ByteLength(''), 0); assert.equal(utf8ByteLength('abc'), 3); // é is 1 UTF-16 unit but 2 UTF-8 bytes; 💩 is 2 UTF-16 units but 4 UTF-8 bytes. assert.equal(utf8ByteLength('é'), 2); assert.equal(utf8ByteLength('💩'), 4); assert.equal(utf8ByteLength('aé💩b'), 1 + 2 + 4 + 1); const emojiBlob = '💩'.repeat(1000); assert.equal(emojiBlob.length, 2000, 'sanity: two code units each'); assert.equal(utf8ByteLength(emojiBlob), 4000); }); test('import rejects oversized payloads by UTF-8 bytes regardless of composition', () => { const asciiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES) + '"}]}'; assert.ok(utf8ByteLength(asciiOver) > MAX_IMPORT_BYTES); assert.throws(() => importLedger(asciiOver), RangeError); // 4 bytes per glyph: byte size crosses the cap at half the code-unit count. const emojiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + '💩'.repeat(Math.ceil(MAX_IMPORT_BYTES / 2)) + '"}]}'; assert.ok(emojiOver.length <= MAX_IMPORT_BYTES * 1.01, 'code-unit count must not be what trips this'); assert.ok(utf8ByteLength(emojiOver) > MAX_IMPORT_BYTES); assert.throws(() => importLedger(emojiOver), RangeError); }); test('import accepts a dense multibyte payload just under the byte cap', () => { // Many small multibyte entries packed deterministically so that both the raw // file and the fully migrated ledger stay inside the total portability // budget (migration expands each row to the strict current schema). const head = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":['; const makeEntry = i => ({ id: `m${i}`, occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕', photoDataUrl: '', symptoms: {} }); const sampleMigrated = JSON.stringify(sanitizeEntry(makeEntry(0))); const perRowRaw = utf8ByteLength(JSON.stringify(makeEntry(0))) + 1; // + comma const perRowMigrated = utf8ByteLength(sampleMigrated) + 1; // Fixed-width ids keep every row the same size, so packing is exact. The // fill factor keeps BOTH documents inside the budget: the migrated ledger // near the cap, the raw file above half of it. const budget = Math.floor((MAX_IMPORT_BYTES * 0.9 - utf8ByteLength(head) - 2) / perRowMigrated); const count = Math.max(1, budget); const payload = `${head}${Array.from({ length: count }, (_, i) => JSON.stringify({ ...makeEntry(i), id: `m${String(i).padStart(8, '0')}` })).join(',')}]}`; assert.ok(utf8ByteLength(payload) <= MAX_IMPORT_BYTES); assert.ok(utf8ByteLength(payload) > MAX_IMPORT_BYTES * 0.5, 'payload must carry real multibyte mass'); const imported = importLedger(payload); assert.equal(imported.length, count, 'every packed entry survives'); assert.ok(estimateLedgerBytes(imported) <= MAX_IMPORT_BYTES, 'migrated total must respect the portability budget'); assert.equal(imported[imported.length - 1].note, 'café ☕'); }); test('a maximal app-produced export with a 4 MiB photo round trips byte-symmetrically', () => { // 4 MiB binary is the app-wide photo ceiling (analysis.js MAX_IMAGE_BYTES). const photoBytes = 4 * 1024 * 1024; let b64 = Buffer.from('a'.repeat(photoBytes)).toString('base64'); const entry = sanitizeEntry({ id: 'big-photo', bristolType: 4, photoDataUrl: `data:image/jpeg;base64,${b64}`, note: 'boundary photo', }); const exported = exportLedger([entry], '2026-08-22T00:00:00.000Z'); assert.ok(utf8ByteLength(exported) <= MAX_IMPORT_BYTES, 'largest producible export must stay inside the import cap'); const roundTripped = importLedger(exported); assert.equal(roundTripped.length, 1); assert.equal(roundTripped[0].photoDataUrl, entry.photoDataUrl, 'photo survives the round trip without silent loss'); }); test('import rejects payloads past the explicit portability ceiling before parsing', () => { const huge = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES + 1024) + '"}]}'; assert.ok(utf8ByteLength(huge) > MAX_IMPORT_BYTES); assert.throws(() => importLedger(huge), RangeError); }); test('merge keeps every distinct record and never duplicates or overwrites user-owned entries', () => { const local = [ { id: 'a', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, note: 'local version of shared id' }, { id: 'b', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'local b', photoDataUrl: '', symptoms: {} }, ]; const incoming = importLedger(JSON.stringify([ { id: 'b', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, color: 'black', urgency: 4, note: 'hostile rewrite of existing id' }, { id: 'c', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 6, note: 'new from file' }, ])); const result = mergeLedgers(local, incoming); assert.equal(result.merged.length, 3, 'one row per unique id'); assert.equal(result.merged.filter(entry => entry.id === 'b').length, 1, 'no duplicate ids'); assert.equal(result.merged.find(entry => entry.id === 'b').note, 'local b', 'existing user-owned entry is never overwritten'); assert.deepEqual(result.added.map(entry => entry.id), ['c'], 'only genuinely new records are added'); assert.deepEqual(result.skippedIds, ['b'], 'collisions are reported explicitly'); // Order stays deterministic: local rows first in their stored order, then additions in incoming order. assert.deepEqual(result.merged.map(entry => entry.id), ['a', 'b', 'c']); }); test('re-importing the same file twice changes nothing (idempotent)', () => { const base = [{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }]; const file = importLedger(JSON.stringify([{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }, { id: 'extra', occurredAt: '2026-08-22T08:30:00.000Z', bristolType: 3 }])); const first = mergeLedgers(base, file); assert.deepEqual(first.added.map(entry => entry.id), ['extra']); const second = mergeLedgers(first.merged, file); assert.equal(second.merged.length, first.merged.length, 'second import adds nothing'); assert.deepEqual(second.added, [], 'second import reports no additions'); assert.deepEqual(second.skippedIds.sort(), ['extra', 'seed'], 'both already-present ids are reported as skipped'); }); test('duplicate ids inside stored data are repaired deterministically, never dropped or silently merged', () => { // Two genuinely different local records that share one id (legacy storage // corruption or a double-save bug) must BOTH survive with distinct stable ids. const local = [ { id: 'twins', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'first twin', photoDataUrl: '', symptoms: {} }, { id: 'twins', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'second twin', photoDataUrl: '', symptoms: {} }, ]; const incoming = importLedger(JSON.stringify([{ id: 'fresh', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 4 }])); const result = mergeLedgers(local, incoming); assert.equal(result.merged.length, 3, 'every distinct record survives a duplicate-id collision'); const ids = result.merged.map(entry => entry.id); assert.equal(new Set(ids).size, 3, 'no duplicate ids remain after repair'); assert.equal(result.merged[0].id, 'twins', 'first-seen record keeps its original id'); const secondTwin = result.merged.find(entry => entry.note === 'second twin'); assert.ok(secondTwin, 'second twin still present'); assert.equal(secondTwin.id, 'twins#2', 'derived id is deterministic, not random'); assert.equal(secondTwin.bristolType, 6, 'repaired record keeps its own data'); assert.deepEqual(result.added.map(entry => entry.id), ['fresh'], 'import still reports additions normally'); // Re-merging the repaired ledger is idempotent: nothing changes, nothing new. const second = mergeLedgers(result.merged, incoming); assert.equal(second.merged.length, 3, 'repaired ledger re-merges without growth'); assert.deepEqual(second.added, []); }); test('repair ids never collide with real records: chained suffixes are skipped', () => { const local = [ { id: 'twins', bristolType: 2, note: 'a' }, { id: 'twins#2', bristolType: 3, note: 'real record that owns the derived slot' }, { id: 'twins', bristolType: 4, note: 'b' }, ]; const result = mergeLedgers(local, []); assert.equal(result.merged.length, 3, 'all three records survive'); const ids = result.merged.map(entry => entry.id); assert.equal(new Set(ids).size, 3, 'derived id must not steal the real record\'s id'); assert.equal(result.merged.find(entry => entry.note === 'real record that owns the derived slot').id, 'twins#2'); assert.equal(result.merged.find(entry => entry.note === 'b').id, 'twins#3', 'duplicate scans past every owned suffix'); }); test('duplicate ids inside one imported file keep exactly the first occurrence', () => { const incoming = importLedger(JSON.stringify([ { id: 'dupe', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'first in file' }, { id: 'dupe', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, note: 'hostile later rewrite' }, ])); const result = mergeLedgers([], incoming); assert.equal(result.merged.length, 1, 'one row per id, first occurrence wins deterministically'); assert.equal(result.merged[0].note, 'first in file'); assert.deepEqual(result.skippedIds, ['dupe'], 'later duplicates are reported, not silently dropped'); }); test('local duplicate ids still win over an incoming file that reuses their id', () => { const local = [ { id: 'twin', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'local twin one', photoDataUrl: '', symptoms: {} }, { id: 'twin', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 3, note: 'local twin two', photoDataUrl: '', symptoms: {} }, ]; const incoming = importLedger(JSON.stringify([ { id: 'twin', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, note: 'hostile import' }, ])); const result = mergeLedgers(local, incoming); assert.equal(result.merged.length, 2, 'both local records survive; hostile import adds nothing'); assert.ok(result.merged.every(entry => entry.note.startsWith('local twin')), 'import never shadows user-owned records'); assert.deepEqual(result.added, []); assert.deepEqual(result.skippedIds, ['twin']); }); test('merge tolerates hostile id types without throwing', () => { const hostile = [ { id: Symbol('sym'), bristolType: 2, note: 'symbol id' }, { id: 123n, bristolType: 3, note: 'bigint id' }, { bristolType: 4, note: 'no id at all' }, ]; let result; assert.doesNotThrow(() => { result = mergeLedgers(hostile, []); }); assert.equal(result.merged.length, 3, 'records with unusable ids are repaired, not dropped'); const ids = result.merged.map(entry => entry.id); assert.equal(new Set(ids).size, 3); assert.ok(ids.every(id => typeof id === 'string' && id !== ''), 'every repaired id is a nonempty string'); }); test('bare top-level arrays are accepted only as a strict Timmy legacy ledger', () => { const legacy = [ { id: 'l1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, note: '' }, { id: 'l2', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 2, color: 'green' }, ]; assert.equal(importLedger(JSON.stringify(legacy)).length, 2, 'genuine legacy exports stay importable'); }); test('ambiguous arrays of unrelated objects are rejected, never defaulted into medical records', () => { for (const payload of [ [{ userId: 7, email: 'person@example.com', preferences: { theme: 'dark' } }], [{ name: 'Alice', role: 'admin' }, { name: 'Bob', role: 'user' }], [{ sku: 'X1', quantity: 3 }], [], [42], ['2026-08-22T09:00:00.000Z'], [null], ]) { let entries; assert.throws( () => { entries = importLedger(JSON.stringify(payload)); }, /not a supported Timmy export/, `must reject: ${JSON.stringify(payload).slice(0, 60)}`, ); assert.equal(entries, undefined, 'no invented defaults may leak from rejected payloads'); } }); test('legacy array rows need a nonempty string id and a Bristol type; one bad row rejects the batch', () => { assert.throws(() => importLedger(JSON.stringify([{ id: 'x' }])), /not a supported Timmy export/, 'missing bristolType'); assert.throws(() => importLedger(JSON.stringify([{ bristolType: 4 }])), /not a supported Timmy export/, 'missing id'); assert.throws(() => importLedger(JSON.stringify([{ id: '', bristolType: 4 }])), /not a supported Timmy export/, 'empty id'); const mixed = JSON.stringify([{ id: 'ok-row', bristolType: 3 }, { foo: 1 }]); assert.throws(() => importLedger(mixed), /not a supported Timmy export/, 'partial acceptance would invent data'); }); test('photos validate canonically: strict grammar, base64 round-trip, decoded size floor, and magic bytes', () => { const jpegBytes = Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]), Buffer.alloc(600, 0x33)]); const pngBytes = Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(600, 0x44)]); const webpBytes = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x60, 0x02, 0x00, 0x00]), Buffer.from('WEBPVP8 '), Buffer.alloc(500, 0x55)]); const url = bytes => `base64:${bytes.toString('base64')}`; const okJpeg = `data:image/jpeg;base64,${jpegBytes.toString('base64')}`; const okPng = `data:image/png;base64,${pngBytes.toString('base64')}`; const okWebp = `data:image/webp;base64,${webpBytes.toString('base64')}`; assert.equal(sanitizeEntry({ id: 'p1', photoDataUrl: okJpeg }).photoDataUrl, okJpeg); assert.equal(sanitizeEntry({ id: 'p2', photoDataUrl: okPng }).photoDataUrl, okPng); assert.equal(sanitizeEntry({ id: 'p3', photoDataUrl: okWebp }).photoDataUrl, okWebp); // Mislabeled content is rejected even with perfect base64 grammar: the // decoded bytes must carry the declared format's real magic signature. const svgPayload = Buffer.from(''); const svgAsJpeg = `data:image/jpeg;base64,${svgPayload.toString('base64')}`; const htmlAsPng = `data:image/png;base64,${Buffer.from('
hi').toString('base64')}`; const pngBytesAsJpeg = `data:image/jpeg;base64,${pngBytes.toString('base64')}`; for (const bad of [ svgAsJpeg, htmlAsPng, pngBytesAsJpeg, 'data:image/svg+xml;base64,PHN2Zy8+', 'data:image/gif;base64,R0lGODlh', // Grammar violations: whitespace/newlines, excess padding, base64url alphabet. `data:image/jpeg;base64,${jpegBytes.toString('base64').replace(/(.{20})/, '$1\n')}`, 'data:image/jpeg;base64,aGVsbG8===', `data:image/jpeg;base64,${jpegBytes.toString('base64').replace(/A/g, '_')}`, 'data:image/jpeg;base64,%2Dencoded', // Noncanonical tiny junk: grammatically valid but far below any real image. `data:image/jpeg;base64,${Buffer.from('ok').toString('base64')}`, `data:image/png;base64,${Buffer.alloc(31, 0x89).toString('base64')}`, 'http://example.com/photo.jpg', 42, undefined, ]) { const label = typeof bad === 'string' ? bad.slice(0, 44) : String((bad && bad.constructor && bad.constructor.name) || 'value'); assert.equal(sanitizeEntry({ id: 'bad', photoDataUrl: bad }).photoDataUrl, '', `rejected: ${label}`); } // Decoded-binary ceiling mirrors the app-wide 4 MiB photo limit. const overDecoded = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(4 * 1024 * 1024 - 3, 0x77)]).toString('base64')}`; assert.ok(Buffer.from(overDecoded.slice(23), 'base64').length > 4 * 1024 * 1024); assert.equal(sanitizeEntry({ id: 'huge', photoDataUrl: overDecoded }).photoDataUrl, '', 'decoded payload past the 4 MiB app ceiling is rejected'); }); test('sanitizeEntry absorbs Symbol, BigInt, and hostile date values without throwing', () => { const hostile = { id: Symbol('sym'), occurredAt: { toString() { throw new Error('date toString boom'); } }, bristolType: 4n, color: Symbol('green'), urgency: BigInt(3), discomfort: { valueOf() { throw new Error('valueOf boom'); } }, note: Object.create(Object.prototype, { toString: { value() { throw new Error('note boom'); } } }), photoDataUrl: Symbol('photo'), symptoms: null, provenance: 'user', extraSymbolKey: Symbol('ignored'), }; hostile[Symbol('poison')] = 'never'; let entry; assert.doesNotThrow(() => { entry = sanitizeEntry(hostile); }); assert.equal(typeof entry.id, 'string'); assert.match(entry.id, /^entry-/); assert.equal(Number.isNaN(new Date(entry.occurredAt).getTime()), false, 'hostile date becomes a safe ISO timestamp'); assert.equal(entry.bristolType, 4, 'BigInt Bristol type falls back to neutral default'); assert.equal(entry.color, 'brown', 'Symbol color falls back to the default'); assert.equal(entry.urgency, 0, 'BigInt urgency is not a schema integer'); assert.equal(entry.discomfort, 0); assert.equal(entry.note, '', 'non-coercible notes become empty instead of crashing'); assert.equal(entry.photoDataUrl, ''); assert.deepEqual(entry.symptoms, { blood: false, blackOrDarkRed: false, severePain: false, vomiting: false, fever: false, cannotPassGas: false }); assert.doesNotThrow(() => JSON.stringify(entry), 'result must stay serializable'); const poisonedDate = new Date('2026-08-01T00:00:00.000Z'); Object.defineProperty(poisonedDate, 'getTime', { value() { throw new Error('getTime boom'); } }); let survived; assert.doesNotThrow(() => { survived = sanitizeEntry({ id: 'pd', occurredAt: poisonedDate }); }); assert.equal(Number.isNaN(new Date(survived.occurredAt).getTime()), false); let symbolDate; assert.doesNotThrow(() => { symbolDate = sanitizeEntry({ id: 'sd', occurredAt: Symbol('nope') }); }); assert.match(symbolDate.occurredAt, /^\d{4}-\d{2}-\d{2}T/); }); test('stored localStorage payloads are validated and migrated before any render', () => { // Malformed real-world storage: an invalid date that would blank the UI, // a duplicate id, and one garbage row that is not an entry at all. const stored = JSON.stringify([ { id: 'keep-1', occurredAt: 'not-a-real-date', bristolType: 3, color: 'brown', urgency: 2, discomfort: 1, note: 'broken date', photoDataUrl: '', symptoms: {} }, { id: 'keep-2', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'healthy row', photoDataUrl: '', symptoms: {} }, { id: 'keep-2', occurredAt: '2026-08-22T10:15:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'twin row', photoDataUrl: '', symptoms: {} }, { completely: 'not an entry' }, 'a bare string row', 17, ]); let migrated; assert.doesNotThrow(() => { migrated = migrateStoredLedger(stored); }); assert.equal(migrated.entries.length, 3, 'every distinct record survives; junk rows are dropped, never fabricated'); const ids = migrated.entries.map(entry => entry.id); assert.equal(new Set(ids).size, 3, 'duplicate stored ids are repaired'); assert.equal(Number.isNaN(new Date(migrated.entries.find(entry => entry.note === 'broken date').occurredAt).getTime()), false, 'invalid dates become valid ISO timestamps so the UI can never blank out'); assert.equal(migrated.entries.find(entry => entry.note === 'twin row').id, 'keep-2#2'); assert.equal(typeof migrated.changed, 'boolean'); }); test('valid stored ledgers pass through migration unchanged', () => { const healthy = [ sanitizeEntry({ id: 'h1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4 }), sanitizeEntry({ id: 'h2', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 2, provenance: { origin: 'user' } }), ]; const result = migrateStoredLedger(JSON.stringify(healthy)); assert.deepEqual(result.entries, healthy, 'healthy storage is byte-for-byte stable through migration'); assert.equal(result.changed, false, 'no rewrite is flagged when nothing needed repair'); }); test('corrupt or wrong-shaped stored values migrate to an empty ledger instead of crashing boot', () => { for (const corrupt of ['', ' ', '{not json', 'null', '"just a string"', '{"entries":[]}', '[]', 'undefined']) { let result; assert.doesNotThrow(() => { result = migrateStoredLedger(corrupt); }, `corrupt value: ${corrupt.slice(0, 24)}`); assert.deepEqual(result.entries, [], `empty ledger for corrupt value: ${corrupt.slice(0, 24)}`); if (result.changed !== undefined) assert.equal(typeof result.changed, 'boolean'); } }); test('hostile stored values cannot crash the migration pass', () => { const raw = JSON.stringify([{ id: 'x', occurredAt: '2026-08-01T09:00:00.000Z', bristolType: 4 }, { id: 'y', occurredAt: { evil: true }, bristolType: { deep: [1, 2] } }]); let result; assert.doesNotThrow(() => { result = migrateStoredLedger(raw); }); assert.equal(result.entries.length, 2); assert.equal(Number.isNaN(new Date(result.entries[0].occurredAt).getTime()), false); // Circular structures and hostile toJSON must also stay contained. const circular = {}; circular.self = circular; assert.doesNotThrow(() => migrateStoredLedger(circular)); const hostileToJson = [{ toJSON() { throw new Error('toJSON boom'); } }]; assert.doesNotThrow(() => migrateStoredLedger(hostileToJson)); }); test('ledger size is measured as the exact UTF-8 bytes storage will hold', () => { const entries = [sanitizeEntry({ id: 'e1', bristolType: 4, note: 'café ☕' })]; assert.equal(estimateLedgerBytes(entries), utf8ByteLength(JSON.stringify(entries))); assert.equal(estimateLedgerBytes([]), utf8ByteLength('[]')); const withPhoto = [sanitizeEntry({ id: 'e2', bristolType: 2, photoDataUrl: `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF]), Buffer.alloc(2048, 0x44)]).toString('base64')}` })]; assert.equal(estimateLedgerBytes(withPhoto), utf8ByteLength(JSON.stringify(withPhoto))); }); test('exports past the total portability budget are refused instead of producing non-importable files', () => { const bigPhoto = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(4 * 1024 * 1024 - 4, 0x66)]).toString('base64')}`; const entries = [1, 2, 3, 4].map(n => sanitizeEntry({ id: `big-${n}`, bristolType: 4, photoDataUrl: bigPhoto })); let text; assert.throws(() => { text = exportLedger(entries, '2026-08-22T00:00:00.000Z'); }, RangeError, 'an export that could never re-import must not be produced'); assert.equal(text, undefined, 'no oversized document may leak from a refused export'); // A single maximal photo entry still exports fine. assert.doesNotThrow(() => exportLedger([entries[0]], '2026-08-22T00:00:00.000Z')); }); test('imports whose migrated total would exceed the portability budget are rejected before the caller can mutate', () => { // Raw bytes stay just under the cap; the migrated ledger (every row expanded // to the full current schema with explicit symptom fields) crosses it, so // import must refuse up front instead of letting a doomed write happen. const head = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":['; const makeRawEntry = i => `{"id":"x${String(i).padStart(7, '0')}","bristolType":4,"occurredAt":"x"}`; // Solve the row count directly with uniform-width ids: raw bytes sit just // under the cap while the migrated expansion crosses it. const rowLen = utf8ByteLength(makeRawEntry(0)); const available = MAX_IMPORT_BYTES - 4096 - utf8ByteLength(head) - 3; // "],}" tail const count = Math.max(1, Math.floor((available + 1) / (rowLen + 1))); // + comma per row const raw = `${head}${Array.from({ length: count }, (_, i) => makeRawEntry(i)).join(',')}]}` + ''; assert.ok(utf8ByteLength(raw) <= MAX_IMPORT_BYTES, 'fixture raw bytes must stay inside the cap'); assert.ok(utf8ByteLength(raw) > MAX_IMPORT_BYTES * 0.9, 'fixture must sit close to the raw boundary'); let entries; assert.throws(() => { entries = importLedger(raw); }, error => error instanceof RangeError && /expand past/.test(error.message), 'migrated total past the budget must refuse before returning entries'); assert.equal(entries, undefined, 'no entries may leak from a refused import'); }); test('merge sanitizes incoming entries so imports cannot smuggle hostile fields into storage', () => { const incoming = [{ id: 'proto-entry', bristolType: 4, __proto__: { poisoned: true }, extra: 'strip me' }, { id: 'ctor-entry', bristolType: 4, sessionCookie: 'SID=x' }]; const result = mergeLedgers([], incoming); assert.equal(result.merged.length, 2, 'both records still import as data'); for (const entry of result.merged) { assert.equal(Object.getPrototypeOf(entry), Object.prototype, `plain-object entry ${entry.id}`); assert.equal(entry.poisoned, undefined, 'prototype payload must not leak'); assert.equal(entry.extra, undefined, 'unknown fields stay out of storage'); assert.equal(entry.sessionCookie, undefined, 'smuggled secrets stay out of storage'); } const serialized = JSON.stringify(result.merged); assert.doesNotMatch(serialized, /poisoned|extra|sessionCookie|SID=/); }); test('current-schema numeric fields are strict integers within clinical bounds', () => { for (const bogus of [4.5, '3', 0, 8, NaN, null, true, [3]]) { const entry = sanitizeEntry({ id: 'n', bristolType: bogus }); assert.equal(entry.bristolType, 4, `bristolType ${JSON.stringify(String(bogus))} falls back to the neutral default`); } assert.equal(sanitizeEntry({ id: 'ok1', bristolType: 1 }).bristolType, 1); assert.equal(sanitizeEntry({ id: 'ok2', bristolType: 7 }).bristolType, 7); const mixed = sanitizeEntry({ id: 'm', urgency: 2.5, discomfort: -1 }); assert.equal(mixed.urgency, 0); assert.equal(mixed.discomfort, 0); const strings = sanitizeEntry({ id: 's', urgency: '3', discomfort: 11 }); assert.equal(strings.urgency, 0, 'numeric strings are not schema integers'); assert.equal(strings.discomfort, 0); const valid = sanitizeEntry({ id: 'v', urgency: 3, discomfort: 4 }); assert.equal(valid.urgency, 3); assert.equal(valid.discomfort, 4); }); test('photo fields accept only approved raster JPEG/PNG/WebP base64 data URLs', () => { // Canonical, magic-valid samples of every supported format (large enough to // satisfy the canonical photo contract's decoded-size floor). const okJpeg = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF]), Buffer.alloc(120, 0x11)]).toString('base64')}`; const okPng = `data:image/png;base64,${Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(120, 0x22)]).toString('base64')}`; const okWebp = `data:image/webp;base64,${Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x70, 0x00, 0x00, 0x00]), Buffer.from('WEBPVP8 '), Buffer.alloc(110, 0x33)]).toString('base64')}`; assert.equal(sanitizeEntry({ id: 'p1', photoDataUrl: okJpeg }).photoDataUrl, okJpeg); assert.equal(sanitizeEntry({ id: 'p2', photoDataUrl: okPng }).photoDataUrl, okPng); assert.equal(sanitizeEntry({ id: 'p3', photoDataUrl: okWebp }).photoDataUrl, okWebp); for (const bad of [ 'data:image/svg+xml;base64,PHN2Zy8+', 'data:image/svg+xml,