127 lines
4.7 KiB
Python
127 lines
4.7 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
|
|
from services.pchome_sales_source_reconciliation import build_source_reconciliation
|
|
|
|
|
|
NOW = datetime(2026, 7, 14, 18, 30, tzinfo=timezone(timedelta(hours=8)))
|
|
|
|
|
|
def _source(source_id, *, enabled=True, ready=True, status="ready"):
|
|
return {
|
|
"id": source_id,
|
|
"enabled": enabled,
|
|
"ready": ready,
|
|
"status": status,
|
|
}
|
|
|
|
|
|
def _receipt(provider, *, completed_at, reconciliation=None):
|
|
stages = {"providers": [provider]}
|
|
if reconciliation:
|
|
stages["source_reconciliation"] = reconciliation
|
|
return {
|
|
"status": "blocked_with_safe_next_action",
|
|
"completed_at": completed_at,
|
|
"stages": stages,
|
|
}
|
|
|
|
|
|
def test_stale_single_source_schedules_retry_and_reports_fallback_gap():
|
|
payload = build_source_reconciliation(
|
|
sources=[
|
|
_source("google_drive"),
|
|
_source("authorized_https", enabled=False, ready=False, status="disabled"),
|
|
],
|
|
provider_attempts=[
|
|
{"source": "google_drive", "status": "upstream_stale", "file_count": 0},
|
|
{"source": "authorized_https", "status": "disabled"},
|
|
],
|
|
prior_receipts=[],
|
|
freshness={"latest_sales_date": "2026-06-24", "data_lag_days": 20, "decision_ready": False},
|
|
now=NOW,
|
|
)
|
|
|
|
assert payload["contract"] == "pchome_sales_source_reconciliation_v1"
|
|
assert payload["status"] == "degraded"
|
|
assert payload["fallback_chain"] == ["google_drive"]
|
|
assert payload["single_source_dependency"] is True
|
|
assert payload["next_retry_at"] == "2026-07-14T19:00:00+08:00"
|
|
assert payload["safe_next_action"] == "scheduled_retry_and_enabled_fallback_probe"
|
|
assert payload["notification_policy"]["severity"] == "critical"
|
|
assert payload["notification_policy"]["send"] is True
|
|
assert payload["sources"][0]["consecutive_empty_runs"] == 1
|
|
assert payload["sources"][1]["runtime_state"] == "disabled"
|
|
|
|
|
|
def test_same_state_within_cooldown_suppresses_duplicate_notification():
|
|
first = build_source_reconciliation(
|
|
sources=[_source("google_drive")],
|
|
provider_attempts=[{"source": "google_drive", "status": "upstream_stale"}],
|
|
prior_receipts=[],
|
|
freshness={"data_lag_days": 20, "decision_ready": False},
|
|
now=NOW - timedelta(minutes=10),
|
|
)
|
|
history = [_receipt(
|
|
{"source": "google_drive", "status": "upstream_stale"},
|
|
completed_at=(NOW - timedelta(minutes=10)).isoformat(),
|
|
reconciliation=first,
|
|
)]
|
|
|
|
second = build_source_reconciliation(
|
|
sources=[_source("google_drive")],
|
|
provider_attempts=[{"source": "google_drive", "status": "upstream_stale"}],
|
|
prior_receipts=history,
|
|
freshness={"data_lag_days": 20, "decision_ready": False},
|
|
now=NOW,
|
|
)
|
|
|
|
assert second["notification_policy"]["fingerprint"] == first["notification_policy"]["fingerprint"]
|
|
assert second["notification_policy"]["send"] is False
|
|
assert second["notification_policy"]["reason"] == "duplicate_within_cooldown"
|
|
assert second["sources"][0]["consecutive_empty_runs"] == 2
|
|
|
|
|
|
def test_provider_failures_use_bounded_exponential_backoff():
|
|
failed = {"source": "authorized_https", "status": "failed", "error_kind": "http_fetch_failed"}
|
|
history = [
|
|
_receipt(failed, completed_at=(NOW - timedelta(minutes=30)).isoformat()),
|
|
_receipt(failed, completed_at=(NOW - timedelta(minutes=60)).isoformat()),
|
|
]
|
|
|
|
payload = build_source_reconciliation(
|
|
sources=[_source("authorized_https")],
|
|
provider_attempts=[failed],
|
|
prior_receipts=history,
|
|
freshness={"data_lag_days": 5, "decision_ready": False},
|
|
now=NOW,
|
|
retry_interval_minutes=30,
|
|
max_failure_backoff_minutes=240,
|
|
)
|
|
|
|
source = payload["sources"][0]
|
|
assert source["runtime_state"] == "failed"
|
|
assert source["consecutive_failure_runs"] == 3
|
|
assert source["retry_minutes"] == 120
|
|
assert source["next_retry_at"] == "2026-07-14T20:30:00+08:00"
|
|
assert source["action"] == "retry_with_bounded_backoff_and_probe_fallback"
|
|
|
|
|
|
def test_duplicate_source_remains_on_scheduled_retry_without_writing():
|
|
payload = build_source_reconciliation(
|
|
sources=[_source("google_drive")],
|
|
provider_attempts=[{
|
|
"source": "google_drive",
|
|
"status": "duplicate",
|
|
"duplicate_count": 1,
|
|
}],
|
|
prior_receipts=[],
|
|
freshness={"data_lag_days": 10, "decision_ready": False},
|
|
now=NOW,
|
|
)
|
|
|
|
source = payload["sources"][0]
|
|
assert source["runtime_state"] == "duplicate"
|
|
assert source["retry_minutes"] == 30
|
|
assert source["next_retry_at"] == "2026-07-14T19:00:00+08:00"
|
|
assert payload["safe_next_action"] == "scheduled_retry_and_enabled_fallback_probe"
|