The panel was removed in d08ffdc0 per TASK-round-0925-followup; the /people
assertions already lock its replacement. Gate run 2901 failed on this line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
397 lines
19 KiB
Python
397 lines
19 KiB
Python
import re
|
||
from pathlib import Path
|
||
|
||
|
||
MIGRATION = (
|
||
Path(__file__).resolve().parents[1]
|
||
/ "frontend"
|
||
/ "supabase"
|
||
/ "migrations"
|
||
/ "20260715030000_user_profiles_chat_sessions.sql"
|
||
)
|
||
COORDS_MIGRATION = (
|
||
Path(__file__).resolve().parents[1]
|
||
/ "frontend"
|
||
/ "supabase"
|
||
/ "migrations"
|
||
/ "20260715050000_profile_coordinates.sql"
|
||
)
|
||
CONSULTATION_MIGRATION = (
|
||
Path(__file__).resolve().parents[1]
|
||
/ "frontend"
|
||
/ "supabase"
|
||
/ "migrations"
|
||
/ "20260717000000_consultation_request_lifecycle.sql"
|
||
)
|
||
CHART_PROFILE_MIGRATION = (
|
||
Path(__file__).resolve().parents[1]
|
||
/ "frontend"
|
||
/ "supabase"
|
||
/ "migrations"
|
||
/ "20260718100000_repair_missing_chart_profiles.sql"
|
||
)
|
||
SYNASTRY_REPORT_MIGRATION = (
|
||
Path(__file__).resolve().parents[1]
|
||
/ "frontend"
|
||
/ "supabase"
|
||
/ "migrations"
|
||
/ "20260718101000_repair_missing_synastry_reports.sql"
|
||
)
|
||
PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "(app)" / "page.tsx"
|
||
_FRONTEND_SRC = Path(__file__).resolve().parents[1] / "frontend" / "src"
|
||
HOME_SURFACE_FILES = (
|
||
PAGE,
|
||
_FRONTEND_SRC / "lib" / "home-types.ts",
|
||
_FRONTEND_SRC / "lib" / "home-profile.ts",
|
||
_FRONTEND_SRC / "lib" / "home-cloud-sync.ts",
|
||
_FRONTEND_SRC / "components" / "birth-location-fields.tsx",
|
||
_FRONTEND_SRC / "components" / "profile-fields.tsx",
|
||
_FRONTEND_SRC / "components" / "onboarding-chat-message.tsx",
|
||
_FRONTEND_SRC / "components" / "people" / "people-page.tsx",
|
||
_FRONTEND_SRC / "components" / "starter-home.tsx",
|
||
)
|
||
|
||
|
||
OPTIONAL_HOME_HOOKS = (
|
||
_FRONTEND_SRC / "hooks" / "use-session-management.ts",
|
||
_FRONTEND_SRC / "hooks" / "use-consultation-run.ts",
|
||
_FRONTEND_SRC / "hooks" / "use-profile-onboarding.ts",
|
||
_FRONTEND_SRC / "hooks" / "use-rectification-surface.ts",
|
||
# 状态下沉把合盘从 page.tsx 搬进了自己的 hook(2026-09-16)。首页表面是这
|
||
# 些文件的并集,新 hook 不进这张白名单,下面的合盘断言就会在代码完全正确
|
||
# 的情况下变红。新增 Home 级 hook 时必须同步加进来。
|
||
_FRONTEND_SRC / "hooks" / "use-synastry.ts",
|
||
_FRONTEND_SRC / "hooks" / "use-session-list.ts",
|
||
_FRONTEND_SRC / "hooks" / "use-home-shell-registration.ts",
|
||
_FRONTEND_SRC / "lib" / "session-list-context.tsx",
|
||
_FRONTEND_SRC / "lib" / "session-list-filter.ts",
|
||
)
|
||
|
||
|
||
def _home_surface() -> str:
|
||
parts = [path.read_text(encoding="utf-8") for path in HOME_SURFACE_FILES]
|
||
parts.extend(path.read_text(encoding="utf-8") for path in OPTIONAL_HOME_HOOKS if path.exists())
|
||
return "".join(parts)
|
||
|
||
|
||
SESSION_CREATE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "sessions" / "route.ts"
|
||
SESSION_ITEM_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "sessions" / "[id]" / "route.ts"
|
||
ACCOUNT_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "account" / "route.ts"
|
||
CHART_PROFILE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "route.ts"
|
||
CHART_PROFILE_DELETE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "[id]" / "route.ts"
|
||
SYNASTRY_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "synastry" / "route.ts"
|
||
SYNASTRY_REPORT_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "synastry-reports" / "route.ts"
|
||
|
||
|
||
def _sql() -> str:
|
||
return re.sub(r"\s+", " ", MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||
|
||
|
||
def test_user_profile_and_chat_session_database_contract() -> None:
|
||
sql = _sql()
|
||
|
||
for definition in (
|
||
"name text",
|
||
"birth_date date",
|
||
"birth_time time without time zone",
|
||
"country_code text",
|
||
"province_code text",
|
||
"city_code text",
|
||
"district_code text",
|
||
):
|
||
assert f"add column if not exists {definition}" in sql
|
||
|
||
assert "create policy profiles_update_own" in sql
|
||
assert "for update to authenticated using ((select auth.uid()) = id) with check ((select auth.uid()) = id)" in sql
|
||
assert "grant update ( name, birth_date, birth_time, country_code, province_code, city_code, district_code, updated_at ) on table public.profiles to authenticated" in sql
|
||
|
||
assert "create table if not exists public.chat_sessions" in sql
|
||
for definition in (
|
||
"id uuid primary key default gen_random_uuid()",
|
||
"user_id uuid not null references auth.users(id) on delete cascade",
|
||
"title text not null default '新对话'",
|
||
"theme text not null default 'general'",
|
||
"messages jsonb not null default '[]'::jsonb",
|
||
"created_at timestamptz not null default now()",
|
||
"updated_at timestamptz not null default now()",
|
||
):
|
||
assert definition in sql
|
||
assert "check (theme in ('career', 'marriage', 'timing', 'general'))" in sql
|
||
assert "check (jsonb_typeof(messages) = 'array')" in sql
|
||
assert "alter table public.chat_sessions enable row level security" in sql
|
||
assert "create policy chat_sessions_select_own on public.chat_sessions for select to authenticated using ((select auth.uid()) = user_id)" in sql
|
||
assert "create policy chat_sessions_insert_own on public.chat_sessions for insert to authenticated with check ((select auth.uid()) = user_id)" in sql
|
||
assert "create policy chat_sessions_update_own on public.chat_sessions for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id)" in sql
|
||
assert "revoke all on table public.chat_sessions from anon, authenticated, service_role" in sql
|
||
assert "grant select on table public.chat_sessions to authenticated" in sql
|
||
assert "grant insert (id, user_id, title, theme, messages, updated_at) on table public.chat_sessions to authenticated" in sql
|
||
assert "grant update (title, theme, messages, updated_at) on table public.chat_sessions to authenticated" in sql
|
||
assert "grant delete" not in sql
|
||
|
||
|
||
def test_chat_page_uses_authenticated_cloud_persistence() -> None:
|
||
source = _home_surface()
|
||
create_route = SESSION_CREATE_ROUTE.read_text(encoding="utf-8")
|
||
item_route = SESSION_ITEM_ROUTE.read_text(encoding="utf-8")
|
||
|
||
# Former value: page talked to Supabase with `.from("profiles")` / `.from("chat_sessions")`.
|
||
# Persistence is now same-origin APIs; the lock is still "no browser-owned writes".
|
||
assert 'fetch("/api/account"' in source
|
||
assert 'fetch("/api/sessions"' in source
|
||
assert '.from("profiles")' not in source
|
||
assert '.from("chat_sessions")' not in source
|
||
assert 'await writeChatSession(session.id, values, mode)' in source
|
||
write_contract = (
|
||
Path(__file__).resolve().parents[1]
|
||
/ "frontend"
|
||
/ "src"
|
||
/ "lib"
|
||
/ "chat-session-write-contract.ts"
|
||
).read_text(encoding="utf-8")
|
||
assert 'mode === "create" ? "/api/sessions"' in write_contract
|
||
# 原值: assert 'user_id: user.id' in create_route
|
||
# 新值: 路由传 userId,由 chat-session-write-contract 落成 user_id
|
||
# 原因: 创建路由改走 chatSessionCreateInsertRow(...)(BUG-729~731 那一轮),
|
||
# 字面量搬了家;归属校验本身没变,断言要跟着搬,不是放宽。
|
||
assert 'userId: user.id' in create_route
|
||
assert 'user_id: input.userId' in write_contract
|
||
assert '.eq("user_id", user.id)' in item_route
|
||
assert '.upsert(' not in source
|
||
assert 'await persistSession(userSession)' not in source
|
||
assert source.index('updateSession(sessionId, () => userSession)') < source.index('await persistSession(completedSession)')
|
||
assert 'function completedOnboardingTranscript(profile: Profile, greeting: string): Message[]' in source
|
||
# Former value: `messages: [...preservedMessages, { role: "user", text: question }]`
|
||
# The user turn is still appended unless the question is already present.
|
||
assert 'messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }]' in source
|
||
assert 'await persistSession(completedSession)' in source
|
||
assert 'const stoppedRequestAwaitingSettlement = useRef<string | null>(null)' in source
|
||
assert 'const stoppedSessionPersistence = useRef(new Map<string, Promise<void>>())' in source
|
||
# Former values: persist interruptedSession when `ownsInterface && !partialReply`.
|
||
# Interrupted answers stay in page memory; the client does not replace the server transcript.
|
||
assert "await persistSession(interruptedSession)" not in source
|
||
assert "if (!cancelled && ownsInterface && pendingConsultation.current)" in source
|
||
assert "await persistence" in source
|
||
assert "pendingSessionId || cancellationInFlight.current" in source
|
||
assert "setCancellationPending(true)" in source
|
||
# Former value: "系统正在以账户记录为准同步点数"
|
||
assert "正在停止回答并申请退回本次点数…" in source
|
||
assert "void refreshAccount()" in source
|
||
assert "回答中途断开,已保留现有内容,本次已计费。" not in source
|
||
assert "本次已开始生成并计费" not in source
|
||
# Former value: `localStorage.setItem(chartLibraryStorageKey(accountId)`
|
||
# wrote a local library copy on cloud save failure.
|
||
assert "localStorage.setItem(chartLibraryStorageKey(accountId)" not in source
|
||
assert 'localStorage.setItem("chat_sessions"' not in source
|
||
|
||
|
||
def test_account_profile_patch_rejects_array_payloads() -> None:
|
||
route = ACCOUNT_ROUTE.read_text(encoding="utf-8")
|
||
schema = (
|
||
Path(__file__).resolve().parents[1]
|
||
/ "frontend"
|
||
/ "src"
|
||
/ "lib"
|
||
/ "account-profile-patch.ts"
|
||
).read_text(encoding="utf-8")
|
||
# Former value: `typeof payload !== "object" || Array.isArray(payload)` in the route.
|
||
# Arrays are still rejected: the route parses with a Zod object schema.
|
||
assert "accountProfilePatchSchema.safeParse" in route
|
||
assert "export const accountProfilePatchSchema = z.object({" in schema
|
||
assert "账户资料格式不正确" in route
|
||
|
||
|
||
def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None:
|
||
sql = re.sub(r"\s+", " ", CHART_PROFILE_MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||
route = CHART_PROFILE_ROUTE.read_text(encoding="utf-8")
|
||
delete_route = CHART_PROFILE_DELETE_ROUTE.read_text(encoding="utf-8")
|
||
page = _home_surface()
|
||
|
||
for token in (
|
||
"create table if not exists public.chart_profiles",
|
||
"user_id uuid not null references auth.users(id) on delete cascade",
|
||
"role text not null default 'other' check (role in ('self', 'other'))",
|
||
"profile jsonb not null check (jsonb_typeof(profile) = 'object')",
|
||
"create unique index if not exists chart_profiles_one_self_per_user_idx",
|
||
"alter table public.chart_profiles enable row level security",
|
||
"create policy chart_profiles_select_own",
|
||
"create policy chart_profiles_insert_own",
|
||
"create policy chart_profiles_update_own",
|
||
"create policy chart_profiles_delete_own",
|
||
"grant delete on table public.chart_profiles to authenticated",
|
||
):
|
||
assert token in sql
|
||
|
||
# 原值: 路由按 role=self 写入 jsonb profile。
|
||
# 新值: 只列他人,拒绝本人镜像,写入类型化列。
|
||
# 原因: 星盘档案停写 role=self,本人资料留在 profiles。
|
||
for token in (
|
||
'from("chart_profiles")',
|
||
'eq("user_id", user.id)',
|
||
'eq("role", "other")',
|
||
'body?.role === "self"',
|
||
"self_mirror_retired",
|
||
"parseChartSubjectWrite",
|
||
'role: "other"',
|
||
):
|
||
assert token in route
|
||
assert 'eq("role", "self")' not in route
|
||
assert "Array.isArray(body.profile)" not in route
|
||
assert 'upsert(record, { onConflict: "id" })' not in route
|
||
# 原值: 删除路由用 count=exact 且 role=other,删不到一行就拒绝。
|
||
# 新值: 删除走 delete_chart_subject,函数内仍要求本人的 other 行且恰好删 1 行,并连带删对话和报告。
|
||
# 原因: 删人必须连带清理,所有权检查留在数据库事务里。
|
||
subject_sql = re.sub(
|
||
r"\s+",
|
||
" ",
|
||
(CHART_PROFILE_MIGRATION.parent / "20260925020000_chart_subject_typed_columns.sql").read_text(encoding="utf-8").lower(),
|
||
)
|
||
assert 'rpc("delete_chart_subject"' in delete_route
|
||
assert 'id === "self"' in delete_route
|
||
assert "本人不能删除" in delete_route
|
||
assert "星盘不存在或无权删除" in delete_route
|
||
assert "where id = p_id and user_id = owner and role = 'other'" in subject_sql
|
||
assert "if removed <> 1 then" in subject_sql
|
||
assert "delete from public.chat_sessions" in subject_sql
|
||
assert "delete from public.personal_reports" in subject_sql
|
||
|
||
for token in (
|
||
"fetchCloudChartLibrary",
|
||
"saveCloudChartProfile",
|
||
"deleteCloudChartProfile",
|
||
"buildSynastryQuestion",
|
||
"draftSynastryQuestionFromChart",
|
||
"synastryReportCard",
|
||
"synastryHistory",
|
||
"discardLegacyCloudMirrorKeys",
|
||
# 原值 / 新值 / 原因:退役面板样式 / 人物详情与带用量的删除 / 锁实际 /people 入口。
|
||
"people-archive-detail",
|
||
"pendingDelete",
|
||
'fetch("/api/synastry"',
|
||
"Ashtakoot",
|
||
"jyotisha_chart_library",
|
||
"保存失败,请重试",
|
||
# 原值:首页相关源码必须出现「星盘库」。
|
||
# 新值:不再断言该词。
|
||
# 原因:该词只在已按收尾单删除的 chart-library-panel.tsx 里;人物管理改在 /people,文案为「星盘档案」,由上下两条 /people 断言锁住。
|
||
# 原值 / 新值 / 原因:添加其他人的星盘、用于合盘 / 添加一个人、和我合盘 / 新人物页文案。
|
||
"添加一个人",
|
||
"和我合盘",
|
||
):
|
||
assert token in page
|
||
# 原值: 首页表面必须有「设为默认」。
|
||
# 新值: 这个入口必须消失。
|
||
# 原因: BUG-1030,设为默认会把页面资料换成别人。
|
||
assert "设为默认" not in page
|
||
# Former values: synastryHistoryStorageKey, chartLibraryStorageKey,
|
||
# and the comment "Cloud chart library is best-effort".
|
||
assert "synastryHistoryStorageKey" not in page
|
||
assert "chartLibraryStorageKey" not in page
|
||
assert "Cloud chart library is best-effort" not in page
|
||
assert "ayanam-profile" not in page
|
||
assert "ayanam-sessions" not in page
|
||
|
||
|
||
def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None:
|
||
route = SYNASTRY_ROUTE.read_text(encoding="utf-8")
|
||
for token in (
|
||
'const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"',
|
||
'postPython("/api/chart", selfPayload)',
|
||
'postPython("/api/chart", partnerPayload)',
|
||
'postPython("/api/varga_full"',
|
||
'postPython("/api/synastry"',
|
||
"moonLongitude(selfChart)",
|
||
"moonSummary(selfChart)",
|
||
"d9Summary(selfD9)",
|
||
"relationshipReport",
|
||
"scoreBand",
|
||
"nextEvidence",
|
||
"ashtakoot_plus_moon_nakshatra_d9",
|
||
'evidenceLayers: ["ashtakoot", "moon_nakshatra", "d9_navamsa"]',
|
||
'status: "blocked"',
|
||
"synastryBirthPayload",
|
||
):
|
||
assert token in route
|
||
|
||
|
||
def test_synastry_reports_are_cloud_persisted_per_user() -> None:
|
||
sql = re.sub(r"\s+", " ", SYNASTRY_REPORT_MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||
route = SYNASTRY_REPORT_ROUTE.read_text(encoding="utf-8")
|
||
page = _home_surface()
|
||
|
||
for token in (
|
||
"create table if not exists public.synastry_reports",
|
||
"user_id uuid not null references auth.users(id) on delete cascade",
|
||
"partner_name text not null default '对方'",
|
||
"report jsonb not null check (jsonb_typeof(report) = 'object')",
|
||
"alter table public.synastry_reports enable row level security",
|
||
"create policy synastry_reports_select_own on public.synastry_reports for select to authenticated using ((select auth.uid()) = user_id)",
|
||
"create policy synastry_reports_insert_own on public.synastry_reports for insert to authenticated with check ((select auth.uid()) = user_id)",
|
||
"create policy synastry_reports_delete_own on public.synastry_reports for delete to authenticated using ((select auth.uid()) = user_id)",
|
||
"grant select on table public.synastry_reports to authenticated",
|
||
"grant insert (id, user_id, partner_name, report, created_at) on table public.synastry_reports to authenticated",
|
||
):
|
||
assert token in sql
|
||
|
||
for token in (
|
||
'.from("synastry_reports")',
|
||
'.select("id, partner_name, report, created_at")',
|
||
".eq(\"user_id\", user.id)",
|
||
".limit(10)",
|
||
"partner_name: partnerName",
|
||
"report: body.report",
|
||
):
|
||
assert token in route
|
||
|
||
for token in (
|
||
"fetchCloudSynastryHistory",
|
||
"saveCloudSynastryReport",
|
||
'fetch("/api/synastry-reports"',
|
||
"未能存入历史",
|
||
"cloud_synastry_history_unavailable",
|
||
"cloud_synastry_report_save_failed",
|
||
):
|
||
assert token in page
|
||
# Former value: writeSynastryHistory(accountId, next) wrote a local
|
||
# history copy when cloud save failed.
|
||
assert "writeSynastryHistory(accountId, next)" not in page
|
||
|
||
|
||
def test_consultation_credit_lifecycle_is_idempotent_and_server_only() -> None:
|
||
sql = re.sub(r"\s+", " ", CONSULTATION_MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||
|
||
assert "create table if not exists public.consultation_requests" in sql
|
||
assert "primary key (user_id, request_id)" in sql
|
||
assert "status in ('reserved', 'completed', 'cancelled')" in sql
|
||
for function_name in (
|
||
"begin_consultation_credit",
|
||
"complete_consultation_credit",
|
||
"cancel_consultation_credit",
|
||
):
|
||
assert f"create or replace function public.{function_name}" in sql
|
||
assert f"grant execute on function public.{function_name}(uuid, text) to service_role" in sql
|
||
assert f"revoke all on function public.{function_name}(uuid, text) from public, anon, authenticated" in sql
|
||
assert "pg_advisory_xact_lock" in sql
|
||
assert "if v_status = 'completed'" in sql
|
||
assert "'request_completed'::text" in sql
|
||
assert "if v_status = 'cancelled'" in sql
|
||
|
||
|
||
def test_profile_coordinates_are_persisted_with_database_bounds() -> None:
|
||
sql = re.sub(r"\s+", " ", COORDS_MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||
source = _home_surface()
|
||
|
||
for definition in (
|
||
"latitude double precision",
|
||
"longitude double precision",
|
||
"timezone_offset double precision",
|
||
):
|
||
assert f"add column if not exists {definition}" in sql
|
||
|
||
assert "latitude between -90 and 90" in sql
|
||
assert "longitude between -180 and 180" in sql
|
||
assert "timezone_offset between -12 and 14" in sql
|
||
assert "grant update (latitude, longitude, timezone_offset) on table public.profiles to authenticated" in sql
|
||
assert "latitude: birthPlace?.lat ?? null" in source
|
||
assert "longitude: birthPlace?.lon ?? null" in source
|
||
assert "timezone_offset: birthPlace?.tz ?? null" in source
|