This commit is contained in:
parent
625999245e
commit
9f73fd4cd4
|
|
@ -1,7 +1,9 @@
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Any, Optional
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
class GeographicCellCache:
|
class GeographicCellCache:
|
||||||
|
|
@ -107,3 +109,98 @@ class CircuitBreaker:
|
||||||
self.failure_count = 0
|
self.failure_count = 0
|
||||||
self.state = "CLOSED"
|
self.state = "CLOSED"
|
||||||
self.last_error_message = None
|
self.last_error_message = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersistentUsageTracker:
|
||||||
|
"""
|
||||||
|
Durable monthly usage and request-budget tracker.
|
||||||
|
Persists request counts and cost estimates to disk to ensure
|
||||||
|
process restarts or server reboots cannot reset billing safeguards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider_id: str,
|
||||||
|
monthly_budget_limit: int = 500,
|
||||||
|
cost_per_request_usd: float = 0.005,
|
||||||
|
state_file: Optional[Any] = None,
|
||||||
|
):
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
|
||||||
|
self.provider_id = provider_id
|
||||||
|
self.monthly_budget_limit = monthly_budget_limit
|
||||||
|
self.cost_per_request_usd = cost_per_request_usd
|
||||||
|
|
||||||
|
if state_file is not None:
|
||||||
|
self.state_file = Path(state_file)
|
||||||
|
else:
|
||||||
|
base_dir = Path(os.environ.get("USAGE_STATE_DIR", Path(__file__).resolve().parent.parent.parent / "storage"))
|
||||||
|
self.state_file = base_dir / f"usage_{provider_id}.json"
|
||||||
|
|
||||||
|
from threading import Lock
|
||||||
|
self.lock = Lock()
|
||||||
|
self.current_period = datetime.now(timezone.utc).strftime("%Y-%m")
|
||||||
|
self.requests_used: int = 0
|
||||||
|
self.total_lifetime_requests: int = 0
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.state_file.exists():
|
||||||
|
import json
|
||||||
|
with open(self.state_file, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
saved_period = data.get("period")
|
||||||
|
if saved_period == self.current_period:
|
||||||
|
self.requests_used = int(data.get("requests_used", 0))
|
||||||
|
else:
|
||||||
|
self.requests_used = 0
|
||||||
|
self.total_lifetime_requests = int(data.get("lifetime_requests", self.requests_used))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = self.state_file.with_suffix(".tmp")
|
||||||
|
data = {
|
||||||
|
"provider_id": self.provider_id,
|
||||||
|
"period": self.current_period,
|
||||||
|
"requests_used": self.requests_used,
|
||||||
|
"lifetime_requests": self.total_lifetime_requests,
|
||||||
|
"monthly_budget_limit": self.monthly_budget_limit,
|
||||||
|
"estimated_cost_usd": round(self.requests_used * self.cost_per_request_usd, 4),
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
tmp_path.replace(self.state_file)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def check_period(self) -> None:
|
||||||
|
now_period = datetime.now(timezone.utc).strftime("%Y-%m")
|
||||||
|
if now_period != self.current_period:
|
||||||
|
self.current_period = now_period
|
||||||
|
self.requests_used = 0
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def can_request(self) -> bool:
|
||||||
|
with self.lock:
|
||||||
|
self.check_period()
|
||||||
|
return self.requests_used < self.monthly_budget_limit
|
||||||
|
|
||||||
|
def record_request(self) -> int:
|
||||||
|
with self.lock:
|
||||||
|
self.check_period()
|
||||||
|
self.requests_used += 1
|
||||||
|
self.total_lifetime_requests += 1
|
||||||
|
self._save()
|
||||||
|
return self.requests_used
|
||||||
|
|
||||||
|
@property
|
||||||
|
def estimated_cost_usd(self) -> float:
|
||||||
|
return round(self.requests_used * self.cost_per_request_usd, 4)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ from pydantic import BaseModel, Field, field_validator
|
||||||
class NormalizedAlert(BaseModel):
|
class NormalizedAlert(BaseModel):
|
||||||
id: str = Field(description="Normalized unique alert identifier")
|
id: str = Field(description="Normalized unique alert identifier")
|
||||||
source: str = Field(description="Attributed data source name")
|
source: str = Field(description="Attributed data source name")
|
||||||
source_type: Literal["native_rider", "licensed_external", "official_dot"] = Field(
|
source_type: Literal[
|
||||||
description="Source classification"
|
"native_rider",
|
||||||
)
|
"experimental_external",
|
||||||
|
"licensed_external",
|
||||||
|
"official_dot",
|
||||||
|
] = Field(description="Source classification")
|
||||||
category: Literal[
|
category: Literal[
|
||||||
"police",
|
"police",
|
||||||
"accident",
|
"accident",
|
||||||
|
|
@ -29,12 +32,15 @@ class NormalizedAlert(BaseModel):
|
||||||
reported_at_utc: str = Field(description="ISO 8601 UTC creation timestamp")
|
reported_at_utc: str = Field(description="ISO 8601 UTC creation timestamp")
|
||||||
fetched_at_utc: str = Field(description="ISO 8601 UTC ingest timestamp")
|
fetched_at_utc: str = Field(description="ISO 8601 UTC ingest timestamp")
|
||||||
expires_at_utc: str = Field(description="ISO 8601 UTC expiration timestamp")
|
expires_at_utc: str = Field(description="ISO 8601 UTC expiration timestamp")
|
||||||
confidence: float = Field(ge=0.0, le=1.0, description="Normalized confidence score (0.0 to 1.0)")
|
confidence: Optional[float] = Field(
|
||||||
|
default=None, ge=0.0, le=1.0, description="Normalized confidence score (0.0 to 1.0)"
|
||||||
|
)
|
||||||
reliability: Optional[float] = Field(default=None, description="Source reliability score")
|
reliability: Optional[float] = Field(default=None, description="Source reliability score")
|
||||||
is_stale: bool = Field(default=False, description="Flag indicating if record exceeds freshness TTL")
|
is_stale: bool = Field(default=False, description="Flag indicating if record exceeds freshness TTL")
|
||||||
raw_provider_id: Optional[str] = Field(default=None, description="Original upstream provider/alert ID")
|
raw_provider_id: Optional[str] = Field(default=None, description="Original upstream provider/alert ID")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class AlertFeedResponse(BaseModel):
|
class AlertFeedResponse(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
count: int
|
count: int
|
||||||
|
|
|
||||||
|
|
@ -63,25 +63,133 @@ def verify_rate_limit():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_gateway_auth(
|
# Set of permitted static test fixture filenames
|
||||||
|
ALLOWED_FIXTURES = {"zyla_rome_sample.json", "zyla_atlanta_sample.json"}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_gateway_or_user_auth(
|
||||||
|
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||||
x_gateway_key: Optional[str] = Header(None, alias="X-MotorSignal-Gateway-Key"),
|
x_gateway_key: Optional[str] = Header(None, alias="X-MotorSignal-Gateway-Key"),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Authenticates requests via either:
|
||||||
|
1. A valid User Bearer Token (MotorSignal JWT access token or Firebase ID Token).
|
||||||
|
2. A matching server-side gateway secret (X-MotorSignal-Gateway-Key), if explicitly configured.
|
||||||
|
FAILS CLOSED: If neither is provided or valid, raises HTTP 401 Unauthorized.
|
||||||
|
"""
|
||||||
|
# 1. Server-to-server gateway key check (if configured in server environment)
|
||||||
required_secret = os.environ.get("MOTORSIGNAL_GATEWAY_SECRET")
|
required_secret = os.environ.get("MOTORSIGNAL_GATEWAY_SECRET")
|
||||||
if required_secret and x_gateway_key != required_secret:
|
if required_secret and x_gateway_key == required_secret:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 2. Bearer token check (MotorSignal access token or Firebase ID token)
|
||||||
|
if authorization:
|
||||||
|
scheme, _, token = authorization.partition(" ")
|
||||||
|
if scheme.lower() == "bearer" and token.strip():
|
||||||
|
token_val = token.strip()
|
||||||
|
# Try decoding MotorSignal access token
|
||||||
|
try:
|
||||||
|
from auth import decode_access_token
|
||||||
|
payload = decode_access_token(token_val)
|
||||||
|
if payload.get("type") in ("access", "test") or "sub" in payload:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try decoding Firebase ID token
|
||||||
|
try:
|
||||||
|
from auth import decode_firebase_id_token
|
||||||
|
claims = decode_firebase_id_token(token_val)
|
||||||
|
if claims.get("sub"):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# In test mode, accept simulated test/mock tokens
|
||||||
|
is_test_env = (
|
||||||
|
os.environ.get("TESTING", "").lower() in ("true", "1")
|
||||||
|
or os.environ.get("ENVIRONMENT", "").lower() == "test"
|
||||||
|
)
|
||||||
|
if is_test_env and (token_val.startswith("test_token_") or token_val.startswith("mock_token_")):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Strict Fail Closed
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid or missing MotorSignal Gateway key",
|
detail="Authentication required. Provide a valid Bearer token or Gateway key.",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_admin_auth(
|
def verify_admin_auth(
|
||||||
|
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||||
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
|
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Requires admin privileges to modify operational provider switches.
|
||||||
|
FAILS CLOSED: If admin key is missing or invalid, raises HTTP 403 Forbidden.
|
||||||
|
"""
|
||||||
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
|
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
|
||||||
if required_admin and x_admin_key != required_admin:
|
if required_admin and x_admin_key == required_admin:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if authorization:
|
||||||
|
scheme, _, token = authorization.partition(" ")
|
||||||
|
if scheme.lower() == "bearer" and token.strip():
|
||||||
|
token_val = token.strip()
|
||||||
|
try:
|
||||||
|
from auth import decode_access_token
|
||||||
|
payload = decode_access_token(token_val)
|
||||||
|
role = payload.get("role", "")
|
||||||
|
if role in ("owner", "operator", "admin"):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
is_test_env = (
|
||||||
|
os.environ.get("TESTING", "").lower() in ("true", "1")
|
||||||
|
or os.environ.get("ENVIRONMENT", "").lower() == "test"
|
||||||
|
)
|
||||||
|
if is_test_env and token_val.startswith("test_admin_token_"):
|
||||||
|
return True
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Unauthorized access to Ops switch controls",
|
detail="Admin authorization required. Access forbidden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_fixture_override(
|
||||||
|
fixture_override: Optional[str],
|
||||||
|
x_admin_key: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Ensures fixture overrides cannot be exploited by normal clients in production.
|
||||||
|
"""
|
||||||
|
if not fixture_override:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if fixture_override not in ALLOWED_FIXTURES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid fixture_override: '{fixture_override}'. Allowed fixtures: {list(ALLOWED_FIXTURES)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
is_test_env = (
|
||||||
|
os.environ.get("TESTING", "").lower() in ("true", "1")
|
||||||
|
or os.environ.get("ENVIRONMENT", "").lower() == "test"
|
||||||
|
or os.environ.get("ENABLE_FIXTURE_OVERRIDE", "false").lower() in ("true", "1")
|
||||||
|
)
|
||||||
|
if is_test_env:
|
||||||
|
return fixture_override
|
||||||
|
|
||||||
|
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
|
||||||
|
if required_admin and x_admin_key == required_admin:
|
||||||
|
return fixture_override
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="fixture_override is restricted to test environments or authorized administrators.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -109,10 +217,19 @@ def validate_bounding_box(min_lat: float, min_lng: float, max_lat: float, max_ln
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def get_public_health():
|
||||||
|
"""
|
||||||
|
Lightweight unauthenticated connectivity check. Does not expose operational metrics.
|
||||||
|
"""
|
||||||
|
return {"status": "ok", "service": "road-alerts"}
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/feed",
|
"/feed",
|
||||||
response_model=AlertFeedResponse,
|
response_model=AlertFeedResponse,
|
||||||
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)],
|
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
|
||||||
)
|
)
|
||||||
def get_aggregated_feed(
|
def get_aggregated_feed(
|
||||||
min_lat: float = Query(..., ge=-90.0, le=90.0, description="Minimum latitude (South)"),
|
min_lat: float = Query(..., ge=-90.0, le=90.0, description="Minimum latitude (South)"),
|
||||||
|
|
@ -122,12 +239,14 @@ def get_aggregated_feed(
|
||||||
include_zyla: bool = Query(default=True, description="Query Zyla Waze Data #4814 if enabled"),
|
include_zyla: bool = Query(default=True, description="Query Zyla Waze Data #4814 if enabled"),
|
||||||
include_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"),
|
include_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"),
|
||||||
fixture_override: Optional[str] = Query(default=None, description="Test fixture override name"),
|
fixture_override: Optional[str] = Query(default=None, description="Test fixture override name"),
|
||||||
|
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Retrieves multi-source road alerts within the bounded geographic box.
|
Retrieves multi-source road alerts within the bounded geographic box.
|
||||||
Enforces geographic bounds (<= 1.0 degree) and rate limits.
|
Enforces geographic bounds (<= 1.0 degree), rate limits, and authentication.
|
||||||
"""
|
"""
|
||||||
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
|
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
|
||||||
|
safe_fixture = validate_fixture_override(fixture_override, x_admin_key)
|
||||||
|
|
||||||
all_alerts: list[NormalizedAlert] = []
|
all_alerts: list[NormalizedAlert] = []
|
||||||
providers_polled: list[str] = []
|
providers_polled: list[str] = []
|
||||||
|
|
@ -138,7 +257,7 @@ def get_aggregated_feed(
|
||||||
min_lng=min_lng,
|
min_lng=min_lng,
|
||||||
max_lat=max_lat,
|
max_lat=max_lat,
|
||||||
max_lng=max_lng,
|
max_lng=max_lng,
|
||||||
fixture_override=fixture_override,
|
fixture_override=safe_fixture,
|
||||||
)
|
)
|
||||||
all_alerts.extend(zyla_alerts)
|
all_alerts.extend(zyla_alerts)
|
||||||
providers_polled.append(zyla_adapter.PROVIDER_ID)
|
providers_polled.append(zyla_adapter.PROVIDER_ID)
|
||||||
|
|
@ -165,7 +284,7 @@ def get_aggregated_feed(
|
||||||
@router.get(
|
@router.get(
|
||||||
"/zyla",
|
"/zyla",
|
||||||
response_model=AlertFeedResponse,
|
response_model=AlertFeedResponse,
|
||||||
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)],
|
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
|
||||||
)
|
)
|
||||||
def get_zyla_alerts(
|
def get_zyla_alerts(
|
||||||
min_lat: float = Query(..., ge=-90.0, le=90.0),
|
min_lat: float = Query(..., ge=-90.0, le=90.0),
|
||||||
|
|
@ -173,19 +292,21 @@ def get_zyla_alerts(
|
||||||
max_lat: float = Query(..., ge=-90.0, le=90.0),
|
max_lat: float = Query(..., ge=-90.0, le=90.0),
|
||||||
max_lng: float = Query(..., ge=-180.0, le=180.0),
|
max_lng: float = Query(..., ge=-180.0, le=180.0),
|
||||||
fixture_override: Optional[str] = Query(default=None),
|
fixture_override: Optional[str] = Query(default=None),
|
||||||
|
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Direct endpoint for Zyla Labs Waze Data API #4814.
|
Direct endpoint for Zyla Labs Waze Data API #4814.
|
||||||
Enforces geographic box bounds and server-side budget limits.
|
Enforces geographic box bounds and server-side budget limits.
|
||||||
"""
|
"""
|
||||||
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
|
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
|
||||||
|
safe_fixture = validate_fixture_override(fixture_override, x_admin_key)
|
||||||
|
|
||||||
alerts = zyla_adapter.fetch_alerts(
|
alerts = zyla_adapter.fetch_alerts(
|
||||||
min_lat=min_lat,
|
min_lat=min_lat,
|
||||||
min_lng=min_lng,
|
min_lng=min_lng,
|
||||||
max_lat=max_lat,
|
max_lat=max_lat,
|
||||||
max_lng=max_lng,
|
max_lng=max_lng,
|
||||||
fixture_override=fixture_override,
|
fixture_override=safe_fixture,
|
||||||
)
|
)
|
||||||
return AlertFeedResponse(
|
return AlertFeedResponse(
|
||||||
success=True,
|
success=True,
|
||||||
|
|
@ -199,7 +320,7 @@ def get_zyla_alerts(
|
||||||
@router.get(
|
@router.get(
|
||||||
"/ga511",
|
"/ga511",
|
||||||
response_model=AlertFeedResponse,
|
response_model=AlertFeedResponse,
|
||||||
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)],
|
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
|
||||||
)
|
)
|
||||||
def get_ga511_alerts(
|
def get_ga511_alerts(
|
||||||
min_lat: float = Query(..., ge=-90.0, le=90.0),
|
min_lat: float = Query(..., ge=-90.0, le=90.0),
|
||||||
|
|
@ -228,11 +349,16 @@ def get_ga511_alerts(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ops/health", response_model=OpsHealthDashboardResponse)
|
@router.get(
|
||||||
|
"/ops/health",
|
||||||
|
response_model=OpsHealthDashboardResponse,
|
||||||
|
dependencies=[Depends(verify_gateway_or_user_auth)],
|
||||||
|
)
|
||||||
def get_ops_health():
|
def get_ops_health():
|
||||||
"""
|
"""
|
||||||
Operational health dashboard reporting uptime, failure rates, request counts,
|
Operational health dashboard reporting uptime, failure rates, request counts,
|
||||||
cache hit ratio, and usage spend per provider.
|
cache hit ratio, and usage spend per provider.
|
||||||
|
Requires authentication to protect internal operational telemetry.
|
||||||
"""
|
"""
|
||||||
zyla_health = zyla_adapter.get_health()
|
zyla_health = zyla_adapter.get_health()
|
||||||
ga511_health = ga511_adapter.get_health()
|
ga511_health = ga511_adapter.get_health()
|
||||||
|
|
@ -250,6 +376,7 @@ def get_ops_health():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ops/switches", dependencies=[Depends(verify_admin_auth)])
|
@router.post("/ops/switches", dependencies=[Depends(verify_admin_auth)])
|
||||||
def update_provider_switch(payload: ProviderSwitchRequest):
|
def update_provider_switch(payload: ProviderSwitchRequest):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from road_alerts.cache import CircuitBreaker, GeographicCellCache
|
from road_alerts.cache import CircuitBreaker, GeographicCellCache, PersistentUsageTracker
|
||||||
from road_alerts.models import NormalizedAlert, ProviderHealth
|
from road_alerts.models import NormalizedAlert, ProviderHealth
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -40,7 +40,7 @@ class ZylaWazeAdapter:
|
||||||
"""
|
"""
|
||||||
Backend-only adapter for Zyla Labs Waze Data API #4814.
|
Backend-only adapter for Zyla Labs Waze Data API #4814.
|
||||||
Adheres strictly to zero-credential leakage, circuit breaker,
|
Adheres strictly to zero-credential leakage, circuit breaker,
|
||||||
geographic caching, and schema normalization.
|
geographic caching, durable request-budget tracking, and schema normalization.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROVIDER_ID = "zyla_waze_4814"
|
PROVIDER_ID = "zyla_waze_4814"
|
||||||
|
|
@ -53,6 +53,7 @@ class ZylaWazeAdapter:
|
||||||
enabled: Optional[bool] = None,
|
enabled: Optional[bool] = None,
|
||||||
monthly_budget_limit: int = 500,
|
monthly_budget_limit: int = 500,
|
||||||
cost_per_request_usd: float = 0.005,
|
cost_per_request_usd: float = 0.005,
|
||||||
|
state_file: Optional[Path] = None,
|
||||||
):
|
):
|
||||||
# Resolve config from environment or overrides
|
# Resolve config from environment or overrides
|
||||||
self.api_key = api_key if api_key is not None else os.environ.get("ZYLA_API_KEY", "")
|
self.api_key = api_key if api_key is not None else os.environ.get("ZYLA_API_KEY", "")
|
||||||
|
|
@ -65,9 +66,15 @@ class ZylaWazeAdapter:
|
||||||
self.monthly_budget_limit = monthly_budget_limit
|
self.monthly_budget_limit = monthly_budget_limit
|
||||||
self.cost_per_request_usd = cost_per_request_usd
|
self.cost_per_request_usd = cost_per_request_usd
|
||||||
|
|
||||||
# Operational telemetry
|
# Operational telemetry and durable billing safeguard
|
||||||
self.cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=300.0)
|
self.cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=300.0)
|
||||||
self.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout_sec=300.0)
|
self.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout_sec=300.0)
|
||||||
|
self.usage_tracker = PersistentUsageTracker(
|
||||||
|
provider_id=self.PROVIDER_ID,
|
||||||
|
monthly_budget_limit=monthly_budget_limit,
|
||||||
|
cost_per_request_usd=cost_per_request_usd,
|
||||||
|
state_file=state_file,
|
||||||
|
)
|
||||||
self.request_count: int = 0
|
self.request_count: int = 0
|
||||||
self.success_count: int = 0
|
self.success_count: int = 0
|
||||||
self.error_count: int = 0
|
self.error_count: int = 0
|
||||||
|
|
@ -87,6 +94,9 @@ class ZylaWazeAdapter:
|
||||||
else:
|
else:
|
||||||
status = "operational"
|
status = "operational"
|
||||||
|
|
||||||
|
monthly_used = self.usage_tracker.requests_used
|
||||||
|
estimated_cost = self.usage_tracker.estimated_cost_usd
|
||||||
|
|
||||||
return ProviderHealth(
|
return ProviderHealth(
|
||||||
provider_id=self.PROVIDER_ID,
|
provider_id=self.PROVIDER_ID,
|
||||||
name=self.PROVIDER_NAME,
|
name=self.PROVIDER_NAME,
|
||||||
|
|
@ -104,8 +114,8 @@ class ZylaWazeAdapter:
|
||||||
last_error_at=self.last_error_at,
|
last_error_at=self.last_error_at,
|
||||||
last_error_message=self.last_error_message,
|
last_error_message=self.last_error_message,
|
||||||
monthly_budget_limit=self.monthly_budget_limit,
|
monthly_budget_limit=self.monthly_budget_limit,
|
||||||
monthly_requests_used=self.request_count,
|
monthly_requests_used=monthly_used,
|
||||||
estimated_cost_usd=round(self.request_count * self.cost_per_request_usd, 4),
|
estimated_cost_usd=estimated_cost,
|
||||||
)
|
)
|
||||||
|
|
||||||
def normalize_alert(self, raw: dict, now: datetime) -> Optional[NormalizedAlert]:
|
def normalize_alert(self, raw: dict, now: datetime) -> Optional[NormalizedAlert]:
|
||||||
|
|
@ -221,45 +231,67 @@ class ZylaWazeAdapter:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 5. Identifier and Source Attribution
|
# 5. Identifier and Source Attribution
|
||||||
raw_id = str(raw.get("uuid") or raw.get("id") or f"{lat}_{lng}_{int(reported_at.timestamp())}")
|
raw_id = str(raw.get("alert_id") or raw.get("uuid") or raw.get("id") or f"{lat}_{lng}_{int(reported_at.timestamp())}")
|
||||||
normalized_id = f"zyla_4814_{raw_id}"
|
normalized_id = f"zyla_4814_{raw_id}"
|
||||||
|
|
||||||
# 6. Confidence normalization (Waze rating 1-6, confidence 0-5)
|
# 6. Confidence normalization (Waze rating 1-6, confidence 0-5, or 0-10)
|
||||||
raw_confidence = raw.get("confidence")
|
# Truthfully preserve 0 values without inventing synthetic default confidence
|
||||||
if isinstance(raw_confidence, (int, float)):
|
raw_confidence = raw.get("alert_confidence") if "alert_confidence" in raw else raw.get("confidence")
|
||||||
conf_score = max(0.1, min(1.0, float(raw_confidence) / 5.0))
|
if raw_confidence is not None and isinstance(raw_confidence, (int, float)):
|
||||||
|
val = float(raw_confidence)
|
||||||
|
if 0.0 <= val <= 5.0:
|
||||||
|
conf_score: Optional[float] = round(max(0.0, min(1.0, val / 5.0)), 2)
|
||||||
|
elif 0.0 <= val <= 10.0:
|
||||||
|
conf_score = round(max(0.0, min(1.0, val / 10.0)), 2)
|
||||||
else:
|
else:
|
||||||
conf_score = 0.7
|
conf_score = round(max(0.0, min(1.0, val)), 2)
|
||||||
|
else:
|
||||||
|
conf_score = None
|
||||||
|
|
||||||
raw_reliability = raw.get("reliability")
|
# 7. Reliability score preservation (including honest 0)
|
||||||
|
raw_reliability = raw.get("alert_reliability") if "alert_reliability" in raw else raw.get("reliability")
|
||||||
reliability_score = float(raw_reliability) if isinstance(raw_reliability, (int, float)) else None
|
reliability_score = float(raw_reliability) if isinstance(raw_reliability, (int, float)) else None
|
||||||
|
|
||||||
|
raw_provider = raw.get("provider") or "Waze"
|
||||||
|
raw_provider_id = str(raw.get("provider_id") or raw.get("alert_id") or "4814")
|
||||||
|
description_text = raw.get("description") or raw.get("reportDescription")
|
||||||
|
|
||||||
return NormalizedAlert(
|
return NormalizedAlert(
|
||||||
id=normalized_id,
|
id=normalized_id,
|
||||||
source=self.PROVIDER_NAME,
|
source=self.PROVIDER_NAME,
|
||||||
source_type="licensed_external",
|
source_type="experimental_external",
|
||||||
category=category,
|
category=category,
|
||||||
subtype=subtype,
|
subtype=subtype,
|
||||||
latitude=round(lat, 6),
|
latitude=round(lat, 6),
|
||||||
longitude=round(lng, 6),
|
longitude=round(lng, 6),
|
||||||
road_name=raw.get("street"),
|
road_name=raw.get("street"),
|
||||||
city=raw.get("city"),
|
city=raw.get("city"),
|
||||||
description=raw.get("reportDescription"),
|
description=description_text,
|
||||||
reported_at_utc=reported_at.isoformat(),
|
reported_at_utc=reported_at.isoformat(),
|
||||||
fetched_at_utc=now.isoformat(),
|
fetched_at_utc=now.isoformat(),
|
||||||
expires_at_utc=expires_at.isoformat(),
|
expires_at_utc=expires_at.isoformat(),
|
||||||
confidence=round(conf_score, 2),
|
confidence=conf_score,
|
||||||
reliability=reliability_score,
|
reliability=reliability_score,
|
||||||
is_stale=False,
|
is_stale=False,
|
||||||
raw_provider_id=str(raw.get("provider_id") or "4814"),
|
raw_provider_id=raw_provider_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def parse_payload(self, payload: dict, now: Optional[datetime] = None) -> list[NormalizedAlert]:
|
def parse_payload(self, payload: dict, now: Optional[datetime] = None) -> list[NormalizedAlert]:
|
||||||
"""
|
"""
|
||||||
Parses upstream Zyla payload, applying deduplication and normalization.
|
Parses upstream Zyla payload, supporting both top-level 'alerts' and wrapped
|
||||||
|
'{status: OK, data: {alerts: [...]}}' structures.
|
||||||
"""
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
current_time = now or datetime.now(timezone.utc)
|
current_time = now or datetime.now(timezone.utc)
|
||||||
raw_alerts = payload.get("alerts", [])
|
if isinstance(payload.get("data"), dict):
|
||||||
|
raw_alerts = payload["data"].get("alerts", [])
|
||||||
|
elif isinstance(payload.get("alerts"), list):
|
||||||
|
raw_alerts = payload["alerts"]
|
||||||
|
else:
|
||||||
|
raw_alerts = []
|
||||||
|
|
||||||
if not isinstance(raw_alerts, list):
|
if not isinstance(raw_alerts, list):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -297,7 +329,7 @@ class ZylaWazeAdapter:
|
||||||
) -> list[NormalizedAlert]:
|
) -> list[NormalizedAlert]:
|
||||||
"""
|
"""
|
||||||
Fetches alerts for a bounding box, enforcing cache, circuit breaker,
|
Fetches alerts for a bounding box, enforcing cache, circuit breaker,
|
||||||
and zero live execution when disabled.
|
durable monthly budget tracker, and zero live execution when disabled.
|
||||||
"""
|
"""
|
||||||
current_time = now or datetime.now(timezone.utc)
|
current_time = now or datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
@ -320,13 +352,16 @@ class ZylaWazeAdapter:
|
||||||
logger.warning("Zyla Waze circuit breaker is OPEN. Requests prevented.")
|
logger.warning("Zyla Waze circuit breaker is OPEN. Requests prevented.")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 4. Request budget safeguard
|
# 4. Durable request budget safeguard
|
||||||
if self.request_count >= self.monthly_budget_limit and not fixture_override:
|
if not self.usage_tracker.can_request() and not fixture_override:
|
||||||
logger.warning("Zyla Waze monthly budget limit reached (%d).", self.monthly_budget_limit)
|
logger.warning("Zyla Waze monthly budget limit reached (%d).", self.monthly_budget_limit)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Increment tracked requests
|
# Increment tracked requests
|
||||||
self.request_count += 1
|
self.request_count += 1
|
||||||
|
if not fixture_override:
|
||||||
|
self.usage_tracker.record_request()
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 5. Fixture handling or mock fallback
|
# 5. Fixture handling or mock fallback
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
{
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"data": {
|
||||||
"alerts": [
|
"alerts": [
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_atl_pol_01",
|
||||||
"id": "zyla_atl_pol_01",
|
"id": "zyla_atl_pol_01",
|
||||||
"uuid": "zyla_atl_pol_01",
|
"uuid": "zyla_atl_pol_01",
|
||||||
"type": "POLICE",
|
"type": "POLICE",
|
||||||
|
|
@ -13,14 +16,18 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1791664300000,
|
"pubMillis": 1791664300000,
|
||||||
"reportRating": 5,
|
"reportRating": 5,
|
||||||
|
"alert_confidence": 5,
|
||||||
|
"alert_reliability": 9,
|
||||||
"confidence": 5,
|
"confidence": 5,
|
||||||
"reliability": 9,
|
"reliability": 9,
|
||||||
|
"description": "Speed enforcement vehicle under Freedom Pkwy overpass",
|
||||||
"reportDescription": "Speed enforcement vehicle under Freedom Pkwy overpass",
|
"reportDescription": "Speed enforcement vehicle under Freedom Pkwy overpass",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T20:31:40Z"
|
"publish_datetime_utc": "2026-10-10T20:31:40Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_atl_acc_01",
|
||||||
"id": "zyla_atl_acc_01",
|
"id": "zyla_atl_acc_01",
|
||||||
"uuid": "zyla_atl_acc_01",
|
"uuid": "zyla_atl_acc_01",
|
||||||
"type": "ACCIDENT",
|
"type": "ACCIDENT",
|
||||||
|
|
@ -33,14 +40,18 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1791664400000,
|
"pubMillis": 1791664400000,
|
||||||
"reportRating": 4,
|
"reportRating": 4,
|
||||||
|
"alert_confidence": 5,
|
||||||
|
"alert_reliability": 8,
|
||||||
"confidence": 5,
|
"confidence": 5,
|
||||||
"reliability": 8,
|
"reliability": 8,
|
||||||
|
"description": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
|
||||||
"reportDescription": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
|
"reportDescription": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T20:33:20Z"
|
"publish_datetime_utc": "2026-10-10T20:33:20Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_atl_haz_01",
|
||||||
"id": "zyla_atl_haz_01",
|
"id": "zyla_atl_haz_01",
|
||||||
"uuid": "zyla_atl_haz_01",
|
"uuid": "zyla_atl_haz_01",
|
||||||
"type": "HAZARD",
|
"type": "HAZARD",
|
||||||
|
|
@ -53,14 +64,18 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1791664250000,
|
"pubMillis": 1791664250000,
|
||||||
"reportRating": 3,
|
"reportRating": 3,
|
||||||
|
"alert_confidence": 4,
|
||||||
|
"alert_reliability": 7,
|
||||||
"confidence": 4,
|
"confidence": 4,
|
||||||
"reliability": 7,
|
"reliability": 7,
|
||||||
|
"description": "Severe pothole right wheel track near Lenox Square",
|
||||||
"reportDescription": "Severe pothole right wheel track near Lenox Square",
|
"reportDescription": "Severe pothole right wheel track near Lenox Square",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T20:30:50Z"
|
"publish_datetime_utc": "2026-10-10T20:30:50Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_atl_clo_01",
|
||||||
"id": "zyla_atl_clo_01",
|
"id": "zyla_atl_clo_01",
|
||||||
"uuid": "zyla_atl_clo_01",
|
"uuid": "zyla_atl_clo_01",
|
||||||
"type": "ROAD_CLOSED",
|
"type": "ROAD_CLOSED",
|
||||||
|
|
@ -68,56 +83,43 @@
|
||||||
"street": "I-75 N at Howell Mill Rd",
|
"street": "I-75 N at Howell Mill Rd",
|
||||||
"city": "Atlanta, GA",
|
"city": "Atlanta, GA",
|
||||||
"location": {
|
"location": {
|
||||||
"x": -84.4250,
|
"x": -84.4150,
|
||||||
"y": 33.8150
|
"y": 33.8100
|
||||||
},
|
},
|
||||||
"pubMillis": 1791662000000,
|
"pubMillis": 1791663000000,
|
||||||
"reportRating": 5,
|
"reportRating": 5,
|
||||||
|
"alert_confidence": 5,
|
||||||
|
"alert_reliability": 10,
|
||||||
"confidence": 5,
|
"confidence": 5,
|
||||||
"reliability": 10,
|
"reliability": 10,
|
||||||
"reportDescription": "Overnight bridge joint replacement closure",
|
"description": "Overnight lane replacement construction",
|
||||||
|
"reportDescription": "Overnight lane replacement construction",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T19:53:20Z"
|
"publish_datetime_utc": "2026-10-10T20:10:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_atl_stale_01",
|
||||||
"id": "zyla_atl_stale_01",
|
"id": "zyla_atl_stale_01",
|
||||||
"uuid": "zyla_atl_stale_01",
|
"uuid": "zyla_atl_stale_01",
|
||||||
"type": "ACCIDENT",
|
"type": "ACCIDENT",
|
||||||
"subtype": "ACCIDENT_MINOR",
|
"subtype": "ACCIDENT_MINOR",
|
||||||
"street": "Northside Dr NW",
|
"street": "GA-400 S",
|
||||||
"city": "Atlanta, GA",
|
"city": "Atlanta, GA",
|
||||||
"location": {
|
"location": {
|
||||||
"x": -84.4100,
|
"x": -84.3600,
|
||||||
"y": 33.7800
|
"y": 33.8500
|
||||||
},
|
},
|
||||||
"pubMillis": 1791620000000,
|
"pubMillis": 1791650000000,
|
||||||
"reportRating": 2,
|
"reportRating": 2,
|
||||||
"confidence": 2,
|
"alert_confidence": 2,
|
||||||
"reliability": 3,
|
"alert_reliability": 3,
|
||||||
"reportDescription": "Cleared accident from 12 hours ago",
|
"description": "Cleared minor crash from 4 hours ago",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T08:13:20Z"
|
"publish_datetime_utc": "2026-10-10T16:33:20Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"jams": [
|
"jams": []
|
||||||
{
|
|
||||||
"id": "zyla_atl_jam_01",
|
|
||||||
"uuid": "zyla_atl_jam_01",
|
|
||||||
"street": "I-20 E",
|
|
||||||
"city": "Atlanta, GA",
|
|
||||||
"speed": 12.5,
|
|
||||||
"delay": 420,
|
|
||||||
"length": 1850,
|
|
||||||
"level": 4,
|
|
||||||
"line": [
|
|
||||||
{ "x": -84.4300, "y": 33.7480 },
|
|
||||||
{ "x": -84.4000, "y": 33.7500 }
|
|
||||||
],
|
|
||||||
"pubMillis": 1791664400000,
|
|
||||||
"provider": "Waze",
|
|
||||||
"provider_id": "4814"
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
{
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"data": {
|
||||||
"alerts": [
|
"alerts": [
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_rome_pol_01",
|
||||||
"id": "zyla_rome_pol_01",
|
"id": "zyla_rome_pol_01",
|
||||||
"uuid": "zyla_rome_pol_01",
|
"uuid": "zyla_rome_pol_01",
|
||||||
"type": "POLICE",
|
"type": "POLICE",
|
||||||
|
|
@ -13,14 +16,18 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1791664000000,
|
"pubMillis": 1791664000000,
|
||||||
"reportRating": 4,
|
"reportRating": 4,
|
||||||
"confidence": 5,
|
"alert_confidence": 0,
|
||||||
"reliability": 8,
|
"alert_reliability": 0,
|
||||||
|
"confidence": 0,
|
||||||
|
"reliability": 0,
|
||||||
|
"description": "Stationary radar trap Northbound on shoulder",
|
||||||
"reportDescription": "Stationary radar trap Northbound on shoulder",
|
"reportDescription": "Stationary radar trap Northbound on shoulder",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_rome_haz_01",
|
||||||
"id": "zyla_rome_haz_01",
|
"id": "zyla_rome_haz_01",
|
||||||
"uuid": "zyla_rome_haz_01",
|
"uuid": "zyla_rome_haz_01",
|
||||||
"type": "HAZARD",
|
"type": "HAZARD",
|
||||||
|
|
@ -33,14 +40,18 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1791664100000,
|
"pubMillis": 1791664100000,
|
||||||
"reportRating": 3,
|
"reportRating": 3,
|
||||||
|
"alert_confidence": 4,
|
||||||
|
"alert_reliability": 7,
|
||||||
"confidence": 4,
|
"confidence": 4,
|
||||||
"reliability": 7,
|
"reliability": 7,
|
||||||
|
"description": "Tree branch in right lane around bend",
|
||||||
"reportDescription": "Tree branch in right lane around bend",
|
"reportDescription": "Tree branch in right lane around bend",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T20:28:20Z"
|
"publish_datetime_utc": "2026-10-10T20:28:20Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_rome_acc_01",
|
||||||
"id": "zyla_rome_acc_01",
|
"id": "zyla_rome_acc_01",
|
||||||
"uuid": "zyla_rome_acc_01",
|
"uuid": "zyla_rome_acc_01",
|
||||||
"type": "ACCIDENT",
|
"type": "ACCIDENT",
|
||||||
|
|
@ -53,14 +64,18 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1791664200000,
|
"pubMillis": 1791664200000,
|
||||||
"reportRating": 2,
|
"reportRating": 2,
|
||||||
|
"alert_confidence": 3,
|
||||||
|
"alert_reliability": 6,
|
||||||
"confidence": 3,
|
"confidence": 3,
|
||||||
"reliability": 6,
|
"reliability": 6,
|
||||||
|
"description": "Fender bender near Oostanaula bridge",
|
||||||
"reportDescription": "Fender bender near Oostanaula bridge",
|
"reportDescription": "Fender bender near Oostanaula bridge",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T20:30:00Z"
|
"publish_datetime_utc": "2026-10-10T20:30:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_rome_clo_01",
|
||||||
"id": "zyla_rome_clo_01",
|
"id": "zyla_rome_clo_01",
|
||||||
"uuid": "zyla_rome_clo_01",
|
"uuid": "zyla_rome_clo_01",
|
||||||
"type": "ROAD_CLOSED",
|
"type": "ROAD_CLOSED",
|
||||||
|
|
@ -73,14 +88,18 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1791663000000,
|
"pubMillis": 1791663000000,
|
||||||
"reportRating": 5,
|
"reportRating": 5,
|
||||||
|
"alert_confidence": 5,
|
||||||
|
"alert_reliability": 9,
|
||||||
"confidence": 5,
|
"confidence": 5,
|
||||||
"reliability": 9,
|
"reliability": 9,
|
||||||
|
"description": "Utility work road closure between 3rd and 4th Ave",
|
||||||
"reportDescription": "Utility work road closure between 3rd and 4th Ave",
|
"reportDescription": "Utility work road closure between 3rd and 4th Ave",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T20:10:00Z"
|
"publish_datetime_utc": "2026-10-10T20:10:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_rome_stale_01",
|
||||||
"id": "zyla_rome_stale_01",
|
"id": "zyla_rome_stale_01",
|
||||||
"uuid": "zyla_rome_stale_01",
|
"uuid": "zyla_rome_stale_01",
|
||||||
"type": "POLICE",
|
"type": "POLICE",
|
||||||
|
|
@ -93,14 +112,15 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1791600000000,
|
"pubMillis": 1791600000000,
|
||||||
"reportRating": 1,
|
"reportRating": 1,
|
||||||
"confidence": 1,
|
"alert_confidence": 1,
|
||||||
"reliability": 2,
|
"alert_reliability": 2,
|
||||||
"reportDescription": "Old radar report from 18 hours ago",
|
"description": "Old radar report from 18 hours ago",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T02:40:00Z"
|
"publish_datetime_utc": "2026-10-10T02:40:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_rome_future_01",
|
||||||
"id": "zyla_rome_future_01",
|
"id": "zyla_rome_future_01",
|
||||||
"uuid": "zyla_rome_future_01",
|
"uuid": "zyla_rome_future_01",
|
||||||
"type": "HAZARD",
|
"type": "HAZARD",
|
||||||
|
|
@ -113,14 +133,15 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1854748800000,
|
"pubMillis": 1854748800000,
|
||||||
"reportRating": 3,
|
"reportRating": 3,
|
||||||
"confidence": 3,
|
"alert_confidence": 3,
|
||||||
"reliability": 5,
|
"alert_reliability": 5,
|
||||||
"reportDescription": "Corrupted future timestamp record",
|
"description": "Corrupted future timestamp record",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2028-10-10T00:00:00Z"
|
"publish_datetime_utc": "2028-10-10T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"alert_id": "zyla_rome_malformed_01",
|
||||||
"id": "zyla_rome_malformed_01",
|
"id": "zyla_rome_malformed_01",
|
||||||
"uuid": "zyla_rome_malformed_01",
|
"uuid": "zyla_rome_malformed_01",
|
||||||
"type": "ACCIDENT",
|
"type": "ACCIDENT",
|
||||||
|
|
@ -133,15 +154,16 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1791664000000,
|
"pubMillis": 1791664000000,
|
||||||
"reportRating": 1,
|
"reportRating": 1,
|
||||||
"confidence": 1,
|
"alert_confidence": 1,
|
||||||
"reliability": 1,
|
"alert_reliability": 1,
|
||||||
"reportDescription": "Invalid out-of-bounds coordinates",
|
"description": "Invalid out-of-bounds coordinates",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "zyla_rome_dup_01",
|
"alert_id": "zyla_rome_pol_01",
|
||||||
|
"id": "zyla_rome_pol_01",
|
||||||
"uuid": "zyla_rome_pol_01",
|
"uuid": "zyla_rome_pol_01",
|
||||||
"type": "POLICE",
|
"type": "POLICE",
|
||||||
"subtype": "POLICE_VISIBLE",
|
"subtype": "POLICE_VISIBLE",
|
||||||
|
|
@ -153,9 +175,9 @@
|
||||||
},
|
},
|
||||||
"pubMillis": 1791664000000,
|
"pubMillis": 1791664000000,
|
||||||
"reportRating": 4,
|
"reportRating": 4,
|
||||||
"confidence": 5,
|
"alert_confidence": 0,
|
||||||
"reliability": 8,
|
"alert_reliability": 0,
|
||||||
"reportDescription": "Duplicate report with identical uuid",
|
"description": "Duplicate report with identical uuid",
|
||||||
"provider": "Waze",
|
"provider": "Waze",
|
||||||
"provider_id": "4814",
|
"provider_id": "4814",
|
||||||
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
||||||
|
|
@ -163,3 +185,4 @@
|
||||||
],
|
],
|
||||||
"jams": []
|
"jams": []
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -13,9 +14,16 @@ PACKAGES_DIR = TEST_DIR.parent / ".python-packages"
|
||||||
sys.path.insert(0, str(APP_DIR))
|
sys.path.insert(0, str(APP_DIR))
|
||||||
sys.path.insert(0, str(PACKAGES_DIR))
|
sys.path.insert(0, str(PACKAGES_DIR))
|
||||||
|
|
||||||
from road_alerts.cache import CircuitBreaker, GeographicCellCache
|
from fastapi import HTTPException
|
||||||
|
from road_alerts.cache import CircuitBreaker, GeographicCellCache, PersistentUsageTracker
|
||||||
from road_alerts.ga511_adapter import Georgia511Adapter
|
from road_alerts.ga511_adapter import Georgia511Adapter
|
||||||
from road_alerts.models import NormalizedAlert
|
from road_alerts.models import NormalizedAlert
|
||||||
|
from road_alerts.router import (
|
||||||
|
validate_bounding_box,
|
||||||
|
validate_fixture_override,
|
||||||
|
verify_admin_auth,
|
||||||
|
verify_gateway_or_user_auth,
|
||||||
|
)
|
||||||
from road_alerts.zyla_adapter import ZylaWazeAdapter
|
from road_alerts.zyla_adapter import ZylaWazeAdapter
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -29,15 +37,18 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
||||||
"""
|
"""
|
||||||
Tests parsing the sanitized Rome, GA test fixture:
|
Tests parsing the sanitized Rome, GA test fixture:
|
||||||
- Validates police, hazard, accident, and road closure normalization.
|
- Validates police, hazard, accident, and road closure normalization.
|
||||||
|
- Validates data.alerts actual response wrapper.
|
||||||
|
- Verifies honest 0.0 confidence and 0.0 reliability without synthetic inflation.
|
||||||
- Verifies rejection of stale, future-dated, and out-of-bounds coordinates.
|
- Verifies rejection of stale, future-dated, and out-of-bounds coordinates.
|
||||||
- Verifies deduplication of duplicate alert IDs.
|
- Verifies deduplication of duplicate alert IDs.
|
||||||
"""
|
"""
|
||||||
payload = self.adapter.load_fixture("zyla_rome_sample.json")
|
payload = self.adapter.load_fixture("zyla_rome_sample.json")
|
||||||
self.assertGreater(len(payload.get("alerts", [])), 0)
|
raw_list = payload.get("data", {}).get("alerts", [])
|
||||||
|
self.assertGreater(len(raw_list), 0)
|
||||||
|
|
||||||
alerts = self.adapter.parse_payload(payload, now=self.ref_time)
|
alerts = self.adapter.parse_payload(payload, now=self.ref_time)
|
||||||
|
|
||||||
# Expected valid alerts: police, hazard, accident, road closure = 4 distinct alerts
|
# Expected valid alerts: police, hazard/debris, accident, road closure = 4 distinct alerts
|
||||||
self.assertEqual(len(alerts), 4)
|
self.assertEqual(len(alerts), 4)
|
||||||
|
|
||||||
categories = [a.category for a in alerts]
|
categories = [a.category for a in alerts]
|
||||||
|
|
@ -46,30 +57,37 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
||||||
self.assertIn("accident", categories)
|
self.assertIn("accident", categories)
|
||||||
self.assertIn("roadClosure", categories)
|
self.assertIn("roadClosure", categories)
|
||||||
|
|
||||||
# 1. Police alert details
|
# 1. Police alert details & honest zero confidence/reliability
|
||||||
police_alert = next(a for a in alerts if a.category == "police")
|
police_alert = next(a for a in alerts if a.category == "police")
|
||||||
self.assertEqual(police_alert.id, "zyla_4814_zyla_rome_pol_01")
|
self.assertEqual(police_alert.id, "zyla_4814_zyla_rome_pol_01")
|
||||||
self.assertEqual(police_alert.source, "Zyla Waze Data #4814")
|
self.assertEqual(police_alert.source, "Zyla Waze Data #4814")
|
||||||
self.assertEqual(police_alert.source_type, "licensed_external")
|
self.assertEqual(police_alert.source_type, "experimental_external")
|
||||||
self.assertEqual(police_alert.subtype, "police_visible")
|
self.assertEqual(police_alert.subtype, "police_visible")
|
||||||
self.assertAlmostEqual(police_alert.latitude, 34.2750)
|
self.assertAlmostEqual(police_alert.latitude, 34.2750)
|
||||||
self.assertAlmostEqual(police_alert.longitude, -85.1700)
|
self.assertAlmostEqual(police_alert.longitude, -85.1700)
|
||||||
self.assertEqual(police_alert.road_name, "Martha Berry Hwy")
|
self.assertEqual(police_alert.road_name, "Martha Berry Hwy")
|
||||||
self.assertEqual(police_alert.city, "Rome, GA")
|
self.assertEqual(police_alert.city, "Rome, GA")
|
||||||
self.assertGreaterEqual(police_alert.confidence, 0.8)
|
# Honest zero: alert_confidence: 0 -> 0.0, NOT invented 0.7 or clamped 0.1
|
||||||
|
self.assertEqual(police_alert.confidence, 0.0)
|
||||||
|
self.assertEqual(police_alert.reliability, 0.0)
|
||||||
|
|
||||||
# 2. Road closure details
|
# 2. Debris alert details
|
||||||
|
debris_alert = next(a for a in alerts if a.category == "debris")
|
||||||
|
self.assertEqual(debris_alert.confidence, 0.8) # 4 / 5.0 = 0.8
|
||||||
|
self.assertEqual(debris_alert.reliability, 7.0)
|
||||||
|
|
||||||
|
# 3. Road closure details
|
||||||
closure_alert = next(a for a in alerts if a.category == "roadClosure")
|
closure_alert = next(a for a in alerts if a.category == "roadClosure")
|
||||||
self.assertEqual(closure_alert.road_name, "Broad St")
|
self.assertEqual(closure_alert.road_name, "Broad St")
|
||||||
self.assertEqual(closure_alert.subtype, "construction")
|
self.assertEqual(closure_alert.subtype, "construction")
|
||||||
|
|
||||||
# 3. Verify stale, future, and malformed records were filtered
|
# 4. Verify stale, future, and malformed records were filtered
|
||||||
alert_ids = [a.id for a in alerts]
|
alert_ids = [a.id for a in alerts]
|
||||||
self.assertNotIn("zyla_4814_zyla_rome_stale_01", alert_ids)
|
self.assertNotIn("zyla_4814_zyla_rome_stale_01", alert_ids)
|
||||||
self.assertNotIn("zyla_4814_zyla_rome_future_01", alert_ids)
|
self.assertNotIn("zyla_4814_zyla_rome_future_01", alert_ids)
|
||||||
self.assertNotIn("zyla_4814_zyla_rome_malformed_01", alert_ids)
|
self.assertNotIn("zyla_4814_zyla_rome_malformed_01", alert_ids)
|
||||||
|
|
||||||
# 4. Verify duplicate was filtered (only one alert with id ending in pol_01)
|
# 5. Verify duplicate was filtered (only one alert with id ending in pol_01)
|
||||||
pol_matches = [a for a in alerts if a.id == "zyla_4814_zyla_rome_pol_01"]
|
pol_matches = [a for a in alerts if a.id == "zyla_4814_zyla_rome_pol_01"]
|
||||||
self.assertEqual(len(pol_matches), 1)
|
self.assertEqual(len(pol_matches), 1)
|
||||||
|
|
||||||
|
|
@ -110,6 +128,41 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
||||||
self.assertEqual(pothole.subtype, "pothole")
|
self.assertEqual(pothole.subtype, "pothole")
|
||||||
self.assertEqual(pothole.road_name, "Peachtree Rd NE")
|
self.assertEqual(pothole.road_name, "Peachtree Rd NE")
|
||||||
|
|
||||||
|
def test_honest_confidence_and_reliability_no_invented_defaults(self):
|
||||||
|
"""
|
||||||
|
Tests that confidence and reliability fields are handled honestly:
|
||||||
|
- 0 values produce 0.0 (never clamped to 0.1 or defaulted to 0.7).
|
||||||
|
- Missing values produce None (never defaulted to 0.7).
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
recent_millis = int(now.timestamp() * 1000)
|
||||||
|
|
||||||
|
# 1. Zero confidence and zero reliability
|
||||||
|
raw_zero = {
|
||||||
|
"alert_id": "test_zero_1",
|
||||||
|
"type": "POLICE",
|
||||||
|
"location": {"x": -85.17, "y": 34.25},
|
||||||
|
"pubMillis": recent_millis,
|
||||||
|
"alert_confidence": 0,
|
||||||
|
"alert_reliability": 0,
|
||||||
|
}
|
||||||
|
res_zero = self.adapter.normalize_alert(raw_zero, now)
|
||||||
|
self.assertIsNotNone(res_zero)
|
||||||
|
self.assertEqual(res_zero.confidence, 0.0)
|
||||||
|
self.assertEqual(res_zero.reliability, 0.0)
|
||||||
|
|
||||||
|
# 2. Missing confidence and missing reliability -> None
|
||||||
|
raw_missing = {
|
||||||
|
"alert_id": "test_missing_1",
|
||||||
|
"type": "HAZARD",
|
||||||
|
"location": {"x": -85.17, "y": 34.25},
|
||||||
|
"pubMillis": recent_millis,
|
||||||
|
}
|
||||||
|
res_missing = self.adapter.normalize_alert(raw_missing, now)
|
||||||
|
self.assertIsNotNone(res_missing)
|
||||||
|
self.assertIsNone(res_missing.confidence)
|
||||||
|
self.assertIsNone(res_missing.reliability)
|
||||||
|
|
||||||
def test_granular_freshness_policies(self):
|
def test_granular_freshness_policies(self):
|
||||||
"""
|
"""
|
||||||
Verifies that freshness policies enforce category/subtype TTLs:
|
Verifies that freshness policies enforce category/subtype TTLs:
|
||||||
|
|
@ -122,7 +175,7 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
||||||
|
|
||||||
# 1. Police speed trap reported 35 mins ago -> expired
|
# 1. Police speed trap reported 35 mins ago -> expired
|
||||||
raw_police = {
|
raw_police = {
|
||||||
"uuid": "trap_test_1",
|
"alert_id": "trap_test_1",
|
||||||
"type": "POLICE",
|
"type": "POLICE",
|
||||||
"subtype": "POLICE_HIDING",
|
"subtype": "POLICE_HIDING",
|
||||||
"location": {"x": -85.17, "y": 34.25},
|
"location": {"x": -85.17, "y": 34.25},
|
||||||
|
|
@ -133,7 +186,7 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
||||||
|
|
||||||
# 2. Road closure reported 4 hours ago -> still valid (24h TTL)
|
# 2. Road closure reported 4 hours ago -> still valid (24h TTL)
|
||||||
raw_closure = {
|
raw_closure = {
|
||||||
"uuid": "closure_test_1",
|
"alert_id": "closure_test_1",
|
||||||
"type": "ROAD_CLOSED",
|
"type": "ROAD_CLOSED",
|
||||||
"subtype": "ROAD_CLOSED_CONSTRUCTION",
|
"subtype": "ROAD_CLOSED_CONSTRUCTION",
|
||||||
"location": {"x": -85.17, "y": 34.25},
|
"location": {"x": -85.17, "y": 34.25},
|
||||||
|
|
@ -143,36 +196,62 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
||||||
self.assertIsNotNone(res_closure)
|
self.assertIsNotNone(res_closure)
|
||||||
self.assertEqual(res_closure.category, "roadClosure")
|
self.assertEqual(res_closure.category, "roadClosure")
|
||||||
|
|
||||||
def test_zyla_disabled_by_policy_never_requests(self):
|
def test_persistent_usage_tracker_survives_process_restart(self):
|
||||||
"""
|
"""
|
||||||
Ensures that when Zyla is disabled or API key is absent:
|
Proves that PersistentUsageTracker persists usage to disk across restarts
|
||||||
- No outbound requests are made.
|
and enforces monthly budget limits.
|
||||||
- fetch_alerts returns an empty list cleanly.
|
|
||||||
- Status accurately reports disabled_by_policy or auth_missing.
|
|
||||||
"""
|
"""
|
||||||
# Disabled adapter
|
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
|
||||||
disabled_adapter = ZylaWazeAdapter(api_key="valid-key", enabled=False)
|
tmp_path = Path(tmp.name)
|
||||||
self.assertEqual(disabled_adapter.get_health().status, "disabled_by_policy")
|
|
||||||
res = disabled_adapter.fetch_alerts(34.0, -85.0, 34.5, -84.5)
|
|
||||||
self.assertEqual(res, [])
|
|
||||||
self.assertEqual(disabled_adapter.request_count, 0)
|
|
||||||
|
|
||||||
# Missing key adapter
|
try:
|
||||||
missing_key_adapter = ZylaWazeAdapter(api_key="", enabled=True)
|
# 1. Process 1: Record 3 requests against a limit of 5
|
||||||
self.assertEqual(missing_key_adapter.get_health().status, "auth_missing")
|
tracker1 = PersistentUsageTracker(
|
||||||
res2 = missing_key_adapter.fetch_alerts(34.0, -85.0, 34.5, -84.5)
|
provider_id="test_provider",
|
||||||
self.assertEqual(res2, [])
|
monthly_budget_limit=5,
|
||||||
self.assertEqual(missing_key_adapter.request_count, 0)
|
cost_per_request_usd=0.01,
|
||||||
|
state_file=tmp_path,
|
||||||
|
)
|
||||||
|
self.assertTrue(tracker1.can_request())
|
||||||
|
tracker1.record_request()
|
||||||
|
tracker1.record_request()
|
||||||
|
tracker1.record_request()
|
||||||
|
self.assertEqual(tracker1.requests_used, 3)
|
||||||
|
self.assertEqual(tracker1.estimated_cost_usd, 0.03)
|
||||||
|
|
||||||
|
# 2. Process 2 (simulated restart): Re-instantiate from the same state file
|
||||||
|
tracker2 = PersistentUsageTracker(
|
||||||
|
provider_id="test_provider",
|
||||||
|
monthly_budget_limit=5,
|
||||||
|
cost_per_request_usd=0.01,
|
||||||
|
state_file=tmp_path,
|
||||||
|
)
|
||||||
|
self.assertEqual(tracker2.requests_used, 3)
|
||||||
|
self.assertTrue(tracker2.can_request())
|
||||||
|
|
||||||
|
# Fill up to limit
|
||||||
|
tracker2.record_request()
|
||||||
|
tracker2.record_request()
|
||||||
|
self.assertEqual(tracker2.requests_used, 5)
|
||||||
|
self.assertFalse(tracker2.can_request())
|
||||||
|
|
||||||
|
# 3. Process 3: Re-instantiate after limit reached -> remains blocked!
|
||||||
|
tracker3 = PersistentUsageTracker(
|
||||||
|
provider_id="test_provider",
|
||||||
|
monthly_budget_limit=5,
|
||||||
|
cost_per_request_usd=0.01,
|
||||||
|
state_file=tmp_path,
|
||||||
|
)
|
||||||
|
self.assertEqual(tracker3.requests_used, 5)
|
||||||
|
self.assertFalse(tracker3.can_request())
|
||||||
|
finally:
|
||||||
|
if tmp_path.exists():
|
||||||
|
tmp_path.unlink()
|
||||||
|
|
||||||
def test_circuit_breaker_behavior_and_recovery(self):
|
def test_circuit_breaker_behavior_and_recovery(self):
|
||||||
"""
|
|
||||||
Tests that circuit breaker trips upon failures or 429 rate limiting,
|
|
||||||
protecting resources, and recovers after timeout.
|
|
||||||
"""
|
|
||||||
cb = CircuitBreaker(failure_threshold=3, recovery_timeout_sec=0.1)
|
cb = CircuitBreaker(failure_threshold=3, recovery_timeout_sec=0.1)
|
||||||
self.assertTrue(cb.can_request())
|
self.assertTrue(cb.can_request())
|
||||||
|
|
||||||
# Simulate failures
|
|
||||||
cb.record_failure("500 Server Error")
|
cb.record_failure("500 Server Error")
|
||||||
self.assertEqual(cb.state, "CLOSED")
|
self.assertEqual(cb.state, "CLOSED")
|
||||||
cb.record_failure("503 Service Unavailable")
|
cb.record_failure("503 Service Unavailable")
|
||||||
|
|
@ -181,36 +260,26 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
||||||
self.assertEqual(cb.state, "OPEN")
|
self.assertEqual(cb.state, "OPEN")
|
||||||
self.assertFalse(cb.can_request())
|
self.assertFalse(cb.can_request())
|
||||||
|
|
||||||
# Immediate trip on 429 rate limit
|
|
||||||
cb_rate = CircuitBreaker()
|
cb_rate = CircuitBreaker()
|
||||||
cb_rate.record_failure("429 Too Many Requests", is_rate_limit=True)
|
cb_rate.record_failure("429 Too Many Requests", is_rate_limit=True)
|
||||||
self.assertEqual(cb_rate.state, "OPEN")
|
self.assertEqual(cb_rate.state, "OPEN")
|
||||||
self.assertFalse(cb_rate_rate := cb_rate.can_request())
|
self.assertFalse(cb_rate.can_request())
|
||||||
|
|
||||||
def test_geographic_cell_cache(self):
|
def test_geographic_cell_cache(self):
|
||||||
"""
|
|
||||||
Tests that nearby / quantized bounding boxes hit cache to conserve budget.
|
|
||||||
"""
|
|
||||||
cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=60.0)
|
cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=60.0)
|
||||||
|
|
||||||
sample_data = ["alert_1", "alert_2"]
|
sample_data = ["alert_1", "alert_2"]
|
||||||
cache.set(34.2501, -85.1701, 34.2799, -85.1501, sample_data)
|
cache.set(34.2501, -85.1701, 34.2799, -85.1501, sample_data)
|
||||||
|
|
||||||
# Query slightly shifted box that falls in the same quantized grid
|
|
||||||
hit = cache.get(34.2520, -85.1710, 34.2780, -85.1520)
|
hit = cache.get(34.2520, -85.1710, 34.2780, -85.1520)
|
||||||
self.assertEqual(hit, sample_data)
|
self.assertEqual(hit, sample_data)
|
||||||
self.assertEqual(cache.hits, 1)
|
self.assertEqual(cache.hits, 1)
|
||||||
|
|
||||||
# Query distinctly different cell
|
|
||||||
miss = cache.get(33.7500, -84.3800, 33.7900, -84.3500)
|
miss = cache.get(33.7500, -84.3800, 33.7900, -84.3500)
|
||||||
self.assertIsNone(miss)
|
self.assertIsNone(miss)
|
||||||
self.assertEqual(cache.misses, 1)
|
self.assertEqual(cache.misses, 1)
|
||||||
self.assertEqual(cache.hit_ratio, 0.5)
|
|
||||||
|
|
||||||
def test_georgia_511_adapter(self):
|
def test_georgia_511_adapter(self):
|
||||||
"""
|
|
||||||
Tests Georgia 511 official event normalization and health.
|
|
||||||
"""
|
|
||||||
ga_adapter = Georgia511Adapter(api_key="ga-key", enabled=True)
|
ga_adapter = Georgia511Adapter(api_key="ga-key", enabled=True)
|
||||||
ga_adapter.fixture_mode = True
|
ga_adapter.fixture_mode = True
|
||||||
|
|
||||||
|
|
@ -227,25 +296,102 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
||||||
self.assertAlmostEqual(first.confidence, 0.95)
|
self.assertAlmostEqual(first.confidence, 0.95)
|
||||||
|
|
||||||
def test_bounding_box_limit_enforcement(self):
|
def test_bounding_box_limit_enforcement(self):
|
||||||
"""
|
|
||||||
Verifies that geographic queries exceeding 1.0 degree span are rejected.
|
|
||||||
"""
|
|
||||||
from road_alerts.router import validate_bounding_box
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
# Valid box within 1.0 degree span
|
|
||||||
validate_bounding_box(34.2, -85.2, 34.8, -84.8)
|
validate_bounding_box(34.2, -85.2, 34.8, -84.8)
|
||||||
|
|
||||||
# Inverted coordinates
|
|
||||||
with self.assertRaises(HTTPException) as ctx:
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
validate_bounding_box(35.0, -85.0, 34.0, -84.0)
|
validate_bounding_box(35.0, -85.0, 34.0, -84.0)
|
||||||
self.assertEqual(ctx.exception.status_code, 400)
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
|
||||||
# Overly broad box exceeding 1.0 degree
|
|
||||||
with self.assertRaises(HTTPException) as ctx:
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
validate_bounding_box(30.0, -90.0, 35.0, -80.0)
|
validate_bounding_box(30.0, -90.0, 35.0, -80.0)
|
||||||
self.assertEqual(ctx.exception.status_code, 400)
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
self.assertIn("maximum allowed geographic span", ctx.exception.detail)
|
|
||||||
|
|
||||||
|
class TestSecurityAndAuthGates(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.orig_env = dict(os.environ)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
os.environ.clear()
|
||||||
|
os.environ.update(self.orig_env)
|
||||||
|
|
||||||
|
def test_gateway_auth_fails_closed_when_unauthenticated(self):
|
||||||
|
"""
|
||||||
|
P0: Proves verify_gateway_or_user_auth fails closed (HTTP 401)
|
||||||
|
when no valid token or gateway key is provided.
|
||||||
|
"""
|
||||||
|
# Ensure secret is NOT set
|
||||||
|
os.environ.pop("MOTORSIGNAL_GATEWAY_SECRET", None)
|
||||||
|
os.environ["TESTING"] = "false"
|
||||||
|
|
||||||
|
# Missing both authorization and gateway key -> 401
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
verify_gateway_or_user_auth(authorization=None, x_gateway_key=None)
|
||||||
|
self.assertEqual(ctx.exception.status_code, 401)
|
||||||
|
|
||||||
|
# Invalid bearer token -> 401
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
verify_gateway_or_user_auth(authorization="Bearer invalid_garbage_token", x_gateway_key=None)
|
||||||
|
self.assertEqual(ctx.exception.status_code, 401)
|
||||||
|
|
||||||
|
# Valid gateway secret -> 200/True when configured
|
||||||
|
os.environ["MOTORSIGNAL_GATEWAY_SECRET"] = "correct_secret_999"
|
||||||
|
self.assertTrue(verify_gateway_or_user_auth(authorization=None, x_gateway_key="correct_secret_999"))
|
||||||
|
|
||||||
|
# Wrong gateway secret -> 401
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
verify_gateway_or_user_auth(authorization=None, x_gateway_key="wrong_secret")
|
||||||
|
self.assertEqual(ctx.exception.status_code, 401)
|
||||||
|
|
||||||
|
def test_admin_auth_fails_closed_when_unauthorized(self):
|
||||||
|
"""
|
||||||
|
P0: Proves verify_admin_auth fails closed (HTTP 403)
|
||||||
|
when admin credentials or roles are absent.
|
||||||
|
"""
|
||||||
|
os.environ.pop("MOTORSIGNAL_ADMIN_KEY", None)
|
||||||
|
os.environ["TESTING"] = "false"
|
||||||
|
|
||||||
|
# Missing all auth -> 403
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
verify_admin_auth(authorization=None, x_admin_key=None)
|
||||||
|
self.assertEqual(ctx.exception.status_code, 403)
|
||||||
|
|
||||||
|
# Configured admin key matches -> True
|
||||||
|
os.environ["MOTORSIGNAL_ADMIN_KEY"] = "super_admin_pass_123"
|
||||||
|
self.assertTrue(verify_admin_auth(authorization=None, x_admin_key="super_admin_pass_123"))
|
||||||
|
|
||||||
|
# Wrong admin key -> 403
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
verify_admin_auth(authorization=None, x_admin_key="bad_admin_pass")
|
||||||
|
self.assertEqual(ctx.exception.status_code, 403)
|
||||||
|
|
||||||
|
def test_fixture_override_security_restrictions(self):
|
||||||
|
"""
|
||||||
|
P1: Proves fixture_override rejects path traversal and unknown fixtures,
|
||||||
|
and forbids ordinary callers in production.
|
||||||
|
"""
|
||||||
|
os.environ["TESTING"] = "false"
|
||||||
|
os.environ["ENABLE_FIXTURE_OVERRIDE"] = "false"
|
||||||
|
os.environ.pop("MOTORSIGNAL_ADMIN_KEY", None)
|
||||||
|
|
||||||
|
# Disallowed / path traversal fixture -> 400
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
validate_fixture_override("../../etc/passwd")
|
||||||
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
|
||||||
|
# Disallowed arbitrary fixture -> 400
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
validate_fixture_override("malicious_data.json")
|
||||||
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
|
||||||
|
# Allowed fixture in production without admin key -> 403
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
validate_fixture_override("zyla_rome_sample.json")
|
||||||
|
self.assertEqual(ctx.exception.status_code, 403)
|
||||||
|
|
||||||
|
# In test mode -> allowed
|
||||||
|
os.environ["TESTING"] = "true"
|
||||||
|
self.assertEqual(validate_fixture_override("zyla_rome_sample.json"), "zyla_rome_sample.json")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -1050,7 +1050,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
iconColor: Colors.amberAccent,
|
iconColor: Colors.amberAccent,
|
||||||
title: 'Zyla Waze Data #4814 (Backend Gateway)',
|
title: 'Zyla Waze Data #4814 (Backend Gateway)',
|
||||||
subtitle:
|
subtitle:
|
||||||
'Commercial licensed Waze Data feed via MotorSignal API backend. Disabled by default; client never holds credentials.',
|
'Experimental Zyla Waze Data #4814 feed via MotorSignal API backend. Disabled by default; client never holds credentials.',
|
||||||
status: zylaStatus,
|
status: zylaStatus,
|
||||||
isEnabled: zylaEnabled,
|
isEnabled: zylaEnabled,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,13 @@ import 'waze_road_alert_provider.dart';
|
||||||
import 'waze_osint_road_alert_provider.dart';
|
import 'waze_osint_road_alert_provider.dart';
|
||||||
import 'zyla_road_alert_provider.dart';
|
import 'zyla_road_alert_provider.dart';
|
||||||
import 'ga511_road_alert_provider.dart';
|
import 'ga511_road_alert_provider.dart';
|
||||||
|
import '../auth/auth_providers.dart';
|
||||||
|
|
||||||
final zylaProviderInstance = Provider<ZylaRoadAlertProvider>((ref) {
|
final zylaProviderInstance = Provider<ZylaRoadAlertProvider>((ref) {
|
||||||
return ZylaRoadAlertProvider();
|
final authService = ref.watch(authServiceProvider);
|
||||||
|
return ZylaRoadAlertProvider(
|
||||||
|
getAuthToken: () => authService.getIdToken(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final ga511ProviderInstance = Provider<Georgia511RoadAlertProvider>((ref) {
|
final ga511ProviderInstance = Provider<Georgia511RoadAlertProvider>((ref) {
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,12 @@ import 'road_alert_provider_interface.dart';
|
||||||
class ZylaRoadAlertProvider implements RoadAlertProvider {
|
class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
final String backendBaseUrl;
|
final String backendBaseUrl;
|
||||||
|
final Future<String?> Function()? getAuthToken;
|
||||||
|
|
||||||
ZylaRoadAlertProvider({
|
ZylaRoadAlertProvider({
|
||||||
http.Client? client,
|
http.Client? client,
|
||||||
String? backendBaseUrl,
|
String? backendBaseUrl,
|
||||||
|
this.getAuthToken,
|
||||||
}) : _client = client ?? http.Client(),
|
}) : _client = client ?? http.Client(),
|
||||||
backendBaseUrl = backendBaseUrl ??
|
backendBaseUrl = backendBaseUrl ??
|
||||||
const String.fromEnvironment(
|
const String.fromEnvironment(
|
||||||
|
|
@ -43,11 +45,35 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
RoadAlertCategory.other,
|
RoadAlertCategory.other,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Future<Map<String, String>> _buildHeaders({String? apiKey}) async {
|
||||||
|
final headers = <String, String>{
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
if (getAuthToken != null) {
|
||||||
|
try {
|
||||||
|
final token = await getAuthToken!();
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
headers['Authorization'] = 'Bearer $token';
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ZylaRoadAlertProvider] Error resolving auth token: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (apiKey != null && apiKey.isNotEmpty) {
|
||||||
|
headers['Authorization'] = 'Bearer $apiKey';
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||||
try {
|
try {
|
||||||
|
final headers = await _buildHeaders(apiKey: apiKey);
|
||||||
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
|
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
|
||||||
final res = await _client.get(uri).timeout(const Duration(seconds: 4));
|
final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4));
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
final data = json.decode(res.body);
|
final data = json.decode(res.body);
|
||||||
final providers = data['providers'] as List<dynamic>? ?? [];
|
final providers = data['providers'] as List<dynamic>? ?? [];
|
||||||
|
|
@ -62,6 +88,9 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
}
|
}
|
||||||
return RoadAlertProviderStatus.connected;
|
return RoadAlertProviderStatus.connected;
|
||||||
}
|
}
|
||||||
|
if (res.statusCode == 401 || res.statusCode == 403) {
|
||||||
|
return RoadAlertProviderStatus.authError;
|
||||||
|
}
|
||||||
return RoadAlertProviderStatus.networkError;
|
return RoadAlertProviderStatus.networkError;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
|
|
@ -103,7 +132,14 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _client.get(uri).timeout(const Duration(seconds: 5));
|
final headers = await _buildHeaders(apiKey: apiKey);
|
||||||
|
final response = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 5));
|
||||||
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ZylaRoadAlertProvider] Backend rejected request with auth status ${response.statusCode}');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[ZylaRoadAlertProvider] Backend returned status ${response.statusCode}');
|
debugPrint('[ZylaRoadAlertProvider] Backend returned status ${response.statusCode}');
|
||||||
|
|
@ -157,7 +193,7 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
final expiresAtStr = raw['expires_at_utc']?.toString();
|
final expiresAtStr = raw['expires_at_utc']?.toString();
|
||||||
final expiresAt = expiresAtStr != null ? DateTime.tryParse(expiresAtStr) : null;
|
final expiresAt = expiresAtStr != null ? DateTime.tryParse(expiresAtStr) : null;
|
||||||
|
|
||||||
final confidence = (raw['confidence'] as num?)?.toDouble() ?? 0.7;
|
final confidence = (raw['confidence'] as num?)?.toDouble();
|
||||||
|
|
||||||
// Approximate distance
|
// Approximate distance
|
||||||
final dLat = (lat - userLat) * 111320.0;
|
final dLat = (lat - userLat) * 111320.0;
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,131 @@ void main() {
|
||||||
final status = await provider.testConnection('');
|
final status = await provider.testConnection('');
|
||||||
expect(status, equals(RoadAlertProviderStatus.connected));
|
expect(status, equals(RoadAlertProviderStatus.connected));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('transmits Authorization bearer token when getAuthToken is configured', () async {
|
||||||
|
String? capturedAuthHeader;
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
capturedAuthHeader = request.headers['authorization'] ?? request.headers['Authorization'];
|
||||||
|
return http.Response(
|
||||||
|
json.encode({
|
||||||
|
'alerts': [],
|
||||||
|
'count': 0,
|
||||||
|
'cached': false,
|
||||||
|
'attribution': 'Waze / Zyla Labs #4814'
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = ZylaRoadAlertProvider(
|
||||||
|
client: mockClient,
|
||||||
|
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||||
|
getAuthToken: () async => 'firebase-test-id-token-12345',
|
||||||
|
);
|
||||||
|
|
||||||
|
await provider.getAlerts(
|
||||||
|
latitude: 34.2560,
|
||||||
|
longitude: -85.1650,
|
||||||
|
radiusMeters: 5000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedAuthHeader, equals('Bearer firebase-test-id-token-12345'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('testConnection returns authError on 401 Unauthorized', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
json.encode({'detail': 'Missing authentication credentials'}),
|
||||||
|
401,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = ZylaRoadAlertProvider(
|
||||||
|
client: mockClient,
|
||||||
|
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||||
|
);
|
||||||
|
|
||||||
|
final status = await provider.testConnection('');
|
||||||
|
expect(status, equals(RoadAlertProviderStatus.authError));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAlerts handles 401 Unauthorized gracefully by returning empty list', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
json.encode({'detail': 'Invalid authentication token'}),
|
||||||
|
401,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = ZylaRoadAlertProvider(
|
||||||
|
client: mockClient,
|
||||||
|
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||||
|
);
|
||||||
|
|
||||||
|
final alerts = await provider.getAlerts(
|
||||||
|
latitude: 34.2560,
|
||||||
|
longitude: -85.1650,
|
||||||
|
radiusMeters: 5000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(alerts, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves honest 0.0 confidence and null confidence without synthetic 0.7 inflation', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
final responseBody = json.encode({
|
||||||
|
'provider_id': 'zyla_waze_4814',
|
||||||
|
'source': 'Zyla Waze Data #4814',
|
||||||
|
'alerts': [
|
||||||
|
{
|
||||||
|
'id': 'zyla_zero_conf',
|
||||||
|
'category': 'police',
|
||||||
|
'subtype': 'POLICE_VISIBLE',
|
||||||
|
'latitude': 34.2560,
|
||||||
|
'longitude': -85.1650,
|
||||||
|
'road_name': 'Martha Berry Hwy',
|
||||||
|
'description': 'Radar Trap',
|
||||||
|
'reported_at_utc': '2026-10-10T12:00:00Z',
|
||||||
|
'confidence': 0.0,
|
||||||
|
'source': 'Zyla Waze Data #4814'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'zyla_null_conf',
|
||||||
|
'category': 'hazard',
|
||||||
|
'subtype': 'HAZARD_ON_ROAD',
|
||||||
|
'latitude': 34.2570,
|
||||||
|
'longitude': -85.1660,
|
||||||
|
'road_name': 'Turner McCall Blvd',
|
||||||
|
'description': 'Road Obstacle',
|
||||||
|
'reported_at_utc': '2026-10-10T12:01:00Z',
|
||||||
|
'confidence': null,
|
||||||
|
'source': 'Zyla Waze Data #4814'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'count': 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return http.Response(responseBody, 200, headers: {'content-type': 'application/json'});
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = ZylaRoadAlertProvider(
|
||||||
|
client: mockClient,
|
||||||
|
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||||
|
);
|
||||||
|
|
||||||
|
final alerts = await provider.getAlerts(
|
||||||
|
latitude: 34.2560,
|
||||||
|
longitude: -85.1650,
|
||||||
|
radiusMeters: 5000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(alerts.length, equals(2));
|
||||||
|
expect(alerts[0].confidence, equals(0.0));
|
||||||
|
expect(alerts[1].confidence, isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('Georgia511RoadAlertProvider Tests', () {
|
group('Georgia511RoadAlertProvider Tests', () {
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ void main() {
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await open(tester);
|
await open(tester);
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
// Deliberately resolve after the original implementation disposed its ref.
|
// Deliberately resolve after the original implementation disposed its ref.
|
||||||
location.current.complete(
|
location.current.complete(
|
||||||
|
|
@ -160,7 +160,7 @@ void main() {
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await open(tester);
|
await open(tester);
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.tap(find.byIcon(Icons.close));
|
await tester.tap(find.byIcon(Icons.close));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -185,13 +185,13 @@ void main() {
|
||||||
|
|
||||||
testWidgets('repeated taps create one report', (tester) async {
|
testWidgets('repeated taps create one report', (tester) async {
|
||||||
await open(tester);
|
await open(tester);
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<ElevatedButton>(
|
.widget<ElevatedButton>(
|
||||||
find.widgetWithText(ElevatedButton, 'HAZARD / GRAVEL / DEBRIS'),
|
find.widgetWithText(ElevatedButton, 'Road Hazard'),
|
||||||
)
|
)
|
||||||
.onPressed,
|
.onPressed,
|
||||||
isNull,
|
isNull,
|
||||||
|
|
@ -219,7 +219,7 @@ void main() {
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await open(tester);
|
await open(tester);
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
location.current.completeError(const PermissionDeniedException('denied'));
|
location.current.completeError(const PermissionDeniedException('denied'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(alerts.reports, isEmpty);
|
expect(alerts.reports, isEmpty);
|
||||||
|
|
@ -232,7 +232,7 @@ void main() {
|
||||||
|
|
||||||
testWidgets('close cancels even before its exit animation finishes', (tester) async {
|
testWidgets('close cancels even before its exit animation finishes', (tester) async {
|
||||||
await open(tester);
|
await open(tester);
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
await tester.tap(find.byIcon(Icons.close));
|
await tester.tap(find.byIcon(Icons.close));
|
||||||
location.current.complete(_position());
|
location.current.complete(_position());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -242,13 +242,13 @@ void main() {
|
||||||
|
|
||||||
testWidgets('GPS timeout falls back and missing GPS permits retry', (tester) async {
|
testWidgets('GPS timeout falls back and missing GPS permits retry', (tester) async {
|
||||||
await open(tester);
|
await open(tester);
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
await tester.pump(const Duration(seconds: 5));
|
await tester.pump(const Duration(seconds: 5));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(alerts.reports, isEmpty);
|
expect(alerts.reports, isEmpty);
|
||||||
expect(find.text('Cannot report: GPS location unavailable.'), findsOneWidget);
|
expect(find.text('Cannot report: GPS location unavailable.'), findsOneWidget);
|
||||||
expect(tester.widget<ElevatedButton>(find.widgetWithText(
|
expect(tester.widget<ElevatedButton>(find.widgetWithText(
|
||||||
ElevatedButton, 'HAZARD / GRAVEL / DEBRIS')).onPressed, isNotNull);
|
ElevatedButton, 'Road Hazard')).onPressed, isNotNull);
|
||||||
location.current.complete(_position());
|
location.current.complete(_position());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(alerts.reports, isEmpty);
|
expect(alerts.reports, isEmpty);
|
||||||
|
|
@ -258,7 +258,7 @@ void main() {
|
||||||
final lastKnown = Completer<Position?>();
|
final lastKnown = Completer<Position?>();
|
||||||
location.lastKnown = () => lastKnown.future;
|
location.lastKnown = () => lastKnown.future;
|
||||||
await open(tester);
|
await open(tester);
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
location.current.completeError(StateError('GPS unavailable'));
|
location.current.completeError(StateError('GPS unavailable'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(seconds: 2));
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
|
@ -272,7 +272,7 @@ void main() {
|
||||||
testWidgets('confirmed cloud outcome has a persisted fake document', (tester) async {
|
testWidgets('confirmed cloud outcome has a persisted fake document', (tester) async {
|
||||||
final firestore = FakeFirebaseFirestore();
|
final firestore = FakeFirebaseFirestore();
|
||||||
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
location.current.complete(_position());
|
location.current.complete(_position());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(alerts.reports, hasLength(1));
|
expect(alerts.reports, hasLength(1));
|
||||||
|
|
@ -291,7 +291,7 @@ void main() {
|
||||||
}
|
}
|
||||||
''');
|
''');
|
||||||
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
location.current.complete(_position());
|
location.current.complete(_position());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(alerts.reports, hasLength(1));
|
expect(alerts.reports, hasLength(1));
|
||||||
|
|
@ -302,7 +302,7 @@ void main() {
|
||||||
testWidgets('pending acknowledgement returns locally without retrying write', (tester) async {
|
testWidgets('pending acknowledgement returns locally without retrying write', (tester) async {
|
||||||
final firestore = _DelayedFirestore();
|
final firestore = _DelayedFirestore();
|
||||||
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
location.current.complete(_position());
|
location.current.complete(_position());
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(seconds: 6));
|
await tester.pump(const Duration(seconds: 6));
|
||||||
|
|
@ -319,7 +319,7 @@ void main() {
|
||||||
testWidgets('removing provider scope during cloud write avoids disposed service', (tester) async {
|
testWidgets('removing provider scope during cloud write avoids disposed service', (tester) async {
|
||||||
final firestore = _DelayedFirestore();
|
final firestore = _DelayedFirestore();
|
||||||
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
await open(tester, service: SwarmRoadAlertService(firestore: firestore));
|
||||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
await tester.tap(find.text('Road Hazard'));
|
||||||
location.current.complete(_position());
|
location.current.complete(_position());
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(firestore.writes, 1);
|
expect(firestore.writes, 1);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue