Quality gates / quality (pull_request) Successful in 3m44s
A journal save now records no training contribution. A contribution is a second consent: the privacy page states consent version training-consent-v1 and the receipt (contribution id, included fields, never-included fields) before the confirm control is enabled, the receipt is queued on this device only and copies no photo bytes, note text, or symptom answers, and withdrawal keeps a tombstone naming every derived artifact by contribution id. Delete-all clears the receipts with the ledger. Closes #24
162 lines
6.3 KiB
JavaScript
162 lines
6.3 KiB
JavaScript
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');
|
|
});
|