diff --git a/scripts/publish_account_facts_from_report.py b/scripts/publish_account_facts_from_report.py index 0ed3107..c7c4f52 100644 --- a/scripts/publish_account_facts_from_report.py +++ b/scripts/publish_account_facts_from_report.py @@ -28,6 +28,7 @@ IBKR_ACCOUNT_FACTS_SYNC_TOKEN_ENV = "IBKR_ACCOUNT_FACTS_SYNC_TOKEN" IBKR_ACCOUNT_FACTS_SYNC_URL = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/account-facts/sync" IBKR_ACCOUNT_FACTS_USER_AGENT = "QSL-IBKR-AccountFacts/1.0" +IBKR_ACCOUNT_FACTS_MAX_AGE = timedelta(hours=36) IBKR_ACCOUNT_FACTS_INGRESS_DIAGNOSTIC_TARGET = "ingress-diagnostic" _INGRESS_DIAGNOSTIC_BODY = b"{}" _INGRESS_DIAGNOSTIC_ERROR = "invalid_account_facts_history" @@ -258,7 +259,7 @@ def publish_ibkr_account_facts_history( raise _ProjectionError("account_facts_invalid") observed = _observed_timestamp(facts.get("observed_at")) current = now.astimezone(timezone.utc) - if observed < current - timedelta(minutes=15) or observed > current + timedelta(minutes=5): + if observed < current - IBKR_ACCOUNT_FACTS_MAX_AGE or observed > current + timedelta(minutes=5): raise _ProjectionError("observation_stale") endpoint = urlsplit(_text(sync_url)) if ( diff --git a/tests/test_publish_ibkr_account_facts.py b/tests/test_publish_ibkr_account_facts.py index d2c3e7b..606897c 100644 --- a/tests/test_publish_ibkr_account_facts.py +++ b/tests/test_publish_ibkr_account_facts.py @@ -15,6 +15,9 @@ def _report() -> dict[str, object]: + observed_at = datetime(2026, 9, 30, 1, 0, 1, tzinfo=timezone.utc) + started_at = observed_at.replace(microsecond=0) + finished_at = observed_at.replace(microsecond=0).replace(second=2) return { "schema_version": "runtime_report.v1", "platform": "interactive_brokers", @@ -28,14 +31,14 @@ def _report() -> dict[str, object]: }, "diagnostics": {"runtime_revision": "service-00369-88c"}, "runtime_release_receipt": {"attestation_state": "legacy_unattested"}, - "started_at": "2026-09-30T01:00:00Z", - "finished_at": "2026-09-30T01:00:02Z", + "started_at": started_at.isoformat().replace("+00:00", "Z"), + "finished_at": finished_at.isoformat().replace("+00:00", "Z"), "summary": { "account_facts": { "schema_version": "ibkr_account_snapshot.v1", "account_ids": ["U16608560"], "currency": "USD", - "observed_at": "2026-09-30T01:00:01Z", + "observed_at": observed_at.isoformat().replace("+00:00", "Z"), "net_assets": "12345.6700", "cash": [ {"currency": "USD", "cash_balance": "100.00", "source_tag": "$LEDGER-CashBalance"}, @@ -209,7 +212,7 @@ def open(self, request, *, timeout): monkeypatch.setattr(publisher, "build_opener", lambda *_args: Opener()) result = publisher.publish_ibkr_account_facts_history( _report(), - now=datetime(2026, 9, 30, 1, 1, 30, tzinfo=timezone.utc), + now=datetime(2026, 9, 30, 1, 40, 1, tzinfo=timezone.utc), source_report_uri="gs://qsl-runtime-reports/ibkr/report-1.json", sync_url=publisher.IBKR_ACCOUNT_FACTS_SYNC_URL, sync_token="dedicated-test-token", @@ -231,12 +234,24 @@ def open(self, request, *, timeout): assert observed["timeout"] == 15 -def test_publisher_rejects_stale_or_missing_facts_without_http(monkeypatch): - monkeypatch.setattr( - publisher, - "build_opener", - lambda *_args: (_ for _ in ()).throw(AssertionError("must not POST")), - ) +def test_publisher_accepts_36_hour_boundary_and_rejects_older_or_future_without_http(monkeypatch): + observed_requests = [] + + class Response: + status = 201 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + class Opener: + def open(self, request, *, timeout): + observed_requests.append((request, timeout)) + return Response() + + monkeypatch.setattr(publisher, "build_opener", lambda *_args: Opener()) args = { "source_report_uri": "gs://qsl-runtime-reports/ibkr/report-1.json", "sync_url": publisher.IBKR_ACCOUNT_FACTS_SYNC_URL, @@ -250,12 +265,27 @@ def test_publisher_rejects_stale_or_missing_facts_without_http(monkeypatch): "expected_account_selector": ["U16608560"], "expected_deployment_selector": "live-u16608560", } - recent_wrapper = _report() - recent_wrapper["finished_at"] = "2026-09-30T01:20:00Z" - stale = publisher.publish_ibkr_account_facts_history( - recent_wrapper, now=datetime(2026, 9, 30, 1, 20, tzinfo=timezone.utc), **args + boundary = publisher.publish_ibkr_account_facts_history( + _report(), now=datetime(2026, 10, 1, 13, 0, 1, tzinfo=timezone.utc), **args + ) + assert boundary == {"status": "published"} + assert len(observed_requests) == 1 + assert observed_requests[0][1] == 15 + + older = publisher.publish_ibkr_account_facts_history( + _report(), now=datetime(2026, 10, 1, 13, 0, 2, tzinfo=timezone.utc), **args + ) + assert older == {"status": "skipped", "reason": "observation_stale"} + + future = _report() + future_facts = future["summary"]["account_facts"] # type: ignore[index] + future_facts["observed_at"] = "2026-09-30T01:06:01Z" # type: ignore[index] + future["started_at"] = "2026-09-30T01:06:00Z" + future["finished_at"] = "2026-09-30T01:06:02Z" + future_observation = publisher.publish_ibkr_account_facts_history( + future, now=datetime(2026, 9, 30, 1, 0, 1, tzinfo=timezone.utc), **args ) - assert stale == {"status": "skipped", "reason": "observation_stale"} + assert future_observation == {"status": "skipped", "reason": "observation_stale"} missing_facts = _report() missing_facts["summary"].pop("account_facts") # type: ignore[union-attr] @@ -263,6 +293,7 @@ def test_publisher_rejects_stale_or_missing_facts_without_http(monkeypatch): missing_facts, now=datetime(2026, 9, 30, 1, 1, 30, tzinfo=timezone.utc), **args ) assert missing == {"status": "skipped", "reason": "account_facts_invalid"} + assert len(observed_requests) == 1 wrong_endpoint = publisher.publish_ibkr_account_facts_history( _report(),