Files
Jyotisha/tests/test_supabase_user_data_contract.py
T
Jesse_ChenandClaude Opus 5.5 e84d6eb80b
Independent Staging Quality Gate / validate (push) Successful in 12m3s
Independent Staging Quality Gate / publish (push) Successful in 3m25s
test(contract): drop retired chart-library-panel wording from home surface contract
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
2026-09-26 05:25:22 +08:00

397 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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