All checks were successful
Quality gates / quality (pull_request) Successful in 4m14s
- collision-safe ID repair: duplicate ids inside stored data are repaired deterministically (first keeps id, twins get id#2, id#3, ... scanning past owned suffixes); every distinct local record survives, never dropped or silently merged; hostile id types (Symbol/BigInt/objects) repair onto fresh deterministic ids instead of throwing - transactional import: parse+merge into a candidate ledger, persist first, then commit memory; quota/error rolls back in-memory state and localStorage together with explicit user feedback; total 16MiB portability budget enforced before mutation on export, import (post-migration expansion), and storage writes - strict Timmy legacy contract for bare top-level arrays: nonempty array of plain rows each carrying a nonempty string id and integer Bristol 1-7; arbitrary unrelated arrays are rejected wholesale - no invented medical defaults from foreign JSON - canonical raster photo validation: strict JPEG/PNG/WebP grammar, canonical base64 (linear scan, no regex on multi-MB strings), atob round-trip decode, declared-format magic bytes, 32B-4MiB decoded bounds; mislabeled SVG/HTML and noncanonical tiny junk are stripped while genuine photos survive byte-for-byte - migrateStoredLedger: localStorage is validated and migrated before render; invalid dates become safe ISO timestamps, duplicate ids repaired, junk rows dropped (never fabricated into default records); healthy storage is byte-stable and never rewritten - sanitizeEntry absorbs Symbol/BigInt/hostile dates/throwing toString, valueOf, getTime, toJSON without throwing; results stay serializable - browser regression suite: quota rollback, pre-render migration, array rejection, photo contract, duplicate-ID preservation in the real app flow - staging-health startup-rejection budget anchored to measured server cold-start instead of a fixed 800ms (fixes load-sensitive flake)
741 lines
40 KiB
JavaScript
741 lines
40 KiB
JavaScript
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('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
|
|
const svgAsJpeg = `data:image/jpeg;base64,${svgPayload.toString('base64')}`;
|
|
const htmlAsPng = `data:image/png;base64,${Buffer.from('<html><body>hi</body></html>').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,<svg onload="alert(1)">',
|
|
'data:image/gif;base64,R0lGODlh',
|
|
'data:image/jpeg;base64,!!!not-base64!!!',
|
|
'data:image/jpeg,percent%2Dencoded',
|
|
'data:text/html;base64,PGh0bWw+',
|
|
'http://example.com/photo.jpg',
|
|
42,
|
|
// Grammar-valid but non-raster or undersized payloads are dropped too:
|
|
// the canonical contract rejects tiny junk that merely parses as base64.
|
|
'data:image/jpeg;base64,b2s=',
|
|
`data:image/jpeg;base64,${Buffer.from('plain text, not an image').toString('base64')}`,
|
|
]) {
|
|
assert.equal(sanitizeEntry({ id: 'bad', photoDataUrl: bad }).photoDataUrl, '', `rejected: ${String(bad).slice(0, 40)}`);
|
|
}
|
|
});
|
|
|
|
test('invalid or missing dates never throw and never persist Invalid Date values', () => {
|
|
for (const bad of ['not-a-date', '2026-13-45T99:99:99Z', {}, ['2026-01-01'], true]) {
|
|
let entry;
|
|
assert.doesNotThrow(() => { entry = sanitizeEntry({ id: 'd', occurredAt: bad }); }, String(bad));
|
|
assert.match(entry.occurredAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
assert.equal(Number.isNaN(new Date(entry.occurredAt).getTime()), false, `safe ISO for ${String(bad)}`);
|
|
}
|
|
const blank = sanitizeEntry({ id: 'd2', occurredAt: '' });
|
|
assert.equal(Number.isNaN(new Date(blank.occurredAt).getTime()), false);
|
|
const kept = sanitizeEntry({ id: 'd3', occurredAt: '2026-08-01T10:00:00.000Z' });
|
|
assert.equal(kept.occurredAt, '2026-08-01T10:00:00.000Z', 'valid dates pass through unchanged');
|
|
});
|
|
|
|
test('imports containing invalid dates migrate forward instead of crashing the whole ledger', () => {
|
|
const payload = JSON.stringify([
|
|
{ id: 'bad-date', occurredAt: 'garbage-date-value', bristolType: 3 },
|
|
{ id: 'good-date', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4 },
|
|
]);
|
|
const imported = importLedger(payload);
|
|
assert.equal(imported.length, 2, 'one bad field cannot destroy the batch');
|
|
assert.equal(Number.isNaN(new Date(imported[0].occurredAt).getTime()), false, 'bad date becomes a safe ISO timestamp');
|
|
assert.equal(imported[1].occurredAt, '2026-08-20T09:00:00.000Z');
|
|
});
|
|
|
|
test('round trip preserves confirmed values and provenance without leaking secrets', () => {
|
|
const saved = [
|
|
sanitizeEntry({
|
|
id: 'r1',
|
|
occurredAt: '2026-08-19T08:30:00.000Z',
|
|
bristolType: 2,
|
|
color: 'green',
|
|
urgency: 3,
|
|
discomfort: 2,
|
|
note: 'rough morning',
|
|
provenance: { origin: 'ai-suggestion', apiToken: 'sk-leaked-token' },
|
|
}),
|
|
{
|
|
id: 'raw-2', bristolType: 9, color: 'chartreuse', urgency: 11, discomfort: -4,
|
|
note: 'odd shape', symptoms: { blood: true }, sessionCookie: 'SID=hijack',
|
|
},
|
|
];
|
|
const exported = exportLedger(saved, '2026-08-20T00:00:00.000Z');
|
|
assert.doesNotMatch(exported, /sk-leaked-token|SID=hijack|chartreuse/);
|
|
const roundTripped = importLedger(exported);
|
|
assert.equal(roundTripped.length, 2);
|
|
assert.deepEqual(
|
|
{ id: roundTripped[0].id, occurredAt: roundTripped[0].occurredAt, bristolType: roundTripped[0].bristolType, color: roundTripped[0].color, urgency: roundTripped[0].urgency, discomfort: roundTripped[0].discomfort, note: roundTripped[0].note },
|
|
{ id: 'r1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'rough morning' },
|
|
);
|
|
assert.equal(roundTripped[0].symptoms.blood, false);
|
|
assert.deepEqual(roundTripped[0].provenance, { origin: 'ai-suggestion' });
|
|
assert.equal(roundTripped[1].bristolType, 4, 'out-of-range Bristol type falls back to the neutral default under strict schema');
|
|
assert.equal(roundTripped[1].color, 'brown');
|
|
assert.equal(roundTripped[1].urgency, 0, 'out-of-range urgency falls back to the neutral default');
|
|
assert.equal(roundTripped[1].discomfort, 0);
|
|
assert.deepEqual(roundTripped[1].provenance, undefined);
|
|
});
|
|
|
|
test('re-exporting an imported ledger converges to the same portable document', () => {
|
|
const entries = [{ id: 'c1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 6, color: 'yellow', urgency: 2, discomfort: 1, note: 'loose', provenance: { origin: 'user' } }];
|
|
const first = JSON.parse(exportLedger(entries, '2026-08-20T00:00:00.000Z'));
|
|
const second = JSON.parse(exportLedger(importLedger(exportLedger(entries, '2026-08-20T00:00:00.000Z')), '2026-08-20T00:00:00.000Z'));
|
|
assert.deepEqual(second, first);
|
|
});
|