265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import {
|
|
BirthTimeJourneyEngineConfigurationError,
|
|
createJourneyEngineMethods,
|
|
createJourneyEngineWire,
|
|
eventScorePayload,
|
|
} from "../src/lib/birth-time-journey-engine-model.ts";
|
|
import type { JourneyEngineFetch } from "../src/lib/birth-time-journey-engine-model.ts";
|
|
import { resolveDynamicRectificationToken } from "../src/lib/birth-time-dynamic-token.ts";
|
|
|
|
test("journey engine serializes only stored event-scoring inputs", () => {
|
|
const payload = eventScorePayload({
|
|
birthDate: "1993-04-17",
|
|
startTime: "14:00",
|
|
endTime: "15:00",
|
|
lat: 31.2304,
|
|
lon: 121.4737,
|
|
tz: 8,
|
|
events: [
|
|
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month", summary: "晋升为团队负责人" },
|
|
{ id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "education", date: "2011", precision: "year" },
|
|
{ id: "0ef52e51-ab5f-453b-81e5-adb44a929224", domain: "relationship", date: "2021-05-01", precision: "day" },
|
|
],
|
|
});
|
|
|
|
assert.deepEqual(payload, {
|
|
birth_date: "1993-04-17",
|
|
start_time: "14:00",
|
|
end_time: "15:00",
|
|
lat: 31.2304,
|
|
lon: 121.4737,
|
|
tz: 8,
|
|
high_rigor: true,
|
|
events: [
|
|
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month", summary: "晋升为团队负责人" },
|
|
{ id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "education", date: "2011", precision: "year" },
|
|
{ id: "0ef52e51-ab5f-453b-81e5-adb44a929224", domain: "relationship", date: "2021-05-01", precision: "day" },
|
|
],
|
|
});
|
|
assert.equal("confidence" in payload, false);
|
|
assert.equal("can_apply" in payload, false);
|
|
});
|
|
|
|
const dynamicInput = {
|
|
caseId: "case-1",
|
|
asOfDate: "2026-07-18",
|
|
birthDate: "1990-01-01",
|
|
startTime: "05:30",
|
|
endTime: "05:33",
|
|
lat: 31.23,
|
|
lon: 121.47,
|
|
tz: 8,
|
|
evidence: [{
|
|
questionId: "question-1",
|
|
opportunityId: "career-window",
|
|
partitionId: "career-early",
|
|
dimensionCode: "career",
|
|
candidateScores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 },
|
|
informationGain: 0.5,
|
|
}],
|
|
events: [{
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
domain: "career",
|
|
date: "2020",
|
|
precision: "year",
|
|
}],
|
|
dismissedOpportunityIds: ["dismissed-1"],
|
|
questionFingerprints: ["question-fingerprint-1"],
|
|
partitionFingerprints: ["partition-fingerprint-1"],
|
|
recentRanges: [{ startTime: "05:30", endTime: "05:33" }],
|
|
candidateModel: { version: "birth-time-choice-scoring-v2" },
|
|
} as const;
|
|
|
|
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,
|
|
events: [{
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
domain: "career",
|
|
date: "2020",
|
|
precision: "year",
|
|
}],
|
|
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": {
|
|
success: true,
|
|
endpoint: "dynamic_rectification_opportunities",
|
|
case_id: "case-1",
|
|
scoring_version: "birth-time-choice-scoring-v2",
|
|
current_range: { start_time: "05:30", end_time: "05:33" },
|
|
opportunities: [],
|
|
asked_question_fingerprints: [],
|
|
candidate_partition_fingerprints: [],
|
|
recent_range_history: [],
|
|
candidate_model: {},
|
|
},
|
|
"/api/dynamic_rectification_score": {
|
|
success: true,
|
|
endpoint: "dynamic_rectification_score",
|
|
result_id: "1d8ee348-61a3-433d-8907-ff6d281b9992",
|
|
confidence: "low",
|
|
can_apply: false,
|
|
winning_segment: null,
|
|
event_count: 1,
|
|
domain_count: 1,
|
|
top_score: 0.5,
|
|
second_score: 0,
|
|
margin_percent: 50,
|
|
reasons: ["insufficient_effective_evidence"],
|
|
evidence: [],
|
|
algorithm_version: "birth-time-choice-scoring-v2",
|
|
evidence_mode: "dynamic_choice",
|
|
effective_answer_count: 1,
|
|
dimension_count: 1,
|
|
},
|
|
};
|
|
|
|
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];
|
|
return { ok: true, status: 200, async json() { return payload; } };
|
|
};
|
|
const wire = createJourneyEngineWire({
|
|
apiBase: "https://engine.invalid",
|
|
dynamicToken,
|
|
fetchImpl,
|
|
signalFactory(timeoutMs) {
|
|
timeoutCalls.push(timeoutMs);
|
|
return timeoutSignal;
|
|
},
|
|
});
|
|
return { calls, timeoutCalls, timeoutSignal, engine: createJourneyEngineMethods(wire) };
|
|
}
|
|
|
|
test("both dynamic endpoints send exact bodies with bearer auth and timeout signals", async () => {
|
|
const harness = engineHarness("server-secret");
|
|
await harness.engine.buildDifferencePacket(dynamicInput);
|
|
await harness.engine.scoreChoices(dynamicInput);
|
|
|
|
assert.equal(harness.calls.length, 2);
|
|
const opportunityCall = harness.calls[0];
|
|
const scoreCall = harness.calls[1];
|
|
assert.ok(opportunityCall);
|
|
assert.ok(scoreCall);
|
|
assert.equal(opportunityCall.url, "https://engine.invalid/api/dynamic_rectification_opportunities");
|
|
assert.equal(scoreCall.url, "https://engine.invalid/api/dynamic_rectification_score");
|
|
assert.equal(new Headers(opportunityCall.init.headers).get("authorization"), "Bearer server-secret");
|
|
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)), 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 () => {
|
|
const harness = engineHarness(null);
|
|
await assert.rejects(
|
|
harness.engine.buildDifferencePacket(dynamicInput),
|
|
BirthTimeJourneyEngineConfigurationError,
|
|
);
|
|
await assert.rejects(
|
|
harness.engine.scoreChoices(dynamicInput),
|
|
BirthTimeJourneyEngineConfigurationError,
|
|
);
|
|
assert.equal(harness.calls.length, 0);
|
|
});
|
|
|
|
test("dynamic token derives only from a server-side service role fallback", () => {
|
|
assert.equal(resolveDynamicRectificationToken("configured-token", "service-role"), "configured-token");
|
|
assert.match(resolveDynamicRectificationToken(undefined, "service-role") ?? "", /^[a-f0-9]{64}$/);
|
|
assert.equal(resolveDynamicRectificationToken(undefined, undefined), null);
|
|
});
|
|
|
|
test("legacy wire calls never receive dynamic authorization", async () => {
|
|
const calls: { readonly path: string; readonly init: RequestInit }[] = [];
|
|
const engine = createJourneyEngineMethods(createJourneyEngineWire({
|
|
apiBase: "https://engine.invalid",
|
|
dynamicToken: "server-secret",
|
|
fetchImpl: async (url, init) => {
|
|
const path = new URL(url).pathname;
|
|
calls.push({ path, init });
|
|
const payload = path === "/api/active_rectification_questions"
|
|
? { questions: [], candidate_scan: { samples: [] } }
|
|
: path === "/api/active_rectification_score"
|
|
? { answered_count: 0, candidate_cluster_rankings: [], next_round: null, next_round_questions: [] }
|
|
: {
|
|
result_id: "1d8ee348-61a3-433d-8907-ff6d281b9992", confidence: "low", can_apply: false,
|
|
winning_segment: null, event_count: 0, domain_count: 0, top_score: 0, second_score: 0,
|
|
margin_percent: 0, reasons: [], evidence: [], algorithm_version: "birth-time-event-scoring-v1",
|
|
};
|
|
return { ok: true, status: 200, async json() { return payload; } };
|
|
},
|
|
}));
|
|
const scan = await engine.scan({ birthTime: "1990-01-01 05:30", uncertaintyMinutes: 3, lat: 31.23, lon: 121.47, tz: 8, ayanamsa: "lahiri" });
|
|
await engine.score({ questionnaire: scan.questionnaire, answers: {} });
|
|
await engine.scoreEvents({ birthDate: "1990-01-01", startTime: "05:30", endTime: "05:33", lat: 31.23, lon: 121.47, tz: 8, events: [] });
|
|
assert.deepEqual(calls.map((call) => call.path), [
|
|
"/api/active_rectification_questions", "/api/active_rectification_score", "/api/active_rectification_events",
|
|
]);
|
|
for (const call of calls) {
|
|
assert.equal(new Headers(call.init.headers).has("authorization"), false);
|
|
assert.ok(call.init.signal instanceof AbortSignal);
|
|
}
|
|
});
|
|
|
|
function componentAndHookFiles(directory: string): string[] {
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) return componentAndHookFiles(path);
|
|
return /\.(ts|tsx)$/.test(entry.name) ? [path] : [];
|
|
});
|
|
}
|
|
|
|
test("candidate scores stay out of the specified client ownership boundary", () => {
|
|
const files = [
|
|
new URL("../src/lib/birth-time-journey-client.ts", import.meta.url).pathname,
|
|
new URL("../src/lib/birth-time-journey-request.ts", import.meta.url).pathname,
|
|
...componentAndHookFiles(new URL("../src/components", import.meta.url).pathname),
|
|
...componentAndHookFiles(new URL("../src/hooks", import.meta.url).pathname),
|
|
];
|
|
for (const path of files) {
|
|
assert.equal(readFileSync(path, "utf8").includes("candidate_scores"), false, path);
|
|
}
|
|
});
|