All checks were successful
Quality gates / quality (pull_request) Successful in 1m42s
Implements #35. - importLedger migrates prior schema versions (v0 bare-array legacy exports and the v1 envelope) and fails safely on future versions, malformed JSON, wrong-product envelopes, and oversized files with a new 2 MiB MAX_IMPORT_BYTES guard applied before parsing. - exportLedger normalizes entries through sanitizeEntry so confirmed values and bounded provenance round-trip while smuggled secrets and unknown fields never enter the portable file. - Entries may carry a whitelisted provenance origin ('user' or 'ai-suggestion'); mergeVisualSuggestion records 'ai-suggestion' only when a suggestion is actually applied, keeping nonvisual fields user-owned. - App import now merges into the existing ledger instead of replacing it, so a failed or partial import can never silently drop user-owned records. - Service-worker shell cache bumped to v6 (per base-path namespace) so installed PWAs receive the migration code; old v5 caches are purged on activation. - New tests/ledger-portability.acceptance.mjs browser gate covers export round trip, merge import, safe-failure surfacing, root vs /timmy-staging storage isolation, and Delete Everything for both namespaces; wired into package.json test:portability and CI quality.yml. Deterministic medical safety unchanged: urgent-flag detection, red-flag copy, and chat escalation paths are untouched; all fixtures synthetic.
253 lines
10 KiB
JavaScript
253 lines
10 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
bucketForBristolType,
|
|
buildTimmySummary,
|
|
detectUrgentFlags,
|
|
detectUrgentText,
|
|
exportLedger,
|
|
hasUrgentLedgerContext,
|
|
importLedger,
|
|
MAX_IMPORT_BYTES,
|
|
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('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('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, 7);
|
|
assert.equal(roundTripped[1].color, 'brown');
|
|
assert.equal(roundTripped[1].urgency, 4);
|
|
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);
|
|
});
|