fix: complete dynamic engine cleanup
This commit is contained in:
@@ -1,103 +1,84 @@
|
||||
# Task 3 — TypeScript Engine Adapter and Trust Boundary
|
||||
|
||||
## Implementation
|
||||
## Final design
|
||||
|
||||
- Added snake-case serializers for dynamic opportunity and deterministic choice-scoring requests. Only server-resolved `ServerChoiceEvidence` becomes `partition_id` / `candidate_scores`; client option IDs and confidence fields are never serialized.
|
||||
- Added strict dynamic response adapters. Opportunity responses are versioned, endpoint-bound, exact-field parsed, and split into a model-safe `packet`, private `candidateModel`, and private `scoringPartitions`.
|
||||
- Candidate score maps must contain finite nonnegative values keyed by exactly every minute in the submitted range, including cross-midnight ranges. Duplicate opportunity/partition IDs and unexpected response fields are rejected.
|
||||
- Dynamic scoring responses require `birth-time-choice-scoring-v2`, `dynamic_choice` evidence mode, empty public evidence, matching compatibility/effective counts, and the existing deterministic candidate safety gates.
|
||||
- Added authenticated server-only engine calls for both dynamic Python endpoints. They fail before fetch when `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN` is absent and send the configured value only as a bearer header. Legacy scan, questionnaire score, and dated-event score calls remain unauthenticated.
|
||||
- Extended the legacy-compatible engine contract with optional dynamic methods and added `DynamicBirthTimeJourneyEngine`, where both methods are required. The production factory returns the required dynamic subtype while existing legacy-only test doubles remain source-compatible.
|
||||
- Raised only the shared candidate-result compatibility cap from 6 to 10 and changed the high-gate message to “effective evidence items.” The dated-event request contract remains capped at 6.
|
||||
- Added source-contract coverage preventing snake-case or camel-case candidate model, partition, score, and token identifiers from entering client, request, response, component, or hook modules.
|
||||
- `BirthTimeJourneyEngine` requires both dynamic operations: `buildDifferencePacket` and `scoreChoices`. Existing scan/score consumers depend on the explicit `LegacyBirthTimeJourneyEngine` pick instead of weakening the primary interface.
|
||||
- The server-only engine factory owns `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN`. Both dynamic endpoints use an authenticated POST and a 45-second abort signal; all three legacy endpoints remain unauthenticated.
|
||||
- Request serializers expose only server-resolved choice evidence. Client option IDs, confidence, applicability, and model-controlled safety gates are never sent as scoring authority.
|
||||
- Dynamic v2 responses have a dedicated strict adapter. Root objects, ranges, opportunities, partitions, winning segments, score-map keys, counts, versions, modes, and duplicate identifiers are validated before mapping.
|
||||
- Public difference packets omit private candidate score vectors. Private scoring partitions retain the exact server vector used by the later deterministic scoring call.
|
||||
- Legacy response parsing remains compatibility-oriented: unknown server metadata is accepted and stripped. Only the shared result representation supports up to ten effective items; the legacy dated-event request remains capped at six.
|
||||
- The HTTP wire accepts injected fetch and timeout-signal factories for executable contract tests. Production still defaults to `AbortSignal.timeout`.
|
||||
|
||||
## Files changed
|
||||
|
||||
Production:
|
||||
|
||||
- `frontend/src/lib/birth-time-evidence.ts`
|
||||
- `frontend/src/lib/birth-time-journey-service.ts`
|
||||
- `frontend/src/lib/birth-time-journey-engine.ts`
|
||||
- `frontend/src/lib/birth-time-journey-adapters.ts`
|
||||
- `frontend/src/lib/birth-time-journey-engine-model.ts`
|
||||
- `frontend/src/lib/birth-time-evidence.ts`
|
||||
- `frontend/src/lib/birth-time-journey-adapters.ts`
|
||||
- `frontend/src/lib/birth-time-journey-dynamic-adapters.ts`
|
||||
- `frontend/src/lib/birth-time-journey-assessment.ts`
|
||||
|
||||
Tests and support:
|
||||
|
||||
- `frontend/tests/birth-time-journey-engine.test.ts`
|
||||
- `frontend/tests/birth-time-journey-adapters.test.ts`
|
||||
- `frontend/tests/birth-time-journey-dynamic-adapters.test.ts`
|
||||
- `frontend/tests/birth-time-journey-memory-store.ts`
|
||||
- `frontend/tests/birth-time-journey-test-support.ts`
|
||||
- `frontend/tests/birth-time-journey-service.test.ts`
|
||||
- `frontend/tests/birth-time-agent-flow-test-support.ts`
|
||||
|
||||
Documentation:
|
||||
|
||||
- `docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md`
|
||||
- `.superpowers/sdd/task-3-report.md`
|
||||
|
||||
## RED
|
||||
## RED evidence
|
||||
|
||||
1. Bundled Node focused run:
|
||||
- `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts`
|
||||
- Failed during module instantiation because `parseCandidateDifferenceBuild` and `parseDynamicChoiceScoring` did not exist. Baseline result: 1 pass, 1 file-load failure.
|
||||
2. After the first adapter implementation, the malformed score-key regression failed because a response containing `candidate_scores: { "not-a-time": 1 }` was accepted.
|
||||
3. The exact-range regression then failed because an otherwise valid `05:34` score key could be added outside the `05:30—05:33` range.
|
||||
4. The initial source-level legacy-auth assertion was too broad and matched the next dynamic method. It was narrowed to the exact legacy method section; production behavior did not change for this test-only correction.
|
||||
1. The initial focused run failed to load because the dynamic response parsers did not exist.
|
||||
2. Adapter regressions then exposed acceptance of malformed score keys, keys outside the submitted range, duplicate opportunity/partition identifiers, nested extra fields, and legacy evidence metadata incompatibility.
|
||||
3. Interface and wire review probes exposed optional primary dynamic methods, source-regex authentication assertions, and a missing executable proof for exact URLs, bodies, authorization, and timeout behavior.
|
||||
4. The final wire cleanup test injected a timeout factory and failed with `[]` instead of `[45000, 45000]`, proving that the seam was initially ignored.
|
||||
|
||||
## GREEN
|
||||
## Final verification
|
||||
|
||||
1. Focused Task 3 suite:
|
||||
- `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts`
|
||||
- 21 passed, 0 failed.
|
||||
2. Complete birth-time frontend regression suite:
|
||||
1. Focused adapter, wire, and evidence suite:
|
||||
- `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts tests/birth-time-journey-dynamic-adapters.test.ts tests/birth-time-evidence.test.ts`
|
||||
- 29 passed, 0 failed.
|
||||
2. Complete birth-time frontend suite:
|
||||
- `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time*.test.ts`
|
||||
- 207 passed, 0 failed.
|
||||
3. Focused ESLint over all Task 3 source and test files:
|
||||
- `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node node_modules/eslint/bin/eslint.js ...`
|
||||
- 208 passed, 0 failed.
|
||||
3. Full frontend suite:
|
||||
- `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts`
|
||||
- 283 passed, 0 failed.
|
||||
4. ESLint across every changed production/test TypeScript module:
|
||||
- Passed with no diagnostics.
|
||||
4. TypeScript diagnostic:
|
||||
- `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node node_modules/typescript/bin/tsc --noEmit --pretty false`
|
||||
- No Task 3 diagnostics. It reproduces only the existing baseline `TS1501` in `tests/profile-persistence.test.ts:7` because the project targets ES2017 while that test uses an ES2018 regex flag.
|
||||
5. `git diff --check`
|
||||
5. TypeScript diagnostic:
|
||||
- No Task 3 diagnostics. The only result is the known baseline `tests/profile-persistence.test.ts:7 TS1501`, caused by an ES2018 regex flag under the project's ES2017 target.
|
||||
6. Pure-LOC audit:
|
||||
- Every changed TypeScript file is at or below 250 pure LOC. The largest is `frontend/src/lib/birth-time-journey-service.ts` at 239; the split test-support modules are 171 and 111.
|
||||
7. `git diff --check`:
|
||||
- Passed with no whitespace errors.
|
||||
|
||||
## Pre-work gate
|
||||
|
||||
- Ran `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` after reading both required sweep documents and the error ledger.
|
||||
- The gate remained red only on the unrelated known fragment-governance baseline: `candidate_count` expected `0`, observed `1`. Remote visibility was blocked, so no cloud-sync claim is made.
|
||||
- `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` remained red only on the unrelated known fragment-governance baseline: `candidate_count` expected `0`, observed `1`.
|
||||
- Remote visibility was blocked, so no cloud-sync claim is made.
|
||||
|
||||
## Self-review
|
||||
|
||||
- Dynamic secrets and score vectors stay behind the existing server-only engine entrypoint. The public dynamic packet deliberately drops `candidateScores`, while the private partition map retains the exact server vector for later binding.
|
||||
- Both response adapters are strict at their dynamic endpoint roots; model-controlled extra fields cannot be silently retained.
|
||||
- The dynamic scorer cannot elevate confidence by mismatching effective counts, dimensions, evidence mode, evidence contents, algorithm version, or the existing high-confidence candidate gates.
|
||||
- Cross-midnight ranges enumerate minutes modulo 24 hours, preventing a valid `23:59—00:00` result from being rejected or split.
|
||||
- Legacy request payloads, endpoints, timeout, parsing behavior, and authentication remain unchanged. The event request schema remains at 6 even though the shared result compatibility schema can represent the v2 cap of 10.
|
||||
- No dependency, logging, model prompt field, client response field, or persistence write was added.
|
||||
- Dynamic secrets and candidate score vectors remain behind the server boundary.
|
||||
- Wire tests use independent literal request bodies rather than production serializers and directly assert two `45_000` timeout calls and the exact injected signals.
|
||||
- Missing-token tests prove both dynamic operations fail before fetch. Executable legacy tests prove no Authorization header reaches any legacy endpoint.
|
||||
- Dynamic parsing is fail-closed; legacy parsing preserves its prior accept-and-strip behavior.
|
||||
- Cross-midnight ranges enumerate minutes modulo 24 hours and bind score keys to the exact submitted interval.
|
||||
- The extracted memory store has no dependency on the fixture module, so its re-export does not create a runtime cycle.
|
||||
- No dependency, logging field, client response field, or persistence write was added.
|
||||
|
||||
## Concerns
|
||||
## Known unrelated baseline
|
||||
|
||||
- The repository still lacks a standalone runtime `server-only` package for direct Node imports. Per the approved module-boundary decision, verification uses the production file's existing `import "server-only"` plus strict projection and source-contract tests rather than adding an unavailable dependency.
|
||||
- The complete TypeScript diagnostic remains blocked by the unrelated ES2017/ES2018 regex baseline described above.
|
||||
|
||||
## Review fixes
|
||||
|
||||
- Restored byte-compatible legacy candidate parsing. Unknown nested evidence metadata is accepted and stripped exactly as before Task 3; dynamic response parsing no longer changes the legacy schema.
|
||||
- Moved every v2 response schema, invariant, and mapping into `birth-time-journey-dynamic-adapters.ts`. Root responses, ranges, opportunities, partitions, and winning segments are strict; empty dynamic evidence is enforced before candidate construction.
|
||||
- Split dynamic adapter regressions into `birth-time-journey-dynamic-adapters.test.ts`. Added duplicate opportunity/partition attacks, nested extra-field attacks, exact-range keys, cross-midnight mapping, and independent expected-value assertions.
|
||||
- Made `buildDifferencePacket` and `scoreChoices` required on the primary `BirthTimeJourneyEngine`. Existing services use the explicit `LegacyBirthTimeJourneyEngine` pick, and legacy-only test doubles were narrowed without runtime behavior changes.
|
||||
- Refactored HTTP execution into `createJourneyEngineWire`, whose `post` accepts one typed request object. The server-only factory remains the environment owner; engine assembly is injectable for executable fake-fetch verification without adding the unavailable `server-only` runtime dependency.
|
||||
- Replaced greedy source-regex authentication tests with executable coverage for both exact dynamic URLs, serialized bodies, POST method, bearer header, 45-second abort signal, and missing-token zero-fetch behavior. All three legacy engine endpoints execute without an Authorization header.
|
||||
- Removed newly introduced non-null assertions and narrowed the ownership scan to the client/request/component/hook boundary named by the plan.
|
||||
- Kept every modified production and test TypeScript module at or below 250 pure LOC. The largest is `birth-time-journey-test-support.ts` at 249; Task 3 production modules range from 19 to 239.
|
||||
|
||||
### Review RED
|
||||
|
||||
1. The legacy compatibility regression failed with `unrecognized_keys` when an existing engine evidence item contained extra server metadata.
|
||||
2. The new dynamic adapter suite failed to load because the protocol-specific module did not yet exist.
|
||||
3. Independent review probes had shown nested dynamic `winning_segment` extras were silently stripped, optional primary engine methods weakened consumers, and the source-regex auth proof could remain green after removing authentication from one endpoint.
|
||||
|
||||
### Review GREEN
|
||||
|
||||
1. Focused legacy/dynamic/wire suite:
|
||||
- `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts tests/birth-time-journey-dynamic-adapters.test.ts`
|
||||
- 24 passed, 0 failed.
|
||||
2. Complete birth-time suite:
|
||||
- `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time*.test.ts`
|
||||
- 210 passed, 0 failed.
|
||||
3. Full frontend suite:
|
||||
- `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts`
|
||||
- 285 passed, 0 failed.
|
||||
4. Focused ESLint across all changed production/test TypeScript modules:
|
||||
- Passed with no diagnostics.
|
||||
5. TypeScript diagnostic:
|
||||
- No Task 3 diagnostics; only the known `tests/profile-persistence.test.ts:7 TS1501` baseline remains.
|
||||
6. Pure-LOC audit and `git diff --check`:
|
||||
- Every changed TypeScript file is at or below 250 pure LOC; no whitespace errors.
|
||||
- A clean TypeScript run is still blocked by `tests/profile-persistence.test.ts:7 TS1501`; Task 3 introduces no additional diagnostic.
|
||||
|
||||
@@ -83,15 +83,7 @@ const eventDomainSchema = z.enum([
|
||||
"career",
|
||||
"health_pressure",
|
||||
]);
|
||||
const candidateEvidenceApiSchema = z.object({
|
||||
event_id: z.string().uuid(),
|
||||
domain: eventDomainSchema,
|
||||
candidate_time: z.string(),
|
||||
rule_ids: z.array(z.string()),
|
||||
points: z.number(),
|
||||
});
|
||||
|
||||
const candidateResultApiFields = {
|
||||
const candidateResultApiSchema = z.object({
|
||||
result_id: z.string().uuid(),
|
||||
confidence: z.enum(["low", "medium", "high"]),
|
||||
can_apply: z.boolean(),
|
||||
@@ -107,10 +99,15 @@ const candidateResultApiFields = {
|
||||
second_score: z.number(),
|
||||
margin_percent: z.number(),
|
||||
reasons: z.array(z.string()),
|
||||
evidence: z.array(candidateEvidenceApiSchema),
|
||||
evidence: z.array(z.object({
|
||||
event_id: z.string().uuid(),
|
||||
domain: eventDomainSchema,
|
||||
candidate_time: z.string(),
|
||||
rule_ids: z.array(z.string()),
|
||||
points: z.number(),
|
||||
})),
|
||||
algorithm_version: z.string(),
|
||||
} as const;
|
||||
const candidateResultApiSchema = z.object(candidateResultApiFields).passthrough();
|
||||
}).passthrough();
|
||||
|
||||
class UnexpectedProfileSourceError extends Error {
|
||||
readonly name = "UnexpectedProfileSourceError";
|
||||
|
||||
@@ -14,8 +14,6 @@ import type {
|
||||
JourneyEventScoreInput,
|
||||
} from "./birth-time-journey-service.ts";
|
||||
|
||||
export const journeyEngineTimeoutMs = 45_000;
|
||||
|
||||
export class BirthTimeJourneyEngineError extends Error {
|
||||
readonly name = "BirthTimeJourneyEngineError";
|
||||
readonly status: number;
|
||||
@@ -55,7 +53,10 @@ export function createJourneyEngineWire(options: {
|
||||
readonly apiBase: string;
|
||||
readonly dynamicToken: string | null;
|
||||
readonly fetchImpl: JourneyEngineFetch;
|
||||
readonly signalFactory?: (timeoutMs: number) => AbortSignal;
|
||||
}): JourneyEngineWire {
|
||||
const signalFactory = options.signalFactory
|
||||
?? ((timeoutMs: number) => AbortSignal.timeout(timeoutMs));
|
||||
return {
|
||||
async post(input) {
|
||||
const token = options.dynamicToken?.trim();
|
||||
@@ -69,7 +70,7 @@ export function createJourneyEngineWire(options: {
|
||||
...(input.authentication === "dynamic" ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(input.body),
|
||||
signal: AbortSignal.timeout(journeyEngineTimeoutMs),
|
||||
signal: signalFactory(45_000),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new BirthTimeJourneyEngineError(response.status);
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
BirthTimeJourneyEngineConfigurationError,
|
||||
createJourneyEngineMethods,
|
||||
createJourneyEngineWire,
|
||||
differencePacketPayload,
|
||||
dynamicChoiceScorePayload,
|
||||
eventScorePayload,
|
||||
journeyEngineTimeoutMs,
|
||||
} from "../src/lib/birth-time-journey-engine-model.ts";
|
||||
import type { JourneyEngineFetch } from "../src/lib/birth-time-journey-engine-model.ts";
|
||||
|
||||
@@ -69,53 +66,39 @@ const dynamicInput = {
|
||||
candidateModel: { version: "birth-time-choice-scoring-v2" },
|
||||
} as const;
|
||||
|
||||
test("dynamic opportunity payload owns private evidence and candidate model server-side", () => {
|
||||
const payload = differencePacketPayload(dynamicInput);
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
case_id: "case-1",
|
||||
as_of_date: "2026-07-18",
|
||||
birth_date: "1990-01-01",
|
||||
start_time: "05:30",
|
||||
end_time: "05:33",
|
||||
lat: 31.23,
|
||||
lon: 121.47,
|
||||
tz: 8,
|
||||
evidence: [{
|
||||
question_id: "question-1",
|
||||
opportunity_id: "career-window",
|
||||
partition_id: "career-early",
|
||||
dimension_code: "career",
|
||||
candidate_scores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 },
|
||||
information_gain: 0.5,
|
||||
}],
|
||||
dismissed_opportunity_ids: ["dismissed-1"],
|
||||
question_fingerprints: ["question-fingerprint-1"],
|
||||
partition_fingerprints: ["partition-fingerprint-1"],
|
||||
recent_ranges: [{ start_time: "05:30", end_time: "05:33" }],
|
||||
candidate_model: { version: "birth-time-choice-scoring-v2" },
|
||||
});
|
||||
const evidence = payload.evidence[0];
|
||||
assert.ok(evidence);
|
||||
assert.equal("option_id" in evidence, false);
|
||||
});
|
||||
|
||||
test("dynamic score payload sends only server-resolved choice evidence", () => {
|
||||
const payload = dynamicChoiceScorePayload(dynamicInput);
|
||||
|
||||
assert.deepEqual(payload.choice_evidence, [{
|
||||
question_id: "question-1",
|
||||
opportunity_id: "career-window",
|
||||
partition_id: "career-early",
|
||||
dimension_code: "career",
|
||||
candidate_scores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 },
|
||||
information_gain: 0.5,
|
||||
}]);
|
||||
assert.equal("case_id" in payload, false);
|
||||
assert.equal("candidate_model" in payload, false);
|
||||
assert.equal("confidence" in payload, false);
|
||||
assert.equal("can_apply" in payload, false);
|
||||
});
|
||||
const expectedChoiceEvidence = [{
|
||||
question_id: "question-1",
|
||||
opportunity_id: "career-window",
|
||||
partition_id: "career-early",
|
||||
dimension_code: "career",
|
||||
candidate_scores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 },
|
||||
information_gain: 0.5,
|
||||
}] as const;
|
||||
const expectedOpportunityBody = {
|
||||
case_id: "case-1",
|
||||
as_of_date: "2026-07-18",
|
||||
birth_date: "1990-01-01",
|
||||
start_time: "05:30",
|
||||
end_time: "05:33",
|
||||
lat: 31.23,
|
||||
lon: 121.47,
|
||||
tz: 8,
|
||||
evidence: expectedChoiceEvidence,
|
||||
dismissed_opportunity_ids: ["dismissed-1"],
|
||||
question_fingerprints: ["question-fingerprint-1"],
|
||||
partition_fingerprints: ["partition-fingerprint-1"],
|
||||
recent_ranges: [{ start_time: "05:30", end_time: "05:33" }],
|
||||
candidate_model: { version: "birth-time-choice-scoring-v2" },
|
||||
} as const;
|
||||
const expectedScoreBody = {
|
||||
birth_date: "1990-01-01",
|
||||
start_time: "05:30",
|
||||
end_time: "05:33",
|
||||
lat: 31.23,
|
||||
lon: 121.47,
|
||||
tz: 8,
|
||||
choice_evidence: expectedChoiceEvidence,
|
||||
} as const;
|
||||
|
||||
const dynamicResponses: Readonly<Record<string, unknown>> = {
|
||||
"/api/dynamic_rectification_opportunities": {
|
||||
@@ -153,6 +136,8 @@ const dynamicResponses: Readonly<Record<string, unknown>> = {
|
||||
|
||||
function engineHarness(dynamicToken: string | null) {
|
||||
const calls: { readonly url: string; readonly init: RequestInit }[] = [];
|
||||
const timeoutCalls: number[] = [];
|
||||
const timeoutSignal = new AbortController().signal;
|
||||
const fetchImpl: JourneyEngineFetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
const payload = dynamicResponses[new URL(url).pathname];
|
||||
@@ -162,8 +147,12 @@ function engineHarness(dynamicToken: string | null) {
|
||||
apiBase: "https://engine.invalid",
|
||||
dynamicToken,
|
||||
fetchImpl,
|
||||
signalFactory(timeoutMs) {
|
||||
timeoutCalls.push(timeoutMs);
|
||||
return timeoutSignal;
|
||||
},
|
||||
});
|
||||
return { calls, engine: createJourneyEngineMethods(wire) };
|
||||
return { calls, timeoutCalls, timeoutSignal, engine: createJourneyEngineMethods(wire) };
|
||||
}
|
||||
|
||||
test("both dynamic endpoints send exact bodies with bearer auth and timeout signals", async () => {
|
||||
@@ -182,11 +171,11 @@ test("both dynamic endpoints send exact bodies with bearer auth and timeout sign
|
||||
assert.equal(new Headers(scoreCall.init.headers).get("authorization"), "Bearer server-secret");
|
||||
assert.equal(opportunityCall.init.method, "POST");
|
||||
assert.equal(scoreCall.init.method, "POST");
|
||||
assert.deepEqual(JSON.parse(String(opportunityCall.init.body)), differencePacketPayload(dynamicInput));
|
||||
assert.deepEqual(JSON.parse(String(scoreCall.init.body)), dynamicChoiceScorePayload(dynamicInput));
|
||||
assert.ok(opportunityCall.init.signal instanceof AbortSignal);
|
||||
assert.ok(scoreCall.init.signal instanceof AbortSignal);
|
||||
assert.equal(journeyEngineTimeoutMs, 45_000);
|
||||
assert.deepEqual(JSON.parse(String(opportunityCall.init.body)), expectedOpportunityBody);
|
||||
assert.deepEqual(JSON.parse(String(scoreCall.init.body)), expectedScoreBody);
|
||||
assert.deepEqual(harness.timeoutCalls, [45_000, 45_000]);
|
||||
assert.equal(opportunityCall.init.signal, harness.timeoutSignal);
|
||||
assert.equal(scoreCall.init.signal, harness.timeoutSignal);
|
||||
});
|
||||
|
||||
test("missing dynamic token fails both endpoints before fetch", async () => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
BirthTimeJourneyStore,
|
||||
PersistedJourneyAssessment,
|
||||
StoredRectificationCase,
|
||||
} from "../src/lib/birth-time-journey-service.ts";
|
||||
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
||||
import { createMemoryScoringJobs } from "./birth-time-scoring-memory-store.ts";
|
||||
|
||||
class MissingTestCaseError extends Error {
|
||||
readonly name = "MissingTestCaseError";
|
||||
}
|
||||
|
||||
export const journeyCaseId = "7299894c-10a8-4b45-91d1-339007282c50";
|
||||
|
||||
export function memoryStore(initialCase?: StoredRectificationCase) {
|
||||
let savedAssessment: PersistedJourneyAssessment | null = null;
|
||||
let savedCase = initialCase ?? null;
|
||||
let committedTurnWrites = 0;
|
||||
let legacyWrites = 0;
|
||||
let guidedCandidateWrites = 0;
|
||||
const scoringJobs = createMemoryScoringJobs({
|
||||
read: () => savedCase,
|
||||
write: (value) => {
|
||||
savedCase = value;
|
||||
},
|
||||
committed: () => {
|
||||
committedTurnWrites += 1;
|
||||
},
|
||||
});
|
||||
const store: BirthTimeJourneyStore = {
|
||||
async saveAssessment(value) {
|
||||
savedAssessment = value;
|
||||
return journeyCaseId;
|
||||
},
|
||||
async loadCase() {
|
||||
return savedCase;
|
||||
},
|
||||
async saveScoring(value) {
|
||||
legacyWrites += 1;
|
||||
savedCase = value;
|
||||
},
|
||||
async saveTurn(value, expectedVersion, actionId) {
|
||||
if (!savedCase) {
|
||||
throw new MissingTestCaseError();
|
||||
}
|
||||
const processedActionIds = savedCase.processedActionIds ?? [];
|
||||
if (processedActionIds.includes(actionId)) {
|
||||
return savedCase;
|
||||
}
|
||||
if (savedCase.turnVersion !== expectedVersion) {
|
||||
throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion ?? 0);
|
||||
}
|
||||
savedCase = {
|
||||
...value,
|
||||
turnVersion: expectedVersion + 1,
|
||||
processedActionIds: [...processedActionIds, actionId],
|
||||
};
|
||||
committedTurnWrites += 1;
|
||||
return savedCase;
|
||||
},
|
||||
...scoringJobs.methods,
|
||||
async saveCandidateResult(value) {
|
||||
legacyWrites += 1;
|
||||
savedCase = value;
|
||||
},
|
||||
async saveCandidate(value) {
|
||||
legacyWrites += 1;
|
||||
savedCase = value;
|
||||
},
|
||||
async confirmCandidate(value) {
|
||||
legacyWrites += 1;
|
||||
savedCase = value;
|
||||
},
|
||||
async commitGuidedCandidate(value, command) {
|
||||
if (!savedCase) {
|
||||
throw new MissingTestCaseError();
|
||||
}
|
||||
const receipt = command.actionId.toLowerCase();
|
||||
const receipts = savedCase.processedActionIds ?? [];
|
||||
if (receipts.includes(receipt)) {
|
||||
return savedCase;
|
||||
}
|
||||
if (savedCase.turnVersion !== command.expectedVersion) {
|
||||
throw new StaleJourneyTurnError(
|
||||
savedCase.id,
|
||||
command.expectedVersion,
|
||||
savedCase.turnVersion ?? 0,
|
||||
);
|
||||
}
|
||||
savedCase = {
|
||||
...value,
|
||||
turnVersion: command.expectedVersion + 1,
|
||||
processedActionIds: [...receipts, receipt],
|
||||
};
|
||||
guidedCandidateWrites += 1;
|
||||
return savedCase;
|
||||
},
|
||||
};
|
||||
return {
|
||||
store,
|
||||
savedAssessment: () => savedAssessment,
|
||||
savedCase: () => savedCase,
|
||||
committedTurnWrites: () => committedTurnWrites,
|
||||
legacyWrites: () => legacyWrites,
|
||||
guidedCandidateWrites: () => guidedCandidateWrites,
|
||||
scoringJobStatus: scoringJobs.status,
|
||||
scoringJobCount: scoringJobs.count,
|
||||
setScoringJobAlgorithm: scoringJobs.setAlgorithm,
|
||||
createdScoringCase: scoringJobs.createdCase,
|
||||
replaceCase: (value: StoredRectificationCase) => {
|
||||
savedCase = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { birthTimeAssessmentSchema, candidateResultSchema, lifeEventSchema } from "../src/lib/birth-time-journey.ts";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import type { BirthTimeJourneyStore, LegacyBirthTimeJourneyEngine, PersistedJourneyAssessment, StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
|
||||
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
||||
import type {
|
||||
LegacyBirthTimeJourneyEngine,
|
||||
StoredRectificationCase,
|
||||
} from "../src/lib/birth-time-journey-service.ts";
|
||||
import type { EvidenceDomain } from "../src/lib/birth-time-question-planner.ts";
|
||||
import { createMemoryScoringJobs } from "./birth-time-scoring-memory-store.ts";
|
||||
import {
|
||||
journeyCaseId,
|
||||
memoryStore,
|
||||
} from "./birth-time-journey-memory-store.ts";
|
||||
|
||||
class UnexpectedTestCallError extends Error { readonly name = "UnexpectedTestCallError"; }
|
||||
|
||||
class MissingTestCaseError extends Error {
|
||||
readonly name = "MissingTestCaseError";
|
||||
class UnexpectedTestCallError extends Error {
|
||||
readonly name = "UnexpectedTestCallError";
|
||||
}
|
||||
|
||||
export const journeyCaseId = "7299894c-10a8-4b45-91d1-339007282c50";
|
||||
export { journeyCaseId, memoryStore };
|
||||
export const draftActionId = "45857b75-4718-4590-aaf5-7113a03ea765";
|
||||
export const confirmActionId = "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5";
|
||||
export const secondActionId = "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea";
|
||||
@@ -53,89 +56,6 @@ export function scanWithSigns(signs: readonly string[]) {
|
||||
};
|
||||
}
|
||||
|
||||
export function memoryStore(initialCase?: StoredRectificationCase) {
|
||||
let savedAssessment: PersistedJourneyAssessment | null = null;
|
||||
let savedCase = initialCase ?? null;
|
||||
let committedTurnWrites = 0;
|
||||
let legacyWrites = 0;
|
||||
let guidedCandidateWrites = 0;
|
||||
const scoringJobs = createMemoryScoringJobs({
|
||||
read: () => savedCase,
|
||||
write: (value) => { savedCase = value; },
|
||||
committed: () => { committedTurnWrites += 1; },
|
||||
});
|
||||
const store: BirthTimeJourneyStore = {
|
||||
async saveAssessment(value) {
|
||||
savedAssessment = value;
|
||||
return journeyCaseId;
|
||||
},
|
||||
async loadCase() {
|
||||
return savedCase;
|
||||
},
|
||||
async saveScoring(value) {
|
||||
legacyWrites += 1;
|
||||
savedCase = value;
|
||||
},
|
||||
async saveTurn(value, expectedVersion, actionId) {
|
||||
if (!savedCase) throw new MissingTestCaseError();
|
||||
const processedActionIds = savedCase.processedActionIds ?? [];
|
||||
if (processedActionIds.includes(actionId)) return savedCase;
|
||||
if (savedCase.turnVersion !== expectedVersion) {
|
||||
throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion ?? 0);
|
||||
}
|
||||
savedCase = {
|
||||
...value,
|
||||
turnVersion: expectedVersion + 1,
|
||||
processedActionIds: [...processedActionIds, actionId],
|
||||
};
|
||||
committedTurnWrites += 1;
|
||||
return savedCase;
|
||||
},
|
||||
...scoringJobs.methods,
|
||||
async saveCandidateResult(value) {
|
||||
legacyWrites += 1;
|
||||
savedCase = value;
|
||||
},
|
||||
async saveCandidate(value) {
|
||||
legacyWrites += 1;
|
||||
savedCase = value;
|
||||
},
|
||||
async confirmCandidate(value) {
|
||||
legacyWrites += 1;
|
||||
savedCase = value;
|
||||
},
|
||||
async commitGuidedCandidate(value, command) {
|
||||
if (!savedCase) throw new MissingTestCaseError();
|
||||
const receipt = command.actionId.toLowerCase();
|
||||
const receipts = savedCase.processedActionIds ?? [];
|
||||
if (receipts.includes(receipt)) return savedCase;
|
||||
if (savedCase.turnVersion !== command.expectedVersion) {
|
||||
throw new StaleJourneyTurnError(savedCase.id, command.expectedVersion, savedCase.turnVersion ?? 0);
|
||||
}
|
||||
savedCase = {
|
||||
...value,
|
||||
turnVersion: command.expectedVersion + 1,
|
||||
processedActionIds: [...receipts, receipt],
|
||||
};
|
||||
guidedCandidateWrites += 1;
|
||||
return savedCase;
|
||||
},
|
||||
};
|
||||
return {
|
||||
store,
|
||||
savedAssessment: () => savedAssessment,
|
||||
savedCase: () => savedCase,
|
||||
committedTurnWrites: () => committedTurnWrites,
|
||||
legacyWrites: () => legacyWrites,
|
||||
guidedCandidateWrites: () => guidedCandidateWrites,
|
||||
scoringJobStatus: scoringJobs.status,
|
||||
scoringJobCount: scoringJobs.count,
|
||||
setScoringJobAlgorithm: scoringJobs.setAlgorithm,
|
||||
createdScoringCase: scoringJobs.createdCase,
|
||||
replaceCase: (value: StoredRectificationCase) => { savedCase = value; },
|
||||
};
|
||||
}
|
||||
|
||||
export const unusedJourneyEngine: LegacyBirthTimeJourneyEngine = {
|
||||
async scan() { throw new UnexpectedTestCallError(); },
|
||||
async score() { throw new UnexpectedTestCallError(); },
|
||||
|
||||
Reference in New Issue
Block a user