Files
Jyotisha/tests/test_supabase_user_data_contract.py
2026-07-22 02:35:31 +08:00

299 lines
14 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" / "page.tsx"
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 = PAGE.read_text(encoding="utf-8")
create_route = SESSION_CREATE_ROUTE.read_text(encoding="utf-8")
item_route = SESSION_ITEM_ROUTE.read_text(encoding="utf-8")
assert '.from("profiles")' in source
assert '.from("chat_sessions")' in source
assert 'await writeChatSession(session.id, values, mode)' in source
assert 'mode === "create" ? "/api/sessions"' in (
Path(__file__).resolve().parents[1]
/ "frontend"
/ "src"
/ "lib"
/ "chat-session-write-contract.ts"
).read_text(encoding="utf-8")
assert 'user_id: user.id' in create_route
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
assert 'messages: [...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
assert 'if (ownsInterface && !partialReply)' in source
assert 'await persistSession(interruptedSession)' in source
assert 'await persistence' in source
assert "pendingSessionId || cancellationInFlight.current" in source
assert "setCancellationPending(true)" in source
assert "系统正在以账户记录为准同步点数" in source
assert "回答中途断开,已保留现有内容,本次已计费。" not in source
assert "本次已开始生成并计费" not in source
assert 'localStorage.setItem(chartLibraryStorageKey(accountId)' 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")
assert 'typeof payload !== "object" || Array.isArray(payload)' in route
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 = PAGE.read_text(encoding="utf-8")
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
for token in (
'from("chart_profiles")',
'eq("user_id", user.id)',
'eq("role", "self")',
"Array.isArray(body.profile)",
'insert({ user_id: user.id, role, profile: body.profile',
'insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt })',
):
assert token in route
assert 'upsert(record, { onConflict: "id" })' not in route
assert '.delete({ count: "exact" })' in delete_route
assert 'eq("role", "other")' in delete_route
assert "count !== 1" in delete_route
assert "星盘不存在或无权删除" in delete_route
for token in (
"fetchCloudChartLibrary",
"saveCloudChartProfile",
"deleteCloudChartProfile",
"buildSynastryQuestion",
"draftSynastryQuestionFromChart",
"synastryReportCard",
"synastryHistory",
"synastryHistoryStorageKey",
"synastry-report-card",
"synastry-history-list",
'fetch("/api/synastry"',
"Ashtakoot",
"chartLibraryStorageKey",
"Cloud chart library is best-effort",
"星盘库",
"添加其他星盘",
"用于合盘",
"设为默认",
):
assert token 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", birthPayload(body.selfProfile))',
'postPython("/api/chart", birthPayload(body.partnerProfile))',
'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"',
):
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 = PAGE.read_text(encoding="utf-8")
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"',
"writeSynastryHistory(accountId, next)",
"cloud_synastry_history_unavailable",
"cloud_synastry_report_save_failed",
):
assert token 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 = PAGE.read_text(encoding="utf-8")
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