import test from 'node:test'; import assert from 'node:assert/strict'; import { TRAINING_CONSENT_VERSION, addContribution, consentTerms, contributionEligibility, contributionForEntry, defaultContributionState, derivedArtifacts, receiptPreview, withdrawContribution, } from '../src/training-contribution.js'; // Issue #24 — contributing a photo to Timmy's training corpus is a separate decision from saving // a log. Nothing here is transmitted: the receipt is what a person could carry to a corpus pipeline // later, and the withdrawal tombstone is what names the artifacts a deletion has to reach. const PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='; const entry = (overrides = {}) => ({ id: 'log-1', occurredAt: '2026-10-14T07:20:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'after lunch', photoDataUrl: PHOTO, symptoms: { blood: false }, ...overrides, }); const preview = (target = entry(), id = 'contrib-1') => receiptPreview(target, { contributionId: id, consentedAt: '2026-10-21T10:00:00.000Z', }); test('saving a journal entry contributes nothing by default', () => { assert.deepEqual(defaultContributionState(), { optedIn: false, receipt: null }); }); test('an entry with no saved photo bytes cannot be contributed', () => { const unavailable = contributionEligibility(entry({ photoDataUrl: '' })); assert.equal(unavailable.eligible, false); assert.match(unavailable.reason, /no saved photo/i); assert.equal(contributionEligibility(entry()).eligible, true); }); test('the consent terms name the version and what is never shared', () => { const terms = consentTerms(); assert.equal(terms.version, TRAINING_CONSENT_VERSION); assert.match(terms.version, /^training-consent-/); assert.ok(terms.includes.some(item => /photo/i.test(item))); assert.ok(terms.excludes.some(item => /note/i.test(item))); assert.ok(terms.excludes.some(item => /symptom/i.test(item))); // This build is honest about transport: a receipt is queued on the device, not uploaded. assert.match(terms.transport, /nothing is uploaded/i); }); test('the receipt shown before consent carries the version, the id, and no free text from the log', () => { const receipt = preview(); assert.equal(receipt.contributionId, 'contrib-1'); assert.equal(receipt.consentVersion, TRAINING_CONSENT_VERSION); assert.equal(receipt.consentedAt, '2026-10-21T10:00:00.000Z'); assert.deepEqual(receipt.labels, { bristolType: 4, color: 'brown' }); assert.equal(receipt.state, 'queued-local'); // The confirmed note and symptom flags stay in the journal; they are not part of a contribution. const serialized = JSON.stringify(receipt); assert.equal(serialized.includes('after lunch'), false); assert.equal(serialized.includes('"blood"'), false); assert.equal(serialized.includes('data:image'), false); }); test('a receipt cannot be recorded without the current consent version', () => { assert.throws( () => addContribution([], { ...preview(), consentVersion: 'training-consent-v0' }), /consent version/i, ); }); test('recording a contribution returns a new list and leaves the ledger it was given alone', () => { const before = []; const after = addContribution(before, preview()); assert.equal(before.length, 0); assert.equal(after.length, 1); assert.equal(after[0].contributionId, 'contrib-1'); assert.equal(contributionForEntry(after, 'log-1')?.contributionId, 'contrib-1'); }); test('the same log cannot be contributed twice while its receipt is still active', () => { const first = addContribution([], preview()); const second = addContribution(first, preview(entry(), 'contrib-2')); assert.equal(second.length, 1); assert.equal(second[0].contributionId, 'contrib-1'); }); test('withdrawal keeps a tombstone that names every derived artifact by contribution id', () => { const contributions = addContribution([], preview()); const artifacts = derivedArtifacts(contributions[0]); const { contributions: after, tombstone } = withdrawContribution( contributions, 'contrib-1', '2026-10-21T11:00:00.000Z', ); assert.equal(after[0].state, 'withdrawn'); assert.equal(after[0].withdrawnAt, '2026-10-21T11:00:00.000Z'); assert.equal(tombstone.contributionId, 'contrib-1'); assert.deepEqual( tombstone.artifacts.map(artifact => artifact.artifact).sort(), artifacts.map(artifact => artifact.artifact).sort(), ); for (const artifact of tombstone.artifacts) { assert.equal(artifact.contributionId, 'contrib-1'); assert.equal(artifact.state, 'withdrawn'); } }); test('the derived artifacts are the image bytes and the confirmed label, addressed by contribution id', () => { const artifacts = derivedArtifacts(preview()); assert.deepEqual(artifacts.map(artifact => artifact.artifact).sort(), ['confirmed-label', 'raw-image']); for (const artifact of artifacts) { assert.equal(artifact.contributionId, 'contrib-1'); assert.equal(artifact.state, 'active'); } }); test('withdrawing one contribution never touches another receipt', () => { const list = [ ...addContribution([], preview()), ...addContribution([], preview(entry({ id: 'log-2' }), 'contrib-2')), ]; const { contributions: after } = withdrawContribution(list, 'contrib-1', '2026-10-21T11:00:00.000Z'); assert.equal(after.find(item => item.contributionId === 'contrib-1').state, 'withdrawn'); assert.equal(after.find(item => item.contributionId === 'contrib-2').state, 'queued-local'); }); test('withdrawing an unknown contribution id changes nothing and reports no tombstone', () => { const list = addContribution([], preview()); const { contributions: after, tombstone } = withdrawContribution(list, 'missing', '2026-10-21T11:00:00.000Z'); assert.equal(after, list); assert.equal(tombstone, null); }); test('a withdrawn log can be contributed again under a fresh receipt', () => { const { contributions: withdrawn } = withdrawContribution( addContribution([], preview()), 'contrib-1', '2026-10-21T11:00:00.000Z', ); const again = addContribution(withdrawn, preview(entry(), 'contrib-2')); assert.equal(again.length, 2); assert.equal(contributionForEntry(again, 'log-1').contributionId, 'contrib-2'); assert.equal(contributionForEntry(again, 'log-1').state, 'queued-local'); });