import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { accountProfilePatchSchema } from "../src/lib/account-profile-patch.ts"; import { libraryProfileFromSubjectRow, parseChartSubjectWrite } from "../src/lib/chart-subject-write.ts"; import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts"; import { chartProfileToConsultationRow, type OwnedChartProfile } from "../src/lib/consultation-subject-resolver.ts"; import { readProfile } from "../src/lib/home-profile.ts"; import { PROFILE_GENDER_HINT, parseProfileGender, profileGenderLabel, } from "../src/lib/profile-gender.ts"; import { CHART_SUBJECT_API_SELECT, CHART_SUBJECT_SELECT, resolveSubjectBirth } from "../src/lib/subject-birth.ts"; // TASK-consult-gender-optional-20260927 T1 (data layer). Fictional people only. const selfRow = Object.freeze({ name: "虚构户主", birth_date: "1990-01-02", reported_birth_time: "08:15:00", active_birth_time: null, birth_time_source: "family_exact", birth_time_status: "reported", birth_place_label: "虚构城", latitude: 31.2, longitude: 121.5, timezone_offset: 8, timezone_id: "Asia/Shanghai", ayanamsa: "raman", gender: "female", }); function typedOther(extra: Record = {}) { return { name: "虚构乙", birth_date: "1988-03-04", reported_birth_time: "09:20:00", birth_time_source: "family_exact", birth_time_status: "reported", birth_place_label: "虚构港", latitude: 22.3, longitude: 114.2, timezone_offset: 8, timezone_id: "Asia/Shanghai", ayanamsa: "lahiri", ...extra, }; } function owned(profile: Record): OwnedChartProfile { return { id: "00000000-0000-4000-8000-000000000002", userId: "user-1", role: "other", profile }; } async function prepare(binding: { chartProfileId: string | null; chartProfileRole: "self" | "other" | null }, other: Record) { return prepareConsultationRoute({ userId: "user-1", mode: "unverified_birth_time", subject: { binding: { ...binding, chartProfileName: null }, loadOwnedChartProfile: async () => owned(other), }, loadProfile: async () => selfRow, resolveTimezoneOffset: async (value) => value, reserve: async () => "reserved", }); } test("gender values: female / male / null only, labelled 女 / 男 / 未填", () => { assert.equal(parseProfileGender("female"), "female"); assert.equal(parseProfileGender("male"), "male"); for (const value of [null, undefined, "", "女", "other", 1]) assert.equal(parseProfileGender(value), null); assert.equal(profileGenderLabel("female"), "女"); assert.equal(profileGenderLabel("male"), "男"); assert.equal(profileGenderLabel(null), "未填"); assert.equal(PROFILE_GENDER_HINT, "用于婚恋解读里判断夫星 / 妻星,不填也能用。"); }); test("account PATCH accepts gender alone, clears it with null, and rejects anything else", () => { assert.equal(accountProfilePatchSchema.safeParse({ gender: "female" }).success, true); assert.equal(accountProfilePatchSchema.safeParse({ gender: "male" }).success, true); assert.equal(accountProfilePatchSchema.safeParse({ gender: null }).success, true); assert.equal(accountProfilePatchSchema.safeParse({ gender: "other" }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ gender: "女" }).success, false); const route = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); assert.match(route, /payload\.gender !== undefined \? \{ gender: payload\.gender \}/); assert.match(route, /rectification_case_id,gender"\)/, "GET returns the stored gender"); }); test("people writes carry gender only when sent; reads expose it only when stored", () => { const base = { name: "虚构乙", date: "1988-03-04", reportedTime: "09:20", birthTimeSource: "family_exact" }; const absent = parseChartSubjectWrite(base); assert.ok(absent.ok); assert.equal("gender" in absent.write.columns, false, "a client that does not send gender never clears it"); const female = parseChartSubjectWrite({ ...base, gender: "female" }); assert.ok(female.ok); assert.equal(female.write.columns.gender, "female"); assert.equal("gender" in female.write.profile, false, "gender stays out of the rollback jsonb mirror"); for (const cleared of [null, ""]) { const result = parseChartSubjectWrite({ ...base, gender: cleared }); assert.ok(result.ok); assert.equal(result.write.columns.gender, null); } const invalid = parseChartSubjectWrite({ ...base, gender: "other" }); assert.equal(invalid.ok, false); assert.equal(libraryProfileFromSubjectRow({ gender: "male" }).gender, "male"); assert.equal("gender" in libraryProfileFromSubjectRow({ gender: null }), false); assert.equal(readProfile({ gender: "female" }).gender, "female"); assert.equal("gender" in readProfile({ gender: "x" }), false); assert.match(CHART_SUBJECT_SELECT, /,gender(,|$)/); assert.match(CHART_SUBJECT_API_SELECT, /,gender,/); }); test("subject resolution carries each person's own gender and never the owner's", async () => { assert.equal(chartProfileToConsultationRow({ ...typedOther(), gender: "male" })?.gender, "male"); assert.equal(chartProfileToConsultationRow(typedOther())?.gender, null); const other = await resolveSubjectBirth({ userId: "user-1", subjectId: "00000000-0000-4000-8000-000000000002", loadSelfRow: async () => selfRow, loadOtherRow: async () => ({ id: "00000000-0000-4000-8000-000000000002", user_id: "user-1", role: "other", ...typedOther(), }), }); assert.equal(other.row.gender, null, "an empty other person does not inherit the owner's gender"); const self = await prepare({ chartProfileId: "self", chartProfileRole: "self" }, typedOther({ gender: "male" })); assert.equal(self.serverChart?.gender, "female"); const male = await prepare( { chartProfileId: "00000000-0000-4000-8000-000000000002", chartProfileRole: "other" }, typedOther({ gender: "male" }), ); assert.equal(male.serverChart?.gender, "male"); const unset = await prepare( { chartProfileId: "00000000-0000-4000-8000-000000000002", chartProfileRole: "other" }, typedOther(), ); assert.equal(unset.serverChart?.gender, null, "the owner is female; the other person stays not filled"); assert.equal("gender" in (unset.serverChart?.toolInput ?? {}), false, "gender is never sent to the engine"); }); test("the migration is additive and backward compatible with the deployed code", () => { const sql = readFileSync(new URL("../supabase/migrations/20260927010000_profile_gender.sql", import.meta.url), "utf8") .replace(/--.*$/gm, ""); assert.match(sql, /alter table public\.profiles\s+add column if not exists gender text;/); assert.match(sql, /alter table public\.chart_profiles\s+add column if not exists gender text;/); assert.match(sql, /check \(gender is null or gender in \('female', 'male'\)\)/); assert.match(sql, /grant update \(gender\) on table public\.profiles to service_role;/); assert.match(sql, /grant update \(gender\) on table public\.chart_profiles to authenticated;/); assert.doesNotMatch(sql, /not null|default|drop column|alter column|rename|update public\./i); assert.doesNotMatch(sql, /admin_runtime\s*;/); });