513 lines
18 KiB
TypeScript
513 lines
18 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
ConsultationProfileTruthError,
|
|
prepareConsultationRoute,
|
|
} from "../src/lib/consultation-route-service.ts";
|
|
import {
|
|
authoritativeSessionBinding,
|
|
ConsultationSubjectError,
|
|
consultationSubjectBindingFromSession,
|
|
consultationSubjectFailureResponse,
|
|
resolveConsultationSubject,
|
|
type OwnedChartProfile,
|
|
} from "../src/lib/consultation-subject-resolver.ts";
|
|
|
|
const selfProfile = Object.freeze({
|
|
name: "Synthetic Self",
|
|
birth_date: "1990-01-02",
|
|
reported_birth_time: "08:15:00",
|
|
active_birth_time: "08:15:00",
|
|
birth_time_source: "family_exact",
|
|
birth_time_status: "reported",
|
|
birth_place_label: "Synthetic City",
|
|
latitude: 31.2,
|
|
longitude: 121.5,
|
|
timezone_offset: 8,
|
|
country_code: "CN",
|
|
province_code: "310000",
|
|
city_code: "310100",
|
|
district_code: "",
|
|
});
|
|
|
|
const otherProfile = Object.freeze({
|
|
name: "Synthetic Other",
|
|
date: "1988-03-04",
|
|
reportedTime: "09:20",
|
|
time: "",
|
|
birthTimeSource: "family_exact",
|
|
birthTimeStatus: "reported",
|
|
birthPlaceLabel: "Synthetic Harbor",
|
|
latitude: 22.3,
|
|
longitude: 114.2,
|
|
timezoneOffset: 8,
|
|
countryCode: "CN",
|
|
ayanamsa: "lahiri",
|
|
});
|
|
|
|
const clientBirth = Object.freeze({
|
|
name: "Client Name",
|
|
year: 2001,
|
|
month: 2,
|
|
day: 3,
|
|
hour: 4,
|
|
minute: 5,
|
|
city: "Client City",
|
|
lat: 1,
|
|
lon: 2,
|
|
tz: 3,
|
|
});
|
|
|
|
function ownedOther(profile: unknown = otherProfile, userId = "user-self"): OwnedChartProfile {
|
|
return { id: "chart-other", userId, role: "other", profile };
|
|
}
|
|
|
|
test("self resolves from the authoritative profile and other resolves from the owned chart profile", async () => {
|
|
let selfLoads = 0;
|
|
let otherLoads = 0;
|
|
const self = await resolveConsultationSubject({
|
|
userId: "user-self",
|
|
binding: { chartProfileId: "self", chartProfileRole: "self", chartProfileName: "Client Name" },
|
|
clientBirth,
|
|
loadSelfProfile: async (userId) => {
|
|
assert.equal(userId, "user-self");
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
loadOwnedChartProfile: async () => {
|
|
otherLoads += 1;
|
|
return ownedOther();
|
|
},
|
|
});
|
|
assert.equal(selfLoads, 1);
|
|
assert.equal(otherLoads, 0);
|
|
assert.equal(self.role, "self");
|
|
assert.equal(self.name, "Synthetic Self");
|
|
assert.equal((self.profile as { birth_date: string }).birth_date, "1990-01-02");
|
|
|
|
const legacy = await resolveConsultationSubject({
|
|
userId: "user-self",
|
|
binding: { chartProfileId: null, chartProfileRole: null, chartProfileName: null },
|
|
clientBirth,
|
|
loadSelfProfile: async () => selfProfile,
|
|
loadOwnedChartProfile: async () => {
|
|
otherLoads += 1;
|
|
return ownedOther();
|
|
},
|
|
});
|
|
assert.equal(otherLoads, 0);
|
|
assert.equal(legacy.role, "self");
|
|
assert.equal((legacy.profile as { birth_date: string }).birth_date, "1990-01-02");
|
|
|
|
selfLoads = 0;
|
|
const other = await resolveConsultationSubject({
|
|
userId: "user-self",
|
|
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Stale Label" },
|
|
clientBirth,
|
|
loadSelfProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
loadOwnedChartProfile: async (chartProfileId) => {
|
|
assert.equal(chartProfileId, "chart-other");
|
|
return ownedOther();
|
|
},
|
|
});
|
|
assert.equal(selfLoads, 0);
|
|
assert.equal(other.role, "other");
|
|
assert.equal(other.name, "Synthetic Other");
|
|
assert.equal(other.chartProfileId, "chart-other");
|
|
const row = other.profile as { birth_date: string; latitude: number; reported_birth_time: string };
|
|
assert.equal(row.birth_date, "1988-03-04");
|
|
assert.equal(row.latitude, 22.3);
|
|
assert.equal(row.reported_birth_time, "09:20");
|
|
assert.equal("year" in row, false);
|
|
assert.equal("lat" in row, false);
|
|
});
|
|
|
|
test("another user's id, a random id, a role mismatch, and a deleted or incomplete profile fail closed", async () => {
|
|
const cases: Array<{ name: string; owned: OwnedChartProfile | null | Error; code: string }> = [
|
|
{ name: "other user", owned: ownedOther(otherProfile, "user-other"), code: "subject_not_found" },
|
|
{ name: "random id", owned: null, code: "subject_not_found" },
|
|
{ name: "deleted", owned: null, code: "subject_not_found" },
|
|
{
|
|
name: "role mismatch",
|
|
owned: { id: "chart-other", userId: "user-self", role: "self", profile: otherProfile },
|
|
code: "subject_role_mismatch",
|
|
},
|
|
{
|
|
name: "missing",
|
|
owned: null,
|
|
code: "subject_not_found",
|
|
},
|
|
{
|
|
name: "incomplete",
|
|
owned: ownedOther({ name: "Synthetic Other", year: 2001, lat: 1, city: "Client City" }),
|
|
code: "subject_incomplete",
|
|
},
|
|
];
|
|
|
|
for (const item of cases) {
|
|
let selfLoads = 0;
|
|
const error = await resolveConsultationSubject({
|
|
userId: "user-self",
|
|
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" },
|
|
clientBirth,
|
|
loadSelfProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
loadOwnedChartProfile: async () => {
|
|
if (item.owned instanceof Error) throw item.owned;
|
|
return item.owned;
|
|
},
|
|
}).then(() => null, (caught: unknown) => caught);
|
|
assert.ok(error instanceof ConsultationSubjectError, item.name);
|
|
assert.equal(error.code, item.code, item.name);
|
|
assert.equal(error.message.includes("Client Name"), false, item.name);
|
|
assert.equal(error.message.includes("Synthetic"), false, item.name);
|
|
assert.equal(selfLoads, 0, item.name);
|
|
}
|
|
});
|
|
|
|
test("a forged display name cannot complete or replace the server profile", async () => {
|
|
let selfLoads = 0;
|
|
const missingName = await resolveConsultationSubject({
|
|
userId: "user-self",
|
|
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" },
|
|
clientBirth,
|
|
loadSelfProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
loadOwnedChartProfile: async () => ownedOther({ ...otherProfile, name: "" }),
|
|
}).then(() => null, (caught: unknown) => caught);
|
|
assert.ok(missingName instanceof ConsultationSubjectError);
|
|
assert.equal(missingName.code, "subject_incomplete");
|
|
assert.equal(missingName.message.includes("Client Name"), false);
|
|
assert.equal(selfLoads, 0);
|
|
|
|
const claimedSelf = await resolveConsultationSubject({
|
|
userId: "user-self",
|
|
binding: { chartProfileId: "chart-other", chartProfileRole: "self", chartProfileName: "Synthetic Self" },
|
|
clientBirth,
|
|
loadSelfProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
loadOwnedChartProfile: async () => ownedOther(),
|
|
}).then(() => null, (caught: unknown) => caught);
|
|
assert.ok(claimedSelf instanceof ConsultationSubjectError);
|
|
assert.equal(claimedSelf.code, "subject_role_mismatch");
|
|
assert.equal(selfLoads, 0);
|
|
});
|
|
|
|
test("database failures stay closed and do not leak internals", async () => {
|
|
const error = await resolveConsultationSubject({
|
|
userId: "user-self",
|
|
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null },
|
|
loadSelfProfile: async () => selfProfile,
|
|
loadOwnedChartProfile: async () => {
|
|
throw new Error("duplicate key value violates unique constraint for user-other@example");
|
|
},
|
|
}).then(() => null, (caught: unknown) => caught);
|
|
assert.ok(error instanceof ConsultationSubjectError);
|
|
assert.equal(error.code, "subject_unavailable");
|
|
assert.equal(error.message.includes("duplicate"), false);
|
|
assert.equal(error.message.includes("example"), false);
|
|
const response = consultationSubjectFailureResponse(error);
|
|
const encoded = JSON.stringify(response);
|
|
assert.equal(encoded.includes("duplicate"), false);
|
|
assert.equal(encoded.includes("user-other"), false);
|
|
assert.equal(encoded.includes("@"), false);
|
|
assert.match(response.body.message, /不会扣点/);
|
|
});
|
|
|
|
test("conflicting client birth fields still lose to the server profile", async () => {
|
|
let reserves = 0;
|
|
let selfLoads = 0;
|
|
const prepared = await prepareConsultationRoute({
|
|
userId: "user-self",
|
|
mode: "unverified_birth_time",
|
|
subject: {
|
|
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" },
|
|
clientBirth,
|
|
loadOwnedChartProfile: async () => ownedOther(),
|
|
},
|
|
loadProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
beforeReserve({ serverChart, consultationMode }) {
|
|
assert.equal(consultationMode, "unverified_birth_time");
|
|
assert.equal(serverChart?.name, "Synthetic Other");
|
|
assert.equal(serverChart?.toolInput.year, 1988);
|
|
assert.equal(serverChart?.toolInput.month, 3);
|
|
assert.equal(serverChart?.toolInput.day, 4);
|
|
assert.equal(serverChart?.toolInput.hour, 9);
|
|
assert.equal(serverChart?.toolInput.minute, 20);
|
|
assert.equal(serverChart?.toolInput.city, "Synthetic Harbor");
|
|
assert.equal(serverChart?.toolInput.lat, 22.3);
|
|
assert.equal(serverChart?.toolInput.lon, 114.2);
|
|
assert.equal(serverChart?.toolInput.tz, 8);
|
|
assert.equal(serverChart?.toolInput.ayanamsa, "lahiri");
|
|
},
|
|
reserve: async () => {
|
|
reserves += 1;
|
|
return { charged: true };
|
|
},
|
|
});
|
|
assert.equal(selfLoads, 0);
|
|
assert.equal(reserves, 1);
|
|
assert.equal(prepared.subject?.role, "other");
|
|
assert.equal(prepared.subject?.name, "Synthetic Other");
|
|
assert.notEqual(prepared.serverChart?.toolInput.year, clientBirth.year);
|
|
});
|
|
|
|
test("consult preparation receives the other profile and a resolver failure does not charge or prepare a model call", async () => {
|
|
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
|
assert.match(route, /chart_profile_id,chart_profile_name,chart_profile_role/);
|
|
assert.match(route, /clientBirth: parsed\.data/);
|
|
assert.match(route, /loadOwnedChartProfile/);
|
|
assert.match(route, /consultationSubjectBindingFromSession\(chatSession\)/);
|
|
assert.doesNotMatch(route, /parsed\.data\.name/);
|
|
const failureHandler = route.slice(
|
|
route.indexOf("instanceof ConsultationSubjectError"),
|
|
route.indexOf("const modelSelection"),
|
|
);
|
|
assert.match(failureHandler, /consultationSubjectFailureResponse/);
|
|
assert.doesNotMatch(failureHandler, /getJyotishAgent|streamAgentResponse|streamTextResponse/);
|
|
|
|
let reserves = 0;
|
|
let selfLoads = 0;
|
|
let preparations = 0;
|
|
const error = await prepareConsultationRoute({
|
|
userId: "user-self",
|
|
mode: "unverified_birth_time",
|
|
subject: {
|
|
binding: consultationSubjectBindingFromSession({
|
|
chart_profile_id: "chart-missing",
|
|
chart_profile_role: "other",
|
|
chart_profile_name: "Synthetic Other",
|
|
}),
|
|
clientBirth,
|
|
loadOwnedChartProfile: async () => null,
|
|
},
|
|
loadProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
beforeReserve() {
|
|
preparations += 1;
|
|
},
|
|
reserve: async () => {
|
|
reserves += 1;
|
|
return { charged: true };
|
|
},
|
|
}).then(() => null, (caught: unknown) => caught);
|
|
assert.ok(error instanceof ConsultationSubjectError);
|
|
assert.equal(error.code, "subject_not_found");
|
|
assert.equal(selfLoads, 0);
|
|
assert.equal(preparations, 0);
|
|
assert.equal(reserves, 0);
|
|
assert.equal(error instanceof ConsultationProfileTruthError, false);
|
|
});
|
|
|
|
test("an other chart does not inherit the account candidate range", async () => {
|
|
let rangeLoads = 0;
|
|
const prepared = await prepareConsultationRoute({
|
|
userId: "user-self",
|
|
mode: "verified_chart",
|
|
subject: {
|
|
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null },
|
|
loadOwnedChartProfile: async () => ownedOther({
|
|
...otherProfile,
|
|
time: "09:20",
|
|
birthTimeStatus: "confirmed",
|
|
}),
|
|
},
|
|
loadProfile: async () => selfProfile,
|
|
loadCandidateRange: async () => {
|
|
rangeLoads += 1;
|
|
return { startTime: "08:00", endTime: "08:30" };
|
|
},
|
|
reserve: async () => "reserved",
|
|
});
|
|
assert.equal(rangeLoads, 0);
|
|
assert.equal(prepared.serverChart?.toolInput.hour, 9);
|
|
assert.equal("candidate_range" in (prepared.serverChart?.toolInput ?? {}), false);
|
|
});
|
|
|
|
test("a session with messages rejects rebinding and an empty session stores the server name", async () => {
|
|
const stored = consultationSubjectBindingFromSession({
|
|
chart_profile_id: "chart-other",
|
|
chart_profile_role: "other",
|
|
chart_profile_name: "Synthetic Other",
|
|
});
|
|
let selfLoads = 0;
|
|
const locked = await authoritativeSessionBinding({
|
|
userId: "user-self",
|
|
requested: {
|
|
chart_profile_id: "chart-next",
|
|
chart_profile_role: "other",
|
|
chart_profile_name: "Client Name",
|
|
},
|
|
existingMessages: [{ role: "user", text: "synthetic question" }],
|
|
existingBinding: stored,
|
|
loadSelfProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
loadOwnedChartProfile: async () => ownedOther(),
|
|
}).then(() => null, (caught: unknown) => caught);
|
|
assert.ok(locked instanceof ConsultationSubjectError);
|
|
assert.equal(locked.code, "subject_locked");
|
|
assert.equal(selfLoads, 0);
|
|
const lockedResponse = consultationSubjectFailureResponse(locked);
|
|
assert.equal(lockedResponse.status, 409);
|
|
assert.equal(JSON.stringify(lockedResponse).includes("Client Name"), false);
|
|
|
|
const forgedRole = await authoritativeSessionBinding({
|
|
userId: "user-self",
|
|
requested: {
|
|
chart_profile_id: "chart-other",
|
|
chart_profile_role: "self",
|
|
chart_profile_name: "Synthetic Self",
|
|
},
|
|
existingMessages: [],
|
|
existingBinding: null,
|
|
loadSelfProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
loadOwnedChartProfile: async () => ownedOther(),
|
|
}).then(() => null, (caught: unknown) => caught);
|
|
assert.ok(forgedRole instanceof ConsultationSubjectError);
|
|
assert.equal(forgedRole.code, "subject_role_mismatch");
|
|
assert.equal(selfLoads, 0);
|
|
|
|
const created = await authoritativeSessionBinding({
|
|
userId: "user-self",
|
|
requested: {
|
|
chart_profile_id: "chart-other",
|
|
chart_profile_role: "other",
|
|
chart_profile_name: "Client Name",
|
|
},
|
|
existingMessages: [],
|
|
existingBinding: null,
|
|
loadSelfProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
loadOwnedChartProfile: async () => ownedOther(),
|
|
});
|
|
assert.equal(selfLoads, 0);
|
|
assert.deepEqual(created, {
|
|
chart_profile_id: "chart-other",
|
|
chart_profile_name: "Synthetic Other",
|
|
chart_profile_role: "other",
|
|
});
|
|
});
|
|
|
|
test("refresh and deep link keep the stored binding instead of a global active chart", async () => {
|
|
const activeChartId = "self";
|
|
const stored = consultationSubjectBindingFromSession({
|
|
chart_profile_id: "chart-other",
|
|
chart_profile_name: "Synthetic Other",
|
|
chart_profile_role: "other",
|
|
});
|
|
assert.notEqual(activeChartId, stored.chartProfileId);
|
|
let selfLoads = 0;
|
|
const resolved = await resolveConsultationSubject({
|
|
userId: "user-self",
|
|
binding: stored,
|
|
loadSelfProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
loadOwnedChartProfile: async () => ownedOther(),
|
|
});
|
|
assert.equal(selfLoads, 0);
|
|
assert.equal((resolved.profile as { birth_date: string }).birth_date, "1988-03-04");
|
|
|
|
const refreshed = await authoritativeSessionBinding({
|
|
userId: "user-self",
|
|
requested: {
|
|
title: "synthetic title",
|
|
chart_profile_id: stored.chartProfileId,
|
|
chart_profile_name: stored.chartProfileName,
|
|
chart_profile_role: stored.chartProfileRole,
|
|
},
|
|
existingMessages: [{ role: "assistant", text: "synthetic reply" }],
|
|
existingBinding: stored,
|
|
loadSelfProfile: async () => {
|
|
throw new Error("refresh must not read profiles");
|
|
},
|
|
loadOwnedChartProfile: async () => {
|
|
throw new Error("refresh must not reread chart_profiles");
|
|
},
|
|
});
|
|
assert.equal(refreshed?.chart_profile_id, "chart-other");
|
|
assert.equal(refreshed?.chart_profile_role, "other");
|
|
assert.equal(refreshed?.chart_profile_name, "Synthetic Other");
|
|
|
|
const sessions = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
|
const sessionItem = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
|
|
assert.match(sessions, /authoritativeSessionBinding/);
|
|
assert.match(sessionItem, /authoritativeSessionBinding/);
|
|
assert.match(sessionItem, /sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role/);
|
|
assert.doesNotMatch(sessionItem, /activeChartId/);
|
|
assert.doesNotMatch(sessions, /activeChartId/);
|
|
});
|
|
|
|
test("a concurrent delete observed by the send fails closed and does not fall back to self", async () => {
|
|
let visible: OwnedChartProfile | null = ownedOther();
|
|
let selfLoads = 0;
|
|
let reserves = 0;
|
|
let preparations = 0;
|
|
const pending = prepareConsultationRoute({
|
|
userId: "user-self",
|
|
mode: "unverified_birth_time",
|
|
subject: {
|
|
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Synthetic Other" },
|
|
clientBirth,
|
|
loadOwnedChartProfile: async () => {
|
|
await Promise.resolve();
|
|
return visible;
|
|
},
|
|
},
|
|
loadProfile: async () => {
|
|
selfLoads += 1;
|
|
return selfProfile;
|
|
},
|
|
beforeReserve() {
|
|
preparations += 1;
|
|
},
|
|
reserve: async () => {
|
|
reserves += 1;
|
|
return { charged: true };
|
|
},
|
|
});
|
|
visible = null;
|
|
const error = await pending.then(() => null, (caught: unknown) => caught);
|
|
assert.ok(error instanceof ConsultationSubjectError);
|
|
assert.equal(error.code, "subject_not_found");
|
|
assert.equal(selfLoads, 0);
|
|
assert.equal(preparations, 0);
|
|
assert.equal(reserves, 0);
|
|
|
|
const source: { date: string; name: string } = { ...otherProfile };
|
|
const resolved = await resolveConsultationSubject({
|
|
userId: "user-self",
|
|
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null },
|
|
loadSelfProfile: async () => selfProfile,
|
|
loadOwnedChartProfile: async () => ownedOther(source),
|
|
});
|
|
source.date = "2000-01-01";
|
|
source.name = "Changed After Read";
|
|
assert.equal((resolved.profile as { birth_date?: string }).birth_date, "1988-03-04");
|
|
assert.equal(resolved.name, "Synthetic Other");
|
|
});
|