timmy-talking-turd/tests/domain.test.js
Timmy b8532f587d
All checks were successful
Quality gates / quality (pull_request) Successful in 1m42s
feat: version ledger migrations and hardened JSON portability
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.
2026-08-22 20:32:57 +00:00

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