- seed rath_highest_occupant_dignity_v1 (named convention; equal tier below own sign blocks the seed) - AD direction profiles rath_p50_md_parity_v1 / rath_stronger_lord_start_parity_v1 (default, pending choice) - Saturn+Ketu in seed / MD sign blocked; PyJHora Ketu priority only as named reference profile - 0-year MD rows flagged, never current, skipped on the annual timeline; years profile rath_table8_phalita_dignity_v1 - KP DBA year_days 365.25 default, parent/AD/full/visible fields, boundaries equal main Vimshottari - functional profile pvr_table30_v1 (aliases), Moon-only blocked without paksha; profile through API, cache keys, audit; rectification asserts one profile per run - golden recaptured with TTL=0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
323 lines
13 KiB
Python
323 lines
13 KiB
Python
"""Retention, chart-cache keys, and VedAstro audit switch."""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import threading
|
||
import time
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
import api_scratch_retention as retention
|
||
import jyotish_api_server as api
|
||
import vedastro_service_adapter as adapter
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
|
||
def _hex(label: str) -> str:
|
||
return hashlib.sha256(label.encode("utf-8")).hexdigest() + ".json"
|
||
|
||
|
||
def _isolate(monkeypatch, tmp_path: Path) -> None:
|
||
chart = tmp_path / "chart"
|
||
jobs = tmp_path / "jobs"
|
||
chart.mkdir()
|
||
jobs.mkdir()
|
||
monkeypatch.setattr(api, "_api_chart_cache_dir", lambda: chart)
|
||
monkeypatch.setattr(api, "_async_job_dir", lambda scope: jobs)
|
||
monkeypatch.setattr(api, "_async_job_backend", lambda: "file")
|
||
monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path / "adapter")
|
||
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", str(tmp_path / "snap"))
|
||
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR", str(tmp_path / "neg"))
|
||
monkeypatch.setenv("VEDASTRO_GATEWAY_QUEUE_DIR", str(tmp_path / "gw"))
|
||
monkeypatch.setenv("JYOTISH_CACHE_DIR", str(tmp_path / "cache"))
|
||
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "900")
|
||
monkeypatch.delenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", raising=False)
|
||
|
||
|
||
def _write(path: Path, payload) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||
|
||
|
||
def test_prune_all_drops_expired_and_old_bad_chart_cache_only(monkeypatch, tmp_path):
|
||
_isolate(monkeypatch, tmp_path)
|
||
chart = api._api_chart_cache_dir()
|
||
now = time.time()
|
||
fresh = chart / _hex("fresh")
|
||
expired = chart / _hex("expired")
|
||
bad_old = chart / _hex("bad-old")
|
||
bad_young = chart / _hex("bad-young")
|
||
stranger = chart / "notes.json"
|
||
_write(fresh, {"cache_key": "a", "created_at": now, "payload": {}})
|
||
_write(expired, {"cache_key": "b", "created_at": now - 5000, "payload": {}})
|
||
bad_old.write_text("{", encoding="utf-8")
|
||
bad_young.write_text("{", encoding="utf-8")
|
||
stranger.write_text("{}", encoding="utf-8")
|
||
old = now - (25 * 3600)
|
||
os.utime(bad_old, (old, old))
|
||
os.utime(stranger, (old, old))
|
||
|
||
result = retention.prune_all(now=now)
|
||
|
||
assert fresh.exists()
|
||
assert bad_young.exists()
|
||
assert stranger.exists()
|
||
assert not expired.exists()
|
||
assert not bad_old.exists()
|
||
assert result["directories"]["api_chart_cache"]["removed"] == 2
|
||
assert result["errors"] == 0
|
||
|
||
|
||
def test_job_cleanup_still_removes_only_expired_records(monkeypatch, tmp_path):
|
||
_isolate(monkeypatch, tmp_path)
|
||
jobs = api._async_job_dir("any")
|
||
expired = jobs / "expired.json"
|
||
active = jobs / "active.json"
|
||
bad = jobs / "bad.json"
|
||
expired.write_text(json.dumps({"expires_at_unix": time.time() - 1}), encoding="utf-8")
|
||
active.write_text(json.dumps({"expires_at_unix": time.time() + 60}), encoding="utf-8")
|
||
bad.write_text("{", encoding="utf-8")
|
||
|
||
result = api.prune_expired_async_jobs()
|
||
|
||
assert result["removed"] == 1
|
||
assert not expired.exists()
|
||
assert active.exists()
|
||
assert bad.exists()
|
||
|
||
|
||
def test_interval_zero_does_not_start_a_thread():
|
||
before = [item.name for item in threading.enumerate()]
|
||
assert retention.start_periodic_prune(0) is None
|
||
assert [item.name for item in threading.enumerate()] == before
|
||
|
||
|
||
def test_prune_counts_unlink_errors_without_raising(monkeypatch, tmp_path):
|
||
_isolate(monkeypatch, tmp_path)
|
||
chart = api._api_chart_cache_dir()
|
||
target = chart / _hex("stuck")
|
||
_write(target, {"created_at": time.time() - 5000, "payload": {}})
|
||
real_unlink = Path.unlink
|
||
|
||
def _boom(self, *args, **kwargs):
|
||
if self == target:
|
||
raise OSError("denied")
|
||
return real_unlink(self, *args, **kwargs)
|
||
|
||
monkeypatch.setattr(Path, "unlink", _boom)
|
||
result = retention.prune_all(now=time.time())
|
||
assert result["errors"] > 0
|
||
assert target.exists()
|
||
|
||
|
||
def _chart_key(body):
|
||
return api._api_chart_cache_key(api._build_api_chart_cache_payload(body))
|
||
|
||
|
||
def test_numeric_birth_spellings_share_one_chart_cache_key():
|
||
base = {
|
||
"year": 1990,
|
||
"month": 1,
|
||
"day": 1,
|
||
"minute": 0,
|
||
"lat": 28.6139,
|
||
"lon": 77.2090,
|
||
"tz": 5.5,
|
||
"today": "2026-10-14",
|
||
}
|
||
keys = {_chart_key(dict(base, hour=hour)) for hour in (12, 12.0, "12")}
|
||
assert len(keys) == 1
|
||
assert _chart_key(dict(base, hour=12, second=0)) in keys
|
||
assert _chart_key(dict(base, hour=12, lat="28.6139")) in keys
|
||
assert _chart_key(dict(base, hour=12, tz="5.5")) in keys
|
||
assert _chart_key(dict(base, hour=12, tz="Asia/Kolkata")) not in keys
|
||
skipped = _chart_key(dict(base, hour=12, skip_vedastro_main_entry_overview=True))
|
||
assert skipped not in keys
|
||
# 原值:cache_schema_version == 6
|
||
# 新值:7
|
||
# 原因:/api/chart 的上升补上星宿三项,旧缓存里没有这三项,必须换键。
|
||
# 原值(2):7;新值(2):8,键里加 analysis_profiles(功能吉凶 profile + Narayana 各步 profile)。
|
||
# 原因(2):占星师 2026-11-08 第五轮逐项答复 K3:缓存按 profile 键控、随 profile 变化失效。
|
||
assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 8
|
||
assert _chart_key(dict(base, hour=12, functional_profile="pvr_table_30")) in keys # alias = same profile
|
||
other = _chart_key(dict(base, hour=12, functional_profile="bphs_exception_profile", analysis_mode="research"))
|
||
assert other not in keys
|
||
|
||
|
||
def test_audit_artifact_default_off_and_compact_when_on(monkeypatch, tmp_path):
|
||
monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path)
|
||
monkeypatch.delenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", raising=False)
|
||
manifest = {"requests": [{"section": "chart_core"}], "reference_date": "2026-10-14"}
|
||
quiet = adapter._normalize_official_full_snapshot_success(
|
||
"https://example.test/api",
|
||
manifest,
|
||
{"chart_core": {"Status": "Pass"}},
|
||
{"chart_core": "ok"},
|
||
1,
|
||
[],
|
||
)
|
||
assert quiet["source_metadata"]["artifact_path"] is None
|
||
assert list(tmp_path.rglob("*.json")) == []
|
||
|
||
monkeypatch.setenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", "1")
|
||
written = adapter._normalize_official_full_snapshot_success(
|
||
"https://example.test/api",
|
||
manifest,
|
||
{"chart_core": {"Status": "Pass"}},
|
||
{"chart_core": "ok"},
|
||
1,
|
||
[],
|
||
)
|
||
rel = written["source_metadata"]["artifact_path"]
|
||
path = Path(rel)
|
||
if not path.is_absolute():
|
||
path = adapter.ROOT / rel
|
||
assert path.exists()
|
||
# Linux reports the mode bit. Windows keeps the file writable and does not surface 0600.
|
||
if os.name != "nt":
|
||
assert path.stat().st_mode & 0o777 == 0o600
|
||
text = path.read_text(encoding="utf-8")
|
||
assert "\n" not in text
|
||
assert json.loads(text)["status"] == "ok"
|
||
|
||
|
||
def test_failed_snapshot_expires_before_success(monkeypatch, tmp_path):
|
||
monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path)
|
||
monkeypatch.setenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", "604800")
|
||
monkeypatch.delenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_NEGATIVE_TTL_SECONDS", raising=False)
|
||
calls = {"fail": 0, "ok": 0}
|
||
|
||
def fake_run(case, case_id="user_chart"):
|
||
if case.get("day") == 2:
|
||
calls["fail"] += 1
|
||
return {"available": False, "status": "timeout", "source_metadata": {}}
|
||
calls["ok"] += 1
|
||
return {"available": True, "status": "ok", "source_metadata": {}}
|
||
|
||
monkeypatch.setattr(adapter, "_run_official_full_snapshot_case", fake_run)
|
||
fail_case = {"year": 1990, "month": 1, "day": 2, "hour": 12, "lat": 1.0, "lon": 2.0, "tz": 5.5}
|
||
ok_case = {"year": 1990, "month": 1, "day": 3, "hour": 12, "lat": 1.0, "lon": 2.0, "tz": 5.5}
|
||
adapter.run_official_full_snapshot_for_case(fail_case, case_id="fail")
|
||
adapter.run_official_full_snapshot_for_case(ok_case, case_id="ok")
|
||
cache_dir = tmp_path / "official_full_snapshot_cache"
|
||
aged = time.time() - 301
|
||
for path in cache_dir.glob("*.json"):
|
||
record = json.loads(path.read_text(encoding="utf-8"))
|
||
record["created_at"] = aged
|
||
path.write_text(json.dumps(record), encoding="utf-8")
|
||
if record.get("negative") is True:
|
||
assert record["negative"] is True
|
||
adapter.run_official_full_snapshot_for_case(fail_case, case_id="fail")
|
||
adapter.run_official_full_snapshot_for_case(ok_case, case_id="ok")
|
||
assert calls["fail"] == 2
|
||
assert calls["ok"] == 1
|
||
|
||
|
||
def test_gateway_status_reports_cache_ttl_fallback(monkeypatch):
|
||
from scripts import vedastro_gateway
|
||
|
||
monkeypatch.setenv("JYOTISH_SKIP_LOCAL_ENV", "1")
|
||
monkeypatch.delenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", raising=False)
|
||
monkeypatch.setenv("VEDASTRO_CACHE_TTL_SECONDS", "604800")
|
||
status = vedastro_gateway.gateway_status()
|
||
assert status["official_full_snapshot_cache_ttl_seconds"] == 604800
|
||
assert status["official_full_snapshot_negative_cache_ttl_seconds"] == 300
|
||
|
||
|
||
def _day(offset: int) -> str:
|
||
return (datetime.now(timezone.utc).date() + timedelta(days=offset)).isoformat()
|
||
|
||
|
||
def test_snapshot_negative_gateway_and_varga_prune_rules(monkeypatch, tmp_path):
|
||
_isolate(monkeypatch, tmp_path)
|
||
now = time.time()
|
||
today = datetime.fromtimestamp(now, timezone.utc).date()
|
||
snap = tmp_path / "snap"
|
||
neg = tmp_path / "neg"
|
||
gw = tmp_path / "gw"
|
||
varga = tmp_path / "cache" / "varga_segments"
|
||
keep_ref = (today - timedelta(days=7)).isoformat()
|
||
drop_ref = (today - timedelta(days=9)).isoformat()
|
||
_write(snap / _hex("snap-keep"), {"schema": "vedastro_snapshot_cache.v1", "reference_date": keep_ref})
|
||
_write(snap / _hex("snap-drop"), {"schema": "vedastro_snapshot_cache.v1", "reference_date": drop_ref})
|
||
_write(snap / "notes.json", {"reference_date": "1990-01-01"})
|
||
_write(neg / _hex("neg-today"), {"schema": "vedastro_snapshot_negative_cache.v1", "reference_date": today.isoformat()})
|
||
_write(neg / _hex("neg-yesterday"), {"schema": "vedastro_snapshot_negative_cache.v1", "reference_date": (today - timedelta(days=1)).isoformat()})
|
||
_write(neg / "stray.json", {"reference_date": "1990-01-01"})
|
||
job_id = "vgw_" + "ab" * 10
|
||
old_stamp = datetime.fromtimestamp(now - 90000, timezone.utc).isoformat()
|
||
fresh_stamp = datetime.fromtimestamp(now, timezone.utc).isoformat()
|
||
_write(gw / f"{job_id}.json", {"job_id": job_id, "status": "completed", "updated_at": old_stamp})
|
||
(gw / f"{job_id}.official_raw_response.json").write_text("{}", encoding="utf-8")
|
||
running = "vgw_" + "cd" * 10
|
||
_write(gw / f"{running}.json", {"job_id": running, "status": "running", "updated_at": old_stamp})
|
||
failed = "vgw_" + "ef" * 10
|
||
_write(gw / f"{failed}.json", {"job_id": failed, "status": "failed", "updated_at": fresh_stamp})
|
||
(gw / "notes.json").write_text("{}", encoding="utf-8")
|
||
old_varga = varga / _hex("old-segment")
|
||
young_varga = varga / _hex("young-segment")
|
||
_write(old_varga, {"window_key": "x"})
|
||
_write(young_varga, {"window_key": "y"})
|
||
_write(varga / "notes.json", {"window_key": "z"})
|
||
os.utime(old_varga, (now - 8 * 86400, now - 8 * 86400))
|
||
|
||
monkeypatch.setenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", "0")
|
||
artifact = tmp_path / "adapter" / "official_full_snapshot-0123456789ab-0123456789ab.json"
|
||
artifact.parent.mkdir(parents=True, exist_ok=True)
|
||
artifact.write_text("{}", encoding="utf-8")
|
||
os.utime(artifact, (now - 25 * 3600, now - 25 * 3600))
|
||
kept_artifact = tmp_path / "adapter" / "method_catalog_snapshot.json"
|
||
kept_artifact.write_text("{}", encoding="utf-8")
|
||
os.utime(kept_artifact, (now - 25 * 3600, now - 25 * 3600))
|
||
|
||
retention.prune_all(now=now)
|
||
|
||
assert (snap / _hex("snap-keep")).exists()
|
||
assert not (snap / _hex("snap-drop")).exists()
|
||
assert (snap / "notes.json").exists()
|
||
assert (neg / _hex("neg-today")).exists()
|
||
assert not (neg / _hex("neg-yesterday")).exists()
|
||
assert (neg / "stray.json").exists()
|
||
assert not (gw / f"{job_id}.json").exists()
|
||
assert not (gw / f"{job_id}.official_raw_response.json").exists()
|
||
assert (gw / f"{running}.json").exists()
|
||
assert (gw / f"{failed}.json").exists()
|
||
assert (gw / "notes.json").exists()
|
||
assert not old_varga.exists()
|
||
assert young_varga.exists()
|
||
assert (varga / "notes.json").exists()
|
||
assert not artifact.exists()
|
||
assert kept_artifact.exists()
|
||
|
||
|
||
def _scratch_local_names() -> set[str]:
|
||
names: set[str] = set()
|
||
slash = re.compile(r"scratch/local/([A-Za-z0-9_.]+)")
|
||
joined = re.compile(
|
||
r"""['\"]scratch['\"]\s*/\s*['\"]local['\"]\s*/\s*(?:f)?['\"]([A-Za-z0-9_.]+)['\"]"""
|
||
)
|
||
after_local = re.compile(
|
||
r"""['\"]local['\"]\s*\)*\s*/\s*['\"]([A-Za-z0-9_.]+)['\"]"""
|
||
)
|
||
for path in (ROOT / "scripts").rglob("*.py"):
|
||
text = path.read_text(encoding="utf-8")
|
||
names.update(slash.findall(text))
|
||
names.update(joined.findall(text))
|
||
names.update(after_local.findall(text))
|
||
return names
|
||
|
||
|
||
def test_new_scratch_local_dirs_are_registered_or_whitelisted():
|
||
allowed = set(retention.REGISTERED_DIR_NAMES) | set(retention.OFFLINE_SCRATCH_NAMES)
|
||
found = _scratch_local_names()
|
||
unexpected = sorted(found - allowed)
|
||
assert unexpected == [], unexpected
|
||
assert "api_chart_cache" in found
|
||
assert "consult_full_data_cache" in retention.OFFLINE_SCRATCH_NAMES
|