Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions apps/api/src/routes/assembled-exam-run-routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_DEV_AUTH_SECRET,
signAuthToken,
} from "@openclinxr/auth";
import type { ExamForm, ExamTimingPlan } from "@openclinxr/exam-assembly";
import { assembledExamOrchestratorClaimBoundary } from "@openclinxr/scenario-runtime";
import { describe, expect, it } from "vitest";
import { ApiApplication } from "../api-application.js";
import { createApiApp } from "../app.js";
import type { ApiPersistenceSink } from "../api-types.js";
import { createApiApp } from "../app.js";
import {
assembledExamRunNotEvidenceFor,
type ApiAssembledExamRunRecord,
assembledExamRunNotEvidenceFor,
} from "../runtime-durable-store.js";
import {
ASSEMBLED_EXAM_RUNS_PATH,
Expand Down Expand Up @@ -166,6 +166,23 @@ describe("assembled-exam run API", () => {
expect(body.action).toBe("resume_station");
expect(body.examEquivalenceGate).toBe(false);
expect(body.claimBoundary).toBe(assembledExamOrchestratorClaimBoundary);
expect(body.orderedStations).toEqual([
{
stationOrder: 1,
slotId: "slot_a",
stationRunId: `${EXAM_RUN_ID}:station:1`,
scenarioId: SCENARIO_A,
scenarioVersion: 1,
},
{
stationOrder: 2,
slotId: "slot_b",
stationRunId: `${EXAM_RUN_ID}:station:2`,
scenarioId: SCENARIO_B,
scenarioVersion: 1,
},
]);
expect(body.admittedPhaseEvents).toEqual([]);
const currentStation = body.currentStation as { stationOrder: number; scenarioId: string; assembledStation: unknown };
expect(currentStation.stationOrder).toBe(1);
expect(currentStation.scenarioId).toBe(SCENARIO_A);
Expand Down Expand Up @@ -206,6 +223,10 @@ describe("assembled-exam run API", () => {
}),
});
expect(admitted.status).toBe(201);
const admittedContract = await json(admitted);
const admittedEvents = admittedContract.admittedPhaseEvents as Array<{ recordedAtIso: string }>;
const recordedAtIso = admittedEvents[0]?.recordedAtIso;
expect(Number.isFinite(Date.parse(recordedAtIso ?? ""))).toBe(true);

const restarted = compose(sink);
restarted.context.assembledExamRuns.clear();
Expand All @@ -219,6 +240,16 @@ describe("assembled-exam run API", () => {
expect(body.stationRunId).toBe(`${EXAM_RUN_ID}:station:1`);
const currentStation = body.currentStation as { lifecycle: { lastAdmittedEventType: string } };
expect(currentStation.lifecycle.lastAdmittedEventType).toBe("encounter.started");
expect(body.orderedStations).toHaveLength(2);
expect(body.admittedPhaseEvents).toEqual([
expect.objectContaining({
stationRunId: `${EXAM_RUN_ID}:station:1`,
eventType: "encounter.started",
sequence: 0,
formAtSecond: 60,
recordedAtIso,
}),
]);
});

it("rejects learner, form, station-order, sequence, and durable-reference mismatches", async () => {
Expand Down
27 changes: 17 additions & 10 deletions apps/api/src/routes/assembled-exam-run-routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Hono } from "hono";
import { resolveSessionLearnerId } from "@openclinxr/auth";
import type { ExamForm, ExamTimingPlan } from "@openclinxr/exam-assembly";
import {
Expand All @@ -7,23 +6,24 @@ import {
type AssembledExamPhaseTransitionType,
} from "@openclinxr/review-workflow";
import {
resumeAssembledExam,
type AssembledExamAdmittedPhaseEvent,
type AssembledExamLedgerResumeProjection,
type AssembledExamResumeDecision,
resumeAssembledExam,
} from "@openclinxr/scenario-runtime";
import type { Hono } from "hono";
import type { ApiAppContext } from "../api-app-context.js";
import { denyIfCannotReadStationRun, isExamForm } from "../api-route-support.js";
import { isRecord } from "../api-support.js";
import type { ApiAppVariables } from "../api-types.js";
import {
assembledExamRunClaimBoundary,
assembledExamRunNotEvidenceFor,
createScenarioRuntimeDurableStoreFromApiPersistence,
type ApiAssembledExamAdmittedPhaseEvent,
type ApiAssembledExamRunRecord,
type ApiAssembledExamStationBinding,
type ApiRuntimeDurableStore,
assembledExamRunClaimBoundary,
assembledExamRunNotEvidenceFor,
createScenarioRuntimeDurableStoreFromApiPersistence,
} from "../runtime-durable-store.js";

export const ASSEMBLED_EXAM_RUNS_PATH = "/exam-runs";
Expand Down Expand Up @@ -89,7 +89,7 @@ export function registerAssembledExamRunRoutes(
if (mismatch) {
return staleIdentity(context, mismatch);
}
return context.json(toContract(decide(existing)), existing.admittedPhaseEvents.length === 0 ? 201 : 200);
return context.json(toContract(decide(existing), existing), existing.admittedPhaseEvents.length === 0 ? 201 : 200);
}

const record: ApiAssembledExamRunRecord = {
Expand All @@ -107,7 +107,7 @@ export function registerAssembledExamRunRoutes(
};
try {
await persistAssembledExamRun(durable, assembledExamRuns, examRunOwners, sessionOwners, record);
return context.json(toContract(decide(record)), 201);
return context.json(toContract(decide(record), record), 201);
} catch (error) {
return assembledExamRunError(context, error);
}
Expand All @@ -128,7 +128,7 @@ export function registerAssembledExamRunRoutes(
return context.json(ownershipDenied.body, ownershipDenied.status);
}
try {
return context.json(toContract(decide(record)));
return context.json(toContract(decide(record), record));
} catch (error) {
return assembledExamRunError(context, error);
}
Expand Down Expand Up @@ -180,7 +180,7 @@ export function registerAssembledExamRunRoutes(
try {
const next = admitPhaseEvent(record, admitted.event);
await persistAssembledExamRun(durable, assembledExamRuns, examRunOwners, sessionOwners, next);
return context.json(toContract(decide(next)), 201);
return context.json(toContract(decide(next), next), 201);
} catch (error) {
return assembledExamRunError(context, error);
}
Expand Down Expand Up @@ -225,7 +225,11 @@ function toOrchestratorEvent(event: ApiAssembledExamAdmittedPhaseEvent): Assembl
};
}

function toContract(decision: AssembledExamResumeDecision) {
function toContract(decision: AssembledExamResumeDecision, record: ApiAssembledExamRunRecord) {
const orderedStations = [...record.orderedStations].sort((left, right) => left.stationOrder - right.stationOrder);
const admittedPhaseEvents = [...record.admittedPhaseEvents].sort(
(left, right) => left.stationOrder - right.stationOrder || left.sequence - right.sequence,
);
return {
examRunId: decision.examRunId,
stationRunId: decision.selectedStation?.stationRunId ?? null,
Expand All @@ -238,6 +242,8 @@ function toContract(decision: AssembledExamResumeDecision) {
claimBoundary: decision.claimBoundary,
notEvidenceFor: decision.notEvidenceFor,
examEquivalenceGate: false as const,
orderedStations,
admittedPhaseEvents,
};
}

Expand Down Expand Up @@ -399,6 +405,7 @@ function parsePhaseAdmission(
durableEventRef: expectedRef,
phase: ASSEMBLED_EXAM_PHASE_BY_TYPE[eventType],
source: typeof body.source === "string" && body.source.trim().length > 0 ? body.source : "system",
recordedAtIso: new Date().toISOString(),
...(eventType === "station.advanced" ? { advanceReason: String(body.advanceReason).trim() } : {}),
},
};
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/runtime-durable-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export type ApiAssembledExamAdmittedPhaseEvent = {
durableEventRef: string;
phase: AssembledExamPhase;
source: string;
/** Durable admission time. Older stored aggregates may omit it and must not be used to invent UI outcome time. */
recordedAtIso?: string;
advanceReason?: string;
};

Expand Down
Loading
Loading