Files
set50-system/backend/tests/test_research.py
Kunthawat Greethong ead9aeb25c chore: pre-existing in-tree work (event-study/research/vintages/prices + migration script + integrity docs)
Committing the prior uncommitted working-tree state that predates this session's
data-source work (was already modified/untracked at session start) so the tree
is clean before push. Includes: event-study + research report integrity/forward
observation work, prices tests, research hash migration script, and the
2026-08-23/24 engineering-log + test-evidence notes. Verified green as part of
the full 362-test suite.
2026-08-29 09:19:24 +07:00

623 lines
29 KiB
Python

import copy
import hashlib
import json
import tempfile
import unittest
from pathlib import Path
from typing import Any, cast
from app.prices import PriceSnapshotStore, PriceSourceError
from app.research import ResearchRunError, ResearchRunStore, _manifest_entry_hash, _report_hash, _validate_config, run_tourism_research
from app.vintages import VintageStore
def tourism_snapshot(vintage_id: str, published_at: str) -> dict:
return {
"as_of": "2026-01-01",
"data_quality": "provisional",
"theme": "tourism",
"strategy_version": "tourism-v0.2-bot",
"observations": [
{"metric_key": "foreign_arrivals_yoy", "value": 1.0, "expected": 0.0, "scale": 1.0, "period": "2025-12", "history_points": 12, "unit": "percent", "provisional": True}
],
"exposures": [
{"symbol": "AOT", "coefficient": 1.0, "confidence": 1.0, "evidence": "airport"},
{"symbol": "PTT", "coefficient": -0.5, "confidence": 1.0, "evidence": "control"},
],
"source": {
"source_id": "test.bot",
"source_url": "https://example.test/bot",
"vintage_id": vintage_id,
"published_at": published_at,
"retrieved_at": "2026-02-02T00:00:00+00:00",
"release_status": "provisional",
"parser_version": "test-v1",
},
}
def price_snapshot(point_in_time: bool) -> tuple[dict, dict[str, bytes]]:
def bar(session_date: str, close: float) -> dict:
if not point_in_time:
return {"date": session_date, "close": close}
return {
"date": session_date,
"session_date": session_date,
"open": close,
"high": close + 1,
"low": close - 1,
"close": close,
"adjusted_close": close,
"volume": 1000,
"known_at": f"{session_date}T17:00:00+07:00",
}
pit_source = {
"source_url": "https://example.test/prices",
"parser_version": "pit-test-v1",
"archive_contract": "pit-daily-v1",
"provider_release_id": "release-2026-02-01",
"point_in_time_evidence": {
"known_at_field": "known_at",
"known_at_semantics": "provider_release_time",
"provider_release_id": "release-2026-02-01",
"release_published_at": "2026-02-01T17:00:00+07:00",
},
} if point_in_time else {}
return (
{
"schema_version": 1,
"source": {
"source_id": "test.prices",
"snapshot_id": "prices-test",
"retrieved_at": "2026-02-02T00:00:00+00:00",
"period_start": "2026-01-01",
"period_end": "2026-01-06",
"quality": "point_in_time_archive" if point_in_time else "revised_vendor_history",
"point_in_time": point_in_time,
"adjusted_prices": False,
**pit_source,
},
"series": {
"AOT": {"canonical_symbol": "AOT", "provider_symbol": "AOT.BK", "raw_payload_hash": hashlib.sha256(b"aot").hexdigest(), "timezone": "Asia/Bangkok", "bars": [bar("2026-01-01", 100.0), bar("2026-01-02", 102.0), bar("2026-01-05", 104.0), bar("2026-01-06", 106.0)]},
"PTT": {"canonical_symbol": "PTT", "provider_symbol": "PTT.BK", "raw_payload_hash": hashlib.sha256(b"ptt").hexdigest(), "timezone": "Asia/Bangkok", "bars": [bar("2026-01-01", 100.0), bar("2026-01-02", 99.0), bar("2026-01-05", 98.0), bar("2026-01-06", 97.0)]},
"SET50": {"canonical_symbol": "SET50", "provider_symbol": "^SET.BK", "raw_payload_hash": hashlib.sha256(b"set").hexdigest(), "timezone": "Asia/Bangkok", "bars": [bar("2026-01-01", 100.0), bar("2026-01-02", 101.0), bar("2026-01-05", 102.0), bar("2026-01-06", 103.0)]},
},
"benchmark_symbol": "SET50",
},
{"AOT.BK": b"aot", "PTT.BK": b"ptt", "^SET.BK": b"set"},
)
class ResearchRunTests(unittest.TestCase):
def test_research_config_rejects_malformed_values_with_domain_error(self):
cases = (
((None,), 20.0, 1, 1),
((float("nan"),), 20.0, 1, 1),
((1,), float("inf"), 1, 1),
((1,), 20.0, None, 1),
((1,), 20.0, 1, float("inf")),
)
for windows, cost_bps, min_events, execution_lag in cases:
with self.subTest(windows=windows, cost_bps=cost_bps, min_events=min_events, execution_lag=execution_lag):
with self.assertRaises(ResearchRunError):
_validate_config(
cast(Any, windows),
cast(Any, cost_bps),
cast(Any, min_events),
cast(Any, execution_lag),
)
def test_research_run_store_binds_report_hash_and_rejects_tampering(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
stored = store.persist(report)
self.assertRegex(stored["report_hash"], r"^[a-f0-9]{64}$")
report_path = root / "reports" / "tourism-run-test.json"
tampered = json.loads(report_path.read_text(encoding="utf-8"))
tampered["reason"] = "tampered"
report_path.write_text(json.dumps(tampered), encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "integrity"):
store.load("tourism-run-test")
def test_research_run_store_rejects_manifest_hash_tampering(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-manifest-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
store.persist(report)
manifest_path = root / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["runs"]["tourism-run-manifest-test"]["report_hash"] = "0" * 64
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "integrity"):
store.load("tourism-run-manifest-test")
def test_research_run_store_migrates_legacy_reports_explicitly(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-legacy-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
legacy_manifest = {
"schema_version": 1,
"runs": {
"tourism-run-legacy-test": {
"run_id": "tourism-run-legacy-test",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"report_file": "tourism-run-legacy-test.json",
}
},
}
(root / "reports").mkdir(parents=True)
(root / "reports" / "tourism-run-legacy-test.json").write_text(json.dumps(report), encoding="utf-8")
(root / "manifest.json").write_text(json.dumps(legacy_manifest), encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "integrity"):
store.load("tourism-run-legacy-test")
self.assertEqual(store.migrate_legacy(), 1)
migrated = store.load("tourism-run-legacy-test")
manifest_entry = store.load_manifest()["runs"]["tourism-run-legacy-test"]
self.assertEqual(migrated["report_hash_algorithm"], "sha256-json-canonical-v1")
self.assertRegex(migrated["report_hash"], r"^[a-f0-9]{64}$")
self.assertRegex(manifest_entry["manifest_entry_hash"], r"^[a-f0-9]{64}$")
self.assertEqual(store.migrate_legacy(), 0)
def test_research_run_store_completes_interrupted_legacy_migration(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-partial-migration-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
partial_report = copy.deepcopy(report)
partial_report["report_hash_algorithm"] = "sha256-json-canonical-v1"
partial_report["report_hash"] = _report_hash(partial_report)
legacy_entry = {
"run_id": report["run_id"],
"status": report["status"],
"reason": report["reason"],
"generated_at": report["generated_at"],
"report_file": f"{report['run_id']}.json",
}
(root / "reports").mkdir(parents=True)
(root / "reports" / f"{report['run_id']}.json").write_text(json.dumps(partial_report), encoding="utf-8")
(root / "manifest.json").write_text(json.dumps({"schema_version": 1, "runs": {report["run_id"]: legacy_entry}}), encoding="utf-8")
self.assertEqual(store.migrate_legacy(), 1)
self.assertEqual(store.load(report["run_id"])["report_hash"], partial_report["report_hash"])
self.assertEqual(store.migrate_legacy(), 0)
def test_research_run_store_rejects_manifest_identity_tampering(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-identity-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
store.persist(report)
manifest_path = root / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
entry = manifest["runs"]["tourism-run-identity-test"]
entry["run_id"] = "tourism-run-other"
entry["manifest_entry_hash"] = _manifest_entry_hash(entry)
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "integrity"):
store.load("tourism-run-identity-test")
def test_research_run_store_rejects_manifest_metadata_tampering(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-metadata-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
store.persist(report)
manifest_path = root / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["runs"]["tourism-run-metadata-test"]["generated_at"] = "2099-02-01T00:00:00+00:00"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "integrity"):
store.load("tourism-run-metadata-test")
def test_research_run_store_rejects_non_object_report(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-shape-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
store.persist(report)
report_path = root / "reports" / "tourism-run-shape-test.json"
report_path.write_text("[\"tampered\"]", encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "invalid"):
store.load("tourism-run-shape-test")
def test_research_run_store_rejects_non_object_manifest(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
(root / "manifest.json").write_text("[]", encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "schema"):
store.load_manifest()
def test_research_run_store_rejects_unreadable_manifest_bytes(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
(root / "manifest.json").write_bytes(b"\xff")
with self.assertRaisesRegex(ResearchRunError, "unreadable"):
store.load_manifest()
def test_research_run_store_rejects_unreadable_report_bytes(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-encoding-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
store.persist(report)
(root / "reports" / "tourism-run-encoding-test.json").write_bytes(b"\xff")
with self.assertRaisesRegex(ResearchRunError, "unreadable"):
store.load("tourism-run-encoding-test")
def test_research_run_store_list_runs_rejects_manifest_metadata_tampering(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-list-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
store.persist(report)
manifest_path = root / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["runs"]["tourism-run-list-test"]["generated_at"] = "2099-02-01T00:00:00+00:00"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "integrity"):
store.list_runs()
def test_research_run_store_latest_and_list_reject_malformed_manifest_entry(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-entry-shape-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
store.persist(report)
manifest_path = root / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["runs"]["tourism-run-entry-shape-test"] = []
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "entry"):
store.list_runs()
with self.assertRaisesRegex(ResearchRunError, "entry"):
store.latest()
def test_research_run_store_persist_rejects_malformed_existing_manifest_entry(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
report = {
"schema_version": 1,
"run_id": "tourism-run-persist-entry-test",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
store.persist(report)
manifest_path = root / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["runs"]["tourism-run-persist-entry-test"] = []
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "entry"):
store.persist(report)
def test_research_run_store_persist_validates_unrelated_manifest_entries_before_write(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = ResearchRunStore(root)
existing_report = {
"schema_version": 1,
"run_id": "tourism-run-persist-unrelated-existing",
"theme": "tourism",
"status": "blocked",
"reason": "test",
"generated_at": "2026-02-01T00:00:00+00:00",
"config": {},
"gates": {},
"inputs": {},
}
new_report = {
**existing_report,
"run_id": "tourism-run-persist-unrelated-new",
}
store.persist(existing_report)
manifest_path = root / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["runs"]["tourism-run-persist-unrelated-malformed"] = []
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "entry"):
store.persist(new_report)
self.assertFalse((root / "reports" / "tourism-run-persist-unrelated-new.json").exists())
def test_price_store_loads_snapshot_and_verifies_raw_payload(self):
with tempfile.TemporaryDirectory() as temp_dir:
store = PriceSnapshotStore(Path(temp_dir))
snapshot, raw = price_snapshot(point_in_time=False)
store.persist(snapshot, raw)
loaded = store.load_snapshot("prices-test")
self.assertEqual(loaded["source"]["snapshot_id"], "prices-test")
self.assertEqual(loaded["source"]["raw_payload_hash"], store.load_manifest()["snapshots"]["prices-test"]["raw_payload_hash"])
def test_price_store_rejects_manifest_metadata_tampering(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = PriceSnapshotStore(root)
snapshot, raw = price_snapshot(point_in_time=False)
store.persist(snapshot, raw)
manifest_path = root / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["snapshots"]["prices-test"]["point_in_time"] = True
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(PriceSourceError, "metadata"):
store.load_snapshot("prices-test")
def test_runner_persists_blocked_report_when_vintage_count_is_insufficient(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
price_store = PriceSnapshotStore(root / "prices")
snapshot, raw = price_snapshot(point_in_time=False)
price_store.persist(snapshot, raw)
run_store = ResearchRunStore(root / "runs")
report = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,))
self.assertEqual(report["status"], "blocked")
self.assertEqual(report["reason"], "insufficient_vintages")
self.assertEqual(report["gates"]["vintages"]["available_events"], 1)
self.assertNotIn("result", report)
self.assertEqual(run_store.latest()["run_id"], report["run_id"])
def test_runner_allows_explicit_exploratory_mode_with_revised_prices(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
price_store = PriceSnapshotStore(root / "prices")
snapshot, raw = price_snapshot(point_in_time=False)
price_store.persist(snapshot, raw)
run_store = ResearchRunStore(root / "runs")
report = run_tourism_research(
vintage_store,
price_store,
run_store,
mode="exploratory",
min_events=1,
windows=(1,),
)
self.assertEqual(report["status"], "descriptive_only")
self.assertEqual(report["research_mode"], "exploratory")
self.assertEqual(report["result_scope"], "non_pit_descriptive_only")
self.assertEqual(report["gates"]["prices"]["status"], "descriptive_only")
self.assertEqual(report["gates"]["prices"]["reason"], "price_series_not_point_in_time")
self.assertEqual(report["result"]["event_count"], 1)
def test_runner_keeps_exploratory_pit_inputs_descriptive_only(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
price_store = PriceSnapshotStore(root / "prices")
snapshot, raw = price_snapshot(point_in_time=True)
price_store.persist(snapshot, raw)
run_store = ResearchRunStore(root / "runs")
report = run_tourism_research(
vintage_store,
price_store,
run_store,
mode="exploratory",
min_events=1,
windows=(1,),
)
self.assertEqual(report["status"], "descriptive_only")
self.assertEqual(report["result_scope"], "non_pit_descriptive_only")
self.assertEqual(report["gates"]["prices"]["status"], "descriptive_only")
self.assertFalse(report["gates"]["prices"]["validated"])
self.assertTrue(report["gates"]["prices"]["point_in_time"])
self.assertEqual(report["gates"]["prices"]["reason"], "exploratory_mode_non_validated")
def test_runner_reuses_same_run_when_only_observation_time_changes(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
price_store = PriceSnapshotStore(root / "prices")
snapshot, raw = price_snapshot(point_in_time=False)
price_store.persist(snapshot, raw)
run_store = ResearchRunStore(root / "runs")
first = run_tourism_research(vintage_store, price_store, run_store, mode="exploratory", min_events=1, windows=(1,))
observed_again = copy.deepcopy(snapshot)
observed_again["source"]["retrieved_at"] = "2026-02-03T00:00:00+00:00"
price_store.persist(observed_again, raw)
second = run_tourism_research(vintage_store, price_store, run_store, mode="exploratory", min_events=1, windows=(1,))
self.assertEqual(second["run_id"], first["run_id"])
self.assertEqual(second, first)
self.assertEqual(len(price_store.list_observations()), 2)
def test_runner_computes_replayable_result_when_both_gates_pass(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
for index, published_at in ((1, "2026-01-01T08:00:00+07:00"), (2, "2026-01-02T08:00:00+07:00")):
snapshot = tourism_snapshot(f"v{index}", published_at)
vintage_store.persist(f"tourism-{index}".encode(), snapshot)
price_store = PriceSnapshotStore(root / "prices")
snapshot, raw = price_snapshot(point_in_time=True)
price_store.persist(snapshot, raw)
run_store = ResearchRunStore(root / "runs")
report = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,), cost_bps=20)
replay = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,), cost_bps=20)
self.assertEqual(report["status"], "ready")
self.assertEqual(report["result"]["event_count"], 2)
self.assertEqual(report["result"]["windows"]["1"]["cost_bps"], 20.0)
self.assertEqual(replay, report)
self.assertEqual(report["inputs"]["price_snapshot"]["point_in_time"], True)
def test_runner_uses_latest_revision_once(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
vintage_store.persist(b"initial", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
revised = tourism_snapshot("v1-revised", "2026-01-01T01:00:00Z")
revised["source"]["source_id"] = "test.bot"
vintage_store.persist(b"revised", revised)
vintage_store.persist(b"next", tourism_snapshot("v2", "2026-01-02T08:00:00+07:00"))
price_store = PriceSnapshotStore(root / "prices")
snapshot, raw = price_snapshot(point_in_time=True)
price_store.persist(snapshot, raw)
report = run_tourism_research(vintage_store, price_store, ResearchRunStore(root / "runs"), min_events=2, windows=(1,))
self.assertEqual(report["status"], "ready")
self.assertEqual(len(report["inputs"]["vintages"]), 2)
self.assertIn("v1-revised", {entry["vintage_id"] for entry in report["inputs"]["vintages"]})
def test_runner_does_not_replay_cached_ready_report_after_price_tampering(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
for index, published_at in ((1, "2026-01-01T08:00:00+07:00"), (2, "2026-01-02T08:00:00+07:00")):
vintage_store.persist(f"tourism-{index}".encode(), tourism_snapshot(f"v{index}", published_at))
price_store = PriceSnapshotStore(root / "prices")
snapshot, raw = price_snapshot(point_in_time=True)
price_store.persist(snapshot, raw)
run_store = ResearchRunStore(root / "runs")
report = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,))
self.assertEqual(report["status"], "ready")
manifest_path = root / "prices" / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["snapshots"]["prices-test"]["point_in_time"] = False
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(ResearchRunError, "integrity"):
run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,))
if __name__ == "__main__":
unittest.main()