Additive migration: nullable profiles.gender / chart_profiles.gender with a female/male CHECK, owner-only via existing table RLS, column grants mirroring the neighbouring birth columns (service_role for the account PATCH, no admin_runtime). Account PATCH/GET, people POST/PUT/GET, subject resolution and the consult route carry each person's own value; a people write only touches gender when the key is sent. DB test runs in the gate's DB job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
159 lines
7.2 KiB
TypeScript
159 lines
7.2 KiB
TypeScript
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<string, unknown> = {}) {
|
|
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<string, unknown>): 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<string, unknown>) {
|
|
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*;/);
|
|
});
|