Skip to content

Commit 9efba27

Browse files
committed
feat(cmc/formSpec): add FormSpecRecord + listFormSpecs + getFormSpecById (plan 61 C1.0)
Pre-work for Phase C consumer port. New exports: - FormSpecRecord interface (collectorId, formSpec, event) - eventToFormSpecRecord pure helper - listFormSpecs(connection, opts?) — replaces appManaging.getCollectors() - getFormSpecById(connection, collectorId, opts?) — replaces getCollectorById 13 new unit tests [CFS30]-[CFS52]. 546/546 pass. Lifecycle locked publish-on-save (no draft state) — consumers will drop the legacy statusCode === 'active' filter.
1 parent b5a62a4 commit 9efba27

2 files changed

Lines changed: 216 additions & 2 deletions

File tree

tests/cmcFormSpec.test.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,139 @@ describe('[CFSP] cmcFormSpec helpers', function () {
7070
assert.equal(cmcFormSpec.isChatOnlyFormSpec(undefined), false);
7171
});
7272
});
73+
74+
describe('[CFSR] eventToFormSpecRecord (pure helper)', function () {
75+
function eventOn (streamId, content = basicSpec()) {
76+
return { id: 'evt-' + streamId, streamIds: [streamId], content };
77+
}
78+
79+
it('[CFS30] extracts collectorId from a hds-collector sub-scope', () => {
80+
const rec = cmcFormSpec.eventToFormSpecRecord(eventOn(':_cmc:apps:hds-collector:abc123'));
81+
assert.equal(rec.collectorId, 'abc123');
82+
assert.equal(rec.formSpec.title.en, 'Hello World');
83+
assert.equal(rec.event.id, 'evt-:_cmc:apps:hds-collector:abc123');
84+
});
85+
86+
it('[CFS31] honors a custom appCode parameter', () => {
87+
const rec = cmcFormSpec.eventToFormSpecRecord(
88+
eventOn(':_cmc:apps:hds-patient:xyz'),
89+
'hds-patient'
90+
);
91+
assert.equal(rec.collectorId, 'xyz');
92+
});
93+
94+
it('[CFS32] picks the matching stream when an event has multiple streamIds', () => {
95+
const ev = { id: 'evt', streamIds: ['other-stream', ':_cmc:apps:hds-collector:formA'], content: basicSpec() };
96+
const rec = cmcFormSpec.eventToFormSpecRecord(ev);
97+
assert.equal(rec.collectorId, 'formA');
98+
});
99+
100+
it('[CFS33] returns the raw streamId when no matching scope marker is found (fallback)', () => {
101+
const ev = { id: 'evt', streamIds: ['orphan'], content: basicSpec() };
102+
const rec = cmcFormSpec.eventToFormSpecRecord(ev);
103+
// extractAppSubScopeSuffix returns the input unchanged when the prefix is missing.
104+
assert.equal(rec.collectorId, 'orphan');
105+
});
106+
107+
it('[CFS34] tolerates a missing streamIds array', () => {
108+
const ev = { id: 'evt', content: basicSpec() };
109+
const rec = cmcFormSpec.eventToFormSpecRecord(ev);
110+
assert.equal(rec.collectorId, '');
111+
});
112+
});
113+
114+
describe('[CFSL] listFormSpecs', function () {
115+
function fakeConnection (events) {
116+
const calls = [];
117+
const conn = {
118+
apiOne (method, params, resultKey) {
119+
calls.push({ method, params, resultKey });
120+
if (method === 'events.get') return events;
121+
throw new Error('unexpected apiOne method ' + method);
122+
}
123+
};
124+
return { conn, calls };
125+
}
126+
127+
it('[CFS40] queries the :_cmc:apps:hds-collector parent stream filtered to hds:form-spec-v1', async () => {
128+
const { conn, calls } = fakeConnection([]);
129+
await cmcFormSpec.listFormSpecs(conn);
130+
assert.equal(calls.length, 1);
131+
assert.equal(calls[0].method, 'events.get');
132+
assert.deepEqual(calls[0].params.streams, [':_cmc:apps:hds-collector']);
133+
assert.deepEqual(calls[0].params.types, ['hds:form-spec-v1']);
134+
assert.equal(calls[0].params.limit, 1000);
135+
assert.equal(calls[0].resultKey, 'events');
136+
});
137+
138+
it('[CFS41] maps every returned event to a FormSpecRecord with collectorId set', async () => {
139+
const events = [
140+
{ id: 'e1', streamIds: [':_cmc:apps:hds-collector:form-a'], content: basicSpec({ title: { en: 'Form A' } }) },
141+
{ id: 'e2', streamIds: [':_cmc:apps:hds-collector:form-b'], content: basicSpec({ title: { en: 'Form B' } }) }
142+
];
143+
const { conn } = fakeConnection(events);
144+
const records = await cmcFormSpec.listFormSpecs(conn);
145+
assert.equal(records.length, 2);
146+
assert.deepEqual(records.map(r => r.collectorId), ['form-a', 'form-b']);
147+
assert.equal(records[0].formSpec.title.en, 'Form A');
148+
assert.equal(records[1].event.id, 'e2');
149+
});
150+
151+
it('[CFS42] returns [] when the api returns null/undefined', async () => {
152+
const { conn } = fakeConnection(null);
153+
const records = await cmcFormSpec.listFormSpecs(conn);
154+
assert.deepEqual(records, []);
155+
});
156+
157+
it('[CFS43] honors a custom appCode opt', async () => {
158+
const { conn, calls } = fakeConnection([]);
159+
await cmcFormSpec.listFormSpecs(conn, { appCode: 'hds-patient' });
160+
assert.deepEqual(calls[0].params.streams, [':_cmc:apps:hds-patient']);
161+
});
162+
163+
it('[CFS44] honors a custom limit opt', async () => {
164+
const { conn, calls } = fakeConnection([]);
165+
await cmcFormSpec.listFormSpecs(conn, { limit: 50 });
166+
assert.equal(calls[0].params.limit, 50);
167+
});
168+
});
169+
170+
describe('[CFSG] getFormSpecById', function () {
171+
it('[CFS50] returns null when loadFormSpec finds nothing', async () => {
172+
const conn = { apiOne: async () => [] };
173+
const rec = await cmcFormSpec.getFormSpecById(conn, 'missing');
174+
assert.equal(rec, null);
175+
});
176+
177+
it('[CFS51] returns a FormSpecRecord with the requested collectorId when found', async () => {
178+
const event = { id: 'evt-xyz', streamIds: [':_cmc:apps:hds-collector:xyz'], content: basicSpec({ title: { en: 'XYZ' } }) };
179+
const calls = [];
180+
const conn = {
181+
apiOne (method, params, resultKey) {
182+
calls.push({ method, params, resultKey });
183+
return [event];
184+
}
185+
};
186+
const rec = await cmcFormSpec.getFormSpecById(conn, 'xyz');
187+
assert.ok(rec);
188+
assert.equal(rec.collectorId, 'xyz');
189+
assert.equal(rec.formSpec.title.en, 'XYZ');
190+
// Verify the underlying loadFormSpec was scoped to the exact sub-scope stream
191+
assert.deepEqual(calls[0].params.streams, [':_cmc:apps:hds-collector:xyz']);
192+
});
193+
194+
it('[CFS52] honors a custom appCode opt', async () => {
195+
const calls = [];
196+
const conn = {
197+
apiOne (method, params) {
198+
calls.push(params);
199+
return [];
200+
}
201+
};
202+
await cmcFormSpec.getFormSpecById(conn, 'foo', { appCode: 'hds-patient' });
203+
assert.deepEqual(calls[0].streams, [':_cmc:apps:hds-patient:foo']);
204+
});
205+
});
73206
});
74207

75208
describe('[CTFS] Contact.aggregateCmc hdsFormSpec pass-through', function () {

ts/cmc/formSpec.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
* patient's hds-webapp provisions `:hds:noop` on first launch.
2525
*/
2626

27-
import { pryv } from '../patchedPryv.ts';
28-
import { CMC_EVENT_TYPES } from './constants.ts';
27+
import { cmc, pryv } from '../patchedPryv.ts';
28+
import { CMC_APP_CODES, CMC_EVENT_TYPES, appSubScope, extractAppSubScopeSuffix } from './constants.ts';
2929
import type { Permission } from '../appTemplates/interfaces.ts';
3030
import type { localizableText } from '../localizeText.ts';
3131
import type {
@@ -118,6 +118,87 @@ export async function loadFormSpec (
118118
return (events && events.length > 0) ? events[0] : null;
119119
}
120120

121+
/**
122+
* A FormSpec persisted on the doctor's `:_cmc:apps:<appCode>:<collectorId>`
123+
* scope, paired with the raw event id + the parsed collectorId. Returned by
124+
* `listFormSpecs` and `getFormSpecById`.
125+
*
126+
* Phase C (plan 61): replaces the legacy `Collector` runtime as the doctor-
127+
* side data-set handle. Every record is "published" — there is no draft
128+
* state in the CMC FormSpec model; `saveFormSpec` writes the canonical event
129+
* immediately.
130+
*/
131+
export interface FormSpecRecord {
132+
/** The collectorId suffix of the scope stream (the doctor's data-set id). */
133+
collectorId: string;
134+
/** The FormSpec content as authored. */
135+
formSpec: FormSpec;
136+
/** The raw `hds:form-spec-v1` event (id, modified, etc.). */
137+
event: pryv.Event;
138+
}
139+
140+
/**
141+
* Pure helper: lift a raw `hds:form-spec-v1` event onto its scope-stream
142+
* `collectorId`. Exported for unit-testing in isolation from the I/O layer.
143+
*
144+
* @param event a `hds:form-spec-v1` event read from a `:_cmc:apps:<appCode>:*` stream.
145+
* @param appCode defaults to `CMC_APP_CODES.COLLECTOR`.
146+
*/
147+
export function eventToFormSpecRecord (
148+
event: pryv.Event,
149+
appCode: string = CMC_APP_CODES.COLLECTOR
150+
): FormSpecRecord {
151+
const streamIds = ((event as any).streamIds || []) as string[];
152+
const marker = ':_cmc:apps:' + appCode + ':';
153+
const subScope = streamIds.find(s => s.indexOf(marker) !== -1) ?? streamIds[0] ?? '';
154+
const collectorId = extractAppSubScopeSuffix(subScope, appCode);
155+
return {
156+
collectorId,
157+
formSpec: (event as any).content as FormSpec,
158+
event
159+
};
160+
}
161+
162+
/**
163+
* Doctor-side: list every FormSpec the doctor owns under their
164+
* `:_cmc:apps:<appCode>` scope (one per data-set). Every result is
165+
* "published" — saveFormSpec writes the canonical event immediately
166+
* (no draft state in the CMC model).
167+
*
168+
* Phase C (plan 61): replaces `appManaging.getCollectors()`.
169+
*/
170+
export async function listFormSpecs (
171+
connection: pryv.Connection,
172+
opts?: { appCode?: string; limit?: number }
173+
): Promise<FormSpecRecord[]> {
174+
const appCode = opts?.appCode ?? CMC_APP_CODES.COLLECTOR;
175+
const parentStream = cmc.appScope(appCode);
176+
const events = await connection.apiOne('events.get', {
177+
streams: [parentStream],
178+
types: [FORM_SPEC_EVENT_TYPE],
179+
limit: opts?.limit ?? 1000
180+
} as any, 'events') as unknown as pryv.Event[];
181+
return (events ?? []).map(e => eventToFormSpecRecord(e, appCode));
182+
}
183+
184+
/**
185+
* Doctor-side: load a single FormSpec by its `collectorId`. Returns null
186+
* if no FormSpec event exists yet on `:_cmc:apps:<appCode>:<collectorId>`.
187+
*
188+
* Phase C (plan 61): replaces `appManaging.getCollectorById()`.
189+
*/
190+
export async function getFormSpecById (
191+
connection: pryv.Connection,
192+
collectorId: string,
193+
opts?: { appCode?: string }
194+
): Promise<FormSpecRecord | null> {
195+
const appCode = opts?.appCode ?? CMC_APP_CODES.COLLECTOR;
196+
const subScope = appSubScope(appCode, collectorId);
197+
const event = await loadFormSpec(connection, subScope);
198+
if (!event) return null;
199+
return eventToFormSpecRecord(event, appCode);
200+
}
201+
121202
/**
122203
* Doctor-side: mint a CMC invite with the FormSpec snapshot embedded on
123204
* the trigger event content at events.create time.

0 commit comments

Comments
 (0)