This commit is contained in:
parent
625999245e
commit
9f73fd4cd4
|
|
@ -1,7 +1,9 @@
|
|||
import math
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class GeographicCellCache:
|
||||
|
|
@ -107,3 +109,98 @@ class CircuitBreaker:
|
|||
self.failure_count = 0
|
||||
self.state = "CLOSED"
|
||||
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):
|
||||
id: str = Field(description="Normalized unique alert identifier")
|
||||
source: str = Field(description="Attributed data source name")
|
||||
source_type: Literal["native_rider", "licensed_external", "official_dot"] = Field(
|
||||
description="Source classification"
|
||||
)
|
||||
source_type: Literal[
|
||||
"native_rider",
|
||||
"experimental_external",
|
||||
"licensed_external",
|
||||
"official_dot",
|
||||
] = Field(description="Source classification")
|
||||
category: Literal[
|
||||
"police",
|
||||
"accident",
|
||||
|
|
@ -29,12 +32,15 @@ class NormalizedAlert(BaseModel):
|
|||
reported_at_utc: str = Field(description="ISO 8601 UTC creation timestamp")
|
||||
fetched_at_utc: str = Field(description="ISO 8601 UTC ingest 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")
|
||||
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")
|
||||
|
||||
|
||||
|
||||
class AlertFeedResponse(BaseModel):
|
||||
success: bool
|
||||
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"),
|
||||
):
|
||||
"""
|
||||
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")
|
||||
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(
|
||||
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(
|
||||
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||
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")
|
||||
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(
|
||||
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(
|
||||
"/feed",
|
||||
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(
|
||||
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_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"),
|
||||
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.
|
||||
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)
|
||||
safe_fixture = validate_fixture_override(fixture_override, x_admin_key)
|
||||
|
||||
all_alerts: list[NormalizedAlert] = []
|
||||
providers_polled: list[str] = []
|
||||
|
|
@ -138,7 +257,7 @@ def get_aggregated_feed(
|
|||
min_lng=min_lng,
|
||||
max_lat=max_lat,
|
||||
max_lng=max_lng,
|
||||
fixture_override=fixture_override,
|
||||
fixture_override=safe_fixture,
|
||||
)
|
||||
all_alerts.extend(zyla_alerts)
|
||||
providers_polled.append(zyla_adapter.PROVIDER_ID)
|
||||
|
|
@ -165,7 +284,7 @@ def get_aggregated_feed(
|
|||
@router.get(
|
||||
"/zyla",
|
||||
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(
|
||||
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_lng: float = Query(..., ge=-180.0, le=180.0),
|
||||
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.
|
||||
Enforces geographic box bounds and server-side budget limits.
|
||||
"""
|
||||
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(
|
||||
min_lat=min_lat,
|
||||
min_lng=min_lng,
|
||||
max_lat=max_lat,
|
||||
max_lng=max_lng,
|
||||
fixture_override=fixture_override,
|
||||
fixture_override=safe_fixture,
|
||||
)
|
||||
return AlertFeedResponse(
|
||||
success=True,
|
||||
|
|
@ -199,7 +320,7 @@ def get_zyla_alerts(
|
|||
@router.get(
|
||||
"/ga511",
|
||||
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(
|
||||
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():
|
||||
"""
|
||||
Operational health dashboard reporting uptime, failure rates, request counts,
|
||||
cache hit ratio, and usage spend per provider.
|
||||
Requires authentication to protect internal operational telemetry.
|
||||
"""
|
||||
zyla_health = zyla_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)])
|
||||
def update_provider_switch(payload: ProviderSwitchRequest):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
|
|||
from pathlib import Path
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -40,7 +40,7 @@ class ZylaWazeAdapter:
|
|||
"""
|
||||
Backend-only adapter for Zyla Labs Waze Data API #4814.
|
||||
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"
|
||||
|
|
@ -53,6 +53,7 @@ class ZylaWazeAdapter:
|
|||
enabled: Optional[bool] = None,
|
||||
monthly_budget_limit: int = 500,
|
||||
cost_per_request_usd: float = 0.005,
|
||||
state_file: Optional[Path] = None,
|
||||
):
|
||||
# Resolve config from environment or overrides
|
||||
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.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.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.success_count: int = 0
|
||||
self.error_count: int = 0
|
||||
|
|
@ -87,6 +94,9 @@ class ZylaWazeAdapter:
|
|||
else:
|
||||
status = "operational"
|
||||
|
||||
monthly_used = self.usage_tracker.requests_used
|
||||
estimated_cost = self.usage_tracker.estimated_cost_usd
|
||||
|
||||
return ProviderHealth(
|
||||
provider_id=self.PROVIDER_ID,
|
||||
name=self.PROVIDER_NAME,
|
||||
|
|
@ -104,8 +114,8 @@ class ZylaWazeAdapter:
|
|||
last_error_at=self.last_error_at,
|
||||
last_error_message=self.last_error_message,
|
||||
monthly_budget_limit=self.monthly_budget_limit,
|
||||
monthly_requests_used=self.request_count,
|
||||
estimated_cost_usd=round(self.request_count * self.cost_per_request_usd, 4),
|
||||
monthly_requests_used=monthly_used,
|
||||
estimated_cost_usd=estimated_cost,
|
||||
)
|
||||
|
||||
def normalize_alert(self, raw: dict, now: datetime) -> Optional[NormalizedAlert]:
|
||||
|
|
@ -221,45 +231,67 @@ class ZylaWazeAdapter:
|
|||
return None
|
||||
|
||||
# 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}"
|
||||
|
||||
# 6. Confidence normalization (Waze rating 1-6, confidence 0-5)
|
||||
raw_confidence = raw.get("confidence")
|
||||
if isinstance(raw_confidence, (int, float)):
|
||||
conf_score = max(0.1, min(1.0, float(raw_confidence) / 5.0))
|
||||
# 6. Confidence normalization (Waze rating 1-6, confidence 0-5, or 0-10)
|
||||
# Truthfully preserve 0 values without inventing synthetic default confidence
|
||||
raw_confidence = raw.get("alert_confidence") if "alert_confidence" in raw else raw.get("confidence")
|
||||
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:
|
||||
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
|
||||
|
||||
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(
|
||||
id=normalized_id,
|
||||
source=self.PROVIDER_NAME,
|
||||
source_type="licensed_external",
|
||||
source_type="experimental_external",
|
||||
category=category,
|
||||
subtype=subtype,
|
||||
latitude=round(lat, 6),
|
||||
longitude=round(lng, 6),
|
||||
road_name=raw.get("street"),
|
||||
city=raw.get("city"),
|
||||
description=raw.get("reportDescription"),
|
||||
description=description_text,
|
||||
reported_at_utc=reported_at.isoformat(),
|
||||
fetched_at_utc=now.isoformat(),
|
||||
expires_at_utc=expires_at.isoformat(),
|
||||
confidence=round(conf_score, 2),
|
||||
confidence=conf_score,
|
||||
reliability=reliability_score,
|
||||
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]:
|
||||
"""
|
||||
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)
|
||||
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):
|
||||
return []
|
||||
|
||||
|
|
@ -297,7 +329,7 @@ class ZylaWazeAdapter:
|
|||
) -> list[NormalizedAlert]:
|
||||
"""
|
||||
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)
|
||||
|
||||
|
|
@ -320,13 +352,16 @@ class ZylaWazeAdapter:
|
|||
logger.warning("Zyla Waze circuit breaker is OPEN. Requests prevented.")
|
||||
return []
|
||||
|
||||
# 4. Request budget safeguard
|
||||
if self.request_count >= self.monthly_budget_limit and not fixture_override:
|
||||
# 4. Durable request budget safeguard
|
||||
if not self.usage_tracker.can_request() and not fixture_override:
|
||||
logger.warning("Zyla Waze monthly budget limit reached (%d).", self.monthly_budget_limit)
|
||||
return []
|
||||
|
||||
# Increment tracked requests
|
||||
self.request_count += 1
|
||||
if not fixture_override:
|
||||
self.usage_tracker.record_request()
|
||||
|
||||
|
||||
try:
|
||||
# 5. Fixture handling or mock fallback
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"status": "OK",
|
||||
"data": {
|
||||
"alerts": [
|
||||
{
|
||||
"alert_id": "zyla_atl_pol_01",
|
||||
"id": "zyla_atl_pol_01",
|
||||
"uuid": "zyla_atl_pol_01",
|
||||
"type": "POLICE",
|
||||
|
|
@ -13,14 +16,18 @@
|
|||
},
|
||||
"pubMillis": 1791664300000,
|
||||
"reportRating": 5,
|
||||
"alert_confidence": 5,
|
||||
"alert_reliability": 9,
|
||||
"confidence": 5,
|
||||
"reliability": 9,
|
||||
"description": "Speed enforcement vehicle under Freedom Pkwy overpass",
|
||||
"reportDescription": "Speed enforcement vehicle under Freedom Pkwy overpass",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:31:40Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_atl_acc_01",
|
||||
"id": "zyla_atl_acc_01",
|
||||
"uuid": "zyla_atl_acc_01",
|
||||
"type": "ACCIDENT",
|
||||
|
|
@ -33,14 +40,18 @@
|
|||
},
|
||||
"pubMillis": 1791664400000,
|
||||
"reportRating": 4,
|
||||
"alert_confidence": 5,
|
||||
"alert_reliability": 8,
|
||||
"confidence": 5,
|
||||
"reliability": 8,
|
||||
"description": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
|
||||
"reportDescription": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:33:20Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_atl_haz_01",
|
||||
"id": "zyla_atl_haz_01",
|
||||
"uuid": "zyla_atl_haz_01",
|
||||
"type": "HAZARD",
|
||||
|
|
@ -53,14 +64,18 @@
|
|||
},
|
||||
"pubMillis": 1791664250000,
|
||||
"reportRating": 3,
|
||||
"alert_confidence": 4,
|
||||
"alert_reliability": 7,
|
||||
"confidence": 4,
|
||||
"reliability": 7,
|
||||
"description": "Severe pothole right wheel track near Lenox Square",
|
||||
"reportDescription": "Severe pothole right wheel track near Lenox Square",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:30:50Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_atl_clo_01",
|
||||
"id": "zyla_atl_clo_01",
|
||||
"uuid": "zyla_atl_clo_01",
|
||||
"type": "ROAD_CLOSED",
|
||||
|
|
@ -68,56 +83,43 @@
|
|||
"street": "I-75 N at Howell Mill Rd",
|
||||
"city": "Atlanta, GA",
|
||||
"location": {
|
||||
"x": -84.4250,
|
||||
"y": 33.8150
|
||||
"x": -84.4150,
|
||||
"y": 33.8100
|
||||
},
|
||||
"pubMillis": 1791662000000,
|
||||
"pubMillis": 1791663000000,
|
||||
"reportRating": 5,
|
||||
"alert_confidence": 5,
|
||||
"alert_reliability": 10,
|
||||
"confidence": 5,
|
||||
"reliability": 10,
|
||||
"reportDescription": "Overnight bridge joint replacement closure",
|
||||
"description": "Overnight lane replacement construction",
|
||||
"reportDescription": "Overnight lane replacement construction",
|
||||
"provider": "Waze",
|
||||
"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",
|
||||
"uuid": "zyla_atl_stale_01",
|
||||
"type": "ACCIDENT",
|
||||
"subtype": "ACCIDENT_MINOR",
|
||||
"street": "Northside Dr NW",
|
||||
"street": "GA-400 S",
|
||||
"city": "Atlanta, GA",
|
||||
"location": {
|
||||
"x": -84.4100,
|
||||
"y": 33.7800
|
||||
"x": -84.3600,
|
||||
"y": 33.8500
|
||||
},
|
||||
"pubMillis": 1791620000000,
|
||||
"pubMillis": 1791650000000,
|
||||
"reportRating": 2,
|
||||
"confidence": 2,
|
||||
"reliability": 3,
|
||||
"reportDescription": "Cleared accident from 12 hours ago",
|
||||
"alert_confidence": 2,
|
||||
"alert_reliability": 3,
|
||||
"description": "Cleared minor crash from 4 hours ago",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T08:13:20Z"
|
||||
"publish_datetime_utc": "2026-10-10T16:33:20Z"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
"jams": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"status": "OK",
|
||||
"data": {
|
||||
"alerts": [
|
||||
{
|
||||
"alert_id": "zyla_rome_pol_01",
|
||||
"id": "zyla_rome_pol_01",
|
||||
"uuid": "zyla_rome_pol_01",
|
||||
"type": "POLICE",
|
||||
|
|
@ -13,14 +16,18 @@
|
|||
},
|
||||
"pubMillis": 1791664000000,
|
||||
"reportRating": 4,
|
||||
"confidence": 5,
|
||||
"reliability": 8,
|
||||
"alert_confidence": 0,
|
||||
"alert_reliability": 0,
|
||||
"confidence": 0,
|
||||
"reliability": 0,
|
||||
"description": "Stationary radar trap Northbound on shoulder",
|
||||
"reportDescription": "Stationary radar trap Northbound on shoulder",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_haz_01",
|
||||
"id": "zyla_rome_haz_01",
|
||||
"uuid": "zyla_rome_haz_01",
|
||||
"type": "HAZARD",
|
||||
|
|
@ -33,14 +40,18 @@
|
|||
},
|
||||
"pubMillis": 1791664100000,
|
||||
"reportRating": 3,
|
||||
"alert_confidence": 4,
|
||||
"alert_reliability": 7,
|
||||
"confidence": 4,
|
||||
"reliability": 7,
|
||||
"description": "Tree branch in right lane around bend",
|
||||
"reportDescription": "Tree branch in right lane around bend",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:28:20Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_acc_01",
|
||||
"id": "zyla_rome_acc_01",
|
||||
"uuid": "zyla_rome_acc_01",
|
||||
"type": "ACCIDENT",
|
||||
|
|
@ -53,14 +64,18 @@
|
|||
},
|
||||
"pubMillis": 1791664200000,
|
||||
"reportRating": 2,
|
||||
"alert_confidence": 3,
|
||||
"alert_reliability": 6,
|
||||
"confidence": 3,
|
||||
"reliability": 6,
|
||||
"description": "Fender bender near Oostanaula bridge",
|
||||
"reportDescription": "Fender bender near Oostanaula bridge",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:30:00Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_clo_01",
|
||||
"id": "zyla_rome_clo_01",
|
||||
"uuid": "zyla_rome_clo_01",
|
||||
"type": "ROAD_CLOSED",
|
||||
|
|
@ -73,14 +88,18 @@
|
|||
},
|
||||
"pubMillis": 1791663000000,
|
||||
"reportRating": 5,
|
||||
"alert_confidence": 5,
|
||||
"alert_reliability": 9,
|
||||
"confidence": 5,
|
||||
"reliability": 9,
|
||||
"description": "Utility work road closure between 3rd and 4th Ave",
|
||||
"reportDescription": "Utility work road closure between 3rd and 4th Ave",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:10:00Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_stale_01",
|
||||
"id": "zyla_rome_stale_01",
|
||||
"uuid": "zyla_rome_stale_01",
|
||||
"type": "POLICE",
|
||||
|
|
@ -93,14 +112,15 @@
|
|||
},
|
||||
"pubMillis": 1791600000000,
|
||||
"reportRating": 1,
|
||||
"confidence": 1,
|
||||
"reliability": 2,
|
||||
"reportDescription": "Old radar report from 18 hours ago",
|
||||
"alert_confidence": 1,
|
||||
"alert_reliability": 2,
|
||||
"description": "Old radar report from 18 hours ago",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T02:40:00Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_future_01",
|
||||
"id": "zyla_rome_future_01",
|
||||
"uuid": "zyla_rome_future_01",
|
||||
"type": "HAZARD",
|
||||
|
|
@ -113,14 +133,15 @@
|
|||
},
|
||||
"pubMillis": 1854748800000,
|
||||
"reportRating": 3,
|
||||
"confidence": 3,
|
||||
"reliability": 5,
|
||||
"reportDescription": "Corrupted future timestamp record",
|
||||
"alert_confidence": 3,
|
||||
"alert_reliability": 5,
|
||||
"description": "Corrupted future timestamp record",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2028-10-10T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_malformed_01",
|
||||
"id": "zyla_rome_malformed_01",
|
||||
"uuid": "zyla_rome_malformed_01",
|
||||
"type": "ACCIDENT",
|
||||
|
|
@ -133,15 +154,16 @@
|
|||
},
|
||||
"pubMillis": 1791664000000,
|
||||
"reportRating": 1,
|
||||
"confidence": 1,
|
||||
"reliability": 1,
|
||||
"reportDescription": "Invalid out-of-bounds coordinates",
|
||||
"alert_confidence": 1,
|
||||
"alert_reliability": 1,
|
||||
"description": "Invalid out-of-bounds coordinates",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"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",
|
||||
"type": "POLICE",
|
||||
"subtype": "POLICE_VISIBLE",
|
||||
|
|
@ -153,9 +175,9 @@
|
|||
},
|
||||
"pubMillis": 1791664000000,
|
||||
"reportRating": 4,
|
||||
"confidence": 5,
|
||||
"reliability": 8,
|
||||
"reportDescription": "Duplicate report with identical uuid",
|
||||
"alert_confidence": 0,
|
||||
"alert_reliability": 0,
|
||||
"description": "Duplicate report with identical uuid",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
||||
|
|
@ -163,3 +185,4 @@
|
|||
],
|
||||
"jams": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
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(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.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
|
||||
|
||||
|
||||
|
|
@ -29,15 +37,18 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
|||
"""
|
||||
Tests parsing the sanitized Rome, GA test fixture:
|
||||
- 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 deduplication of duplicate alert IDs.
|
||||
"""
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
categories = [a.category for a in alerts]
|
||||
|
|
@ -46,30 +57,37 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
|||
self.assertIn("accident", 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")
|
||||
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_type, "licensed_external")
|
||||
self.assertEqual(police_alert.source_type, "experimental_external")
|
||||
self.assertEqual(police_alert.subtype, "police_visible")
|
||||
self.assertAlmostEqual(police_alert.latitude, 34.2750)
|
||||
self.assertAlmostEqual(police_alert.longitude, -85.1700)
|
||||
self.assertEqual(police_alert.road_name, "Martha Berry Hwy")
|
||||
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")
|
||||
self.assertEqual(closure_alert.road_name, "Broad St")
|
||||
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]
|
||||
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_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"]
|
||||
self.assertEqual(len(pol_matches), 1)
|
||||
|
||||
|
|
@ -110,6 +128,41 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
|||
self.assertEqual(pothole.subtype, "pothole")
|
||||
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):
|
||||
"""
|
||||
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
|
||||
raw_police = {
|
||||
"uuid": "trap_test_1",
|
||||
"alert_id": "trap_test_1",
|
||||
"type": "POLICE",
|
||||
"subtype": "POLICE_HIDING",
|
||||
"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)
|
||||
raw_closure = {
|
||||
"uuid": "closure_test_1",
|
||||
"alert_id": "closure_test_1",
|
||||
"type": "ROAD_CLOSED",
|
||||
"subtype": "ROAD_CLOSED_CONSTRUCTION",
|
||||
"location": {"x": -85.17, "y": 34.25},
|
||||
|
|
@ -143,36 +196,62 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
|||
self.assertIsNotNone(res_closure)
|
||||
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:
|
||||
- No outbound requests are made.
|
||||
- fetch_alerts returns an empty list cleanly.
|
||||
- Status accurately reports disabled_by_policy or auth_missing.
|
||||
Proves that PersistentUsageTracker persists usage to disk across restarts
|
||||
and enforces monthly budget limits.
|
||||
"""
|
||||
# Disabled adapter
|
||||
disabled_adapter = ZylaWazeAdapter(api_key="valid-key", enabled=False)
|
||||
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)
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
# Missing key adapter
|
||||
missing_key_adapter = ZylaWazeAdapter(api_key="", enabled=True)
|
||||
self.assertEqual(missing_key_adapter.get_health().status, "auth_missing")
|
||||
res2 = missing_key_adapter.fetch_alerts(34.0, -85.0, 34.5, -84.5)
|
||||
self.assertEqual(res2, [])
|
||||
self.assertEqual(missing_key_adapter.request_count, 0)
|
||||
try:
|
||||
# 1. Process 1: Record 3 requests against a limit of 5
|
||||
tracker1 = PersistentUsageTracker(
|
||||
provider_id="test_provider",
|
||||
monthly_budget_limit=5,
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
self.assertTrue(cb.can_request())
|
||||
|
||||
# Simulate failures
|
||||
cb.record_failure("500 Server Error")
|
||||
self.assertEqual(cb.state, "CLOSED")
|
||||
cb.record_failure("503 Service Unavailable")
|
||||
|
|
@ -181,36 +260,26 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
|||
self.assertEqual(cb.state, "OPEN")
|
||||
self.assertFalse(cb.can_request())
|
||||
|
||||
# Immediate trip on 429 rate limit
|
||||
cb_rate = CircuitBreaker()
|
||||
cb_rate.record_failure("429 Too Many Requests", is_rate_limit=True)
|
||||
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):
|
||||
"""
|
||||
Tests that nearby / quantized bounding boxes hit cache to conserve budget.
|
||||
"""
|
||||
cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=60.0)
|
||||
|
||||
sample_data = ["alert_1", "alert_2"]
|
||||
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)
|
||||
self.assertEqual(hit, sample_data)
|
||||
self.assertEqual(cache.hits, 1)
|
||||
|
||||
# Query distinctly different cell
|
||||
miss = cache.get(33.7500, -84.3800, 33.7900, -84.3500)
|
||||
self.assertIsNone(miss)
|
||||
self.assertEqual(cache.misses, 1)
|
||||
self.assertEqual(cache.hit_ratio, 0.5)
|
||||
|
||||
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.fixture_mode = True
|
||||
|
||||
|
|
@ -227,25 +296,102 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
|||
self.assertAlmostEqual(first.confidence, 0.95)
|
||||
|
||||
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)
|
||||
|
||||
# Inverted coordinates
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
validate_bounding_box(35.0, -85.0, 34.0, -84.0)
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
|
||||
# Overly broad box exceeding 1.0 degree
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
validate_bounding_box(30.0, -90.0, 35.0, -80.0)
|
||||
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__":
|
||||
|
|
|
|||
|
|
@ -1050,7 +1050,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
iconColor: Colors.amberAccent,
|
||||
title: 'Zyla Waze Data #4814 (Backend Gateway)',
|
||||
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,
|
||||
isEnabled: zylaEnabled,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ import 'waze_road_alert_provider.dart';
|
|||
import 'waze_osint_road_alert_provider.dart';
|
||||
import 'zyla_road_alert_provider.dart';
|
||||
import 'ga511_road_alert_provider.dart';
|
||||
import '../auth/auth_providers.dart';
|
||||
|
||||
final zylaProviderInstance = Provider<ZylaRoadAlertProvider>((ref) {
|
||||
return ZylaRoadAlertProvider();
|
||||
final authService = ref.watch(authServiceProvider);
|
||||
return ZylaRoadAlertProvider(
|
||||
getAuthToken: () => authService.getIdToken(),
|
||||
);
|
||||
});
|
||||
|
||||
final ga511ProviderInstance = Provider<Georgia511RoadAlertProvider>((ref) {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ import 'road_alert_provider_interface.dart';
|
|||
class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||
final http.Client _client;
|
||||
final String backendBaseUrl;
|
||||
final Future<String?> Function()? getAuthToken;
|
||||
|
||||
ZylaRoadAlertProvider({
|
||||
http.Client? client,
|
||||
String? backendBaseUrl,
|
||||
this.getAuthToken,
|
||||
}) : _client = client ?? http.Client(),
|
||||
backendBaseUrl = backendBaseUrl ??
|
||||
const String.fromEnvironment(
|
||||
|
|
@ -43,11 +45,35 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
|||
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
|
||||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||
try {
|
||||
final headers = await _buildHeaders(apiKey: apiKey);
|
||||
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) {
|
||||
final data = json.decode(res.body);
|
||||
final providers = data['providers'] as List<dynamic>? ?? [];
|
||||
|
|
@ -62,6 +88,9 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
|||
}
|
||||
return RoadAlertProviderStatus.connected;
|
||||
}
|
||||
if (res.statusCode == 401 || res.statusCode == 403) {
|
||||
return RoadAlertProviderStatus.authError;
|
||||
}
|
||||
return RoadAlertProviderStatus.networkError;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
|
|
@ -103,7 +132,14 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
|||
);
|
||||
|
||||
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 (kDebugMode) {
|
||||
debugPrint('[ZylaRoadAlertProvider] Backend returned status ${response.statusCode}');
|
||||
|
|
@ -157,7 +193,7 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
|||
final expiresAtStr = raw['expires_at_utc']?.toString();
|
||||
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
|
||||
final dLat = (lat - userLat) * 111320.0;
|
||||
|
|
|
|||
|
|
@ -337,6 +337,131 @@ void main() {
|
|||
final status = await provider.testConnection('');
|
||||
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', () {
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ void main() {
|
|||
tester,
|
||||
) async {
|
||||
await open(tester);
|
||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
||||
await tester.tap(find.text('Road Hazard'));
|
||||
await tester.pumpAndSettle();
|
||||
// Deliberately resolve after the original implementation disposed its ref.
|
||||
location.current.complete(
|
||||
|
|
@ -160,7 +160,7 @@ void main() {
|
|||
tester,
|
||||
) async {
|
||||
await open(tester);
|
||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
||||
await tester.tap(find.text('Road Hazard'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -185,13 +185,13 @@ void main() {
|
|||
|
||||
testWidgets('repeated taps create one report', (tester) async {
|
||||
await open(tester);
|
||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
||||
await tester.tap(find.text('Road Hazard'));
|
||||
await tester.tap(find.text('Road Hazard'));
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester
|
||||
.widget<ElevatedButton>(
|
||||
find.widgetWithText(ElevatedButton, 'HAZARD / GRAVEL / DEBRIS'),
|
||||
find.widgetWithText(ElevatedButton, 'Road Hazard'),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
|
|
@ -219,7 +219,7 @@ void main() {
|
|||
tester,
|
||||
) async {
|
||||
await open(tester);
|
||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
||||
await tester.tap(find.text('Road Hazard'));
|
||||
location.current.completeError(const PermissionDeniedException('denied'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(alerts.reports, isEmpty);
|
||||
|
|
@ -232,7 +232,7 @@ void main() {
|
|||
|
||||
testWidgets('close cancels even before its exit animation finishes', (tester) async {
|
||||
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));
|
||||
location.current.complete(_position());
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -242,13 +242,13 @@ void main() {
|
|||
|
||||
testWidgets('GPS timeout falls back and missing GPS permits retry', (tester) async {
|
||||
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.pumpAndSettle();
|
||||
expect(alerts.reports, isEmpty);
|
||||
expect(find.text('Cannot report: GPS location unavailable.'), findsOneWidget);
|
||||
expect(tester.widget<ElevatedButton>(find.widgetWithText(
|
||||
ElevatedButton, 'HAZARD / GRAVEL / DEBRIS')).onPressed, isNotNull);
|
||||
ElevatedButton, 'Road Hazard')).onPressed, isNotNull);
|
||||
location.current.complete(_position());
|
||||
await tester.pumpAndSettle();
|
||||
expect(alerts.reports, isEmpty);
|
||||
|
|
@ -258,7 +258,7 @@ void main() {
|
|||
final lastKnown = Completer<Position?>();
|
||||
location.lastKnown = () => lastKnown.future;
|
||||
await open(tester);
|
||||
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS'));
|
||||
await tester.tap(find.text('Road Hazard'));
|
||||
location.current.completeError(StateError('GPS unavailable'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
|
|
@ -272,7 +272,7 @@ void main() {
|
|||
testWidgets('confirmed cloud outcome has a persisted fake document', (tester) async {
|
||||
final firestore = FakeFirebaseFirestore();
|
||||
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());
|
||||
await tester.pumpAndSettle();
|
||||
expect(alerts.reports, hasLength(1));
|
||||
|
|
@ -291,7 +291,7 @@ void main() {
|
|||
}
|
||||
''');
|
||||
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());
|
||||
await tester.pumpAndSettle();
|
||||
expect(alerts.reports, hasLength(1));
|
||||
|
|
@ -302,7 +302,7 @@ void main() {
|
|||
testWidgets('pending acknowledgement returns locally without retrying write', (tester) async {
|
||||
final firestore = _DelayedFirestore();
|
||||
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());
|
||||
await tester.pump();
|
||||
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 {
|
||||
final firestore = _DelayedFirestore();
|
||||
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());
|
||||
await tester.pump();
|
||||
expect(firestore.writes, 1);
|
||||
|
|
|
|||
Loading…
Reference in New Issue