from datetime import UTC, datetime from sqlalchemy import func, select from app.core.db import SessionLocal from app.models.audit import AuditEvent from app.models.booking import Booking from app.models.customer import Customer from app.models.data_quality import DataQualityIssue from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent from app.models.user import User from app.models.vehicle import Vehicle from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed def test_seed_counts_match_deterministic_dataset(): # Other test modules mutate shared demo state (returns, resets), so this test # re-seeds immediately before asserting counts rather than trusting whatever # order pytest happened to run modules in. db = SessionLocal() try: reset_and_seed(db) assert db.scalar(select(func.count()).select_from(Vehicle)) == 50 assert db.scalar(select(func.count()).select_from(Customer)) == 180 # 246 original plus 8 (BK-T-001..008) added so "Today's movements" reads as a # real day of traffic rather than the same fixed 4 rows on every reset. assert db.scalar(select(func.count()).select_from(Booking)) == 254 # 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a # real open issue) plus a deterministic set discovered by the post-seed scan. The # shared vehicle-status evaluator (app.services.vehicle_status) also catches # MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has # already crossed its service-due odometer threshold -- a genuine conflict the # previous hand-rolled scanner never checked for. assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 33 assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20 assert db.scalar(select(func.count()).select_from(User)) == 2 finally: db.close() def test_seed_demo_scenarios_present(): db = SessionLocal() try: reset_and_seed(db) booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN")) assert booking is not None assert booking.status == "active" duplicate_customer = db.scalar(select(Customer).where(Customer.public_ref == "CUS-0178")) assert duplicate_customer is not None duplicate_issue = db.scalar( select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE") ) assert duplicate_issue is not None assert duplicate_issue.rule_type == "possible_duplicate_customer" failed_run = db.scalar( select(OutboxEvent).where(OutboxEvent.delivery_status == "failed") ) assert failed_run is not None finally: db.close() def _by_ref(db, model, ref): return db.scalar(select(model).where(model.public_ref == ref)) def test_seed_scenario_s1_odometer_regression_return(): """S1: BK-DEMO-RETURN on MO-024 is an active booking ready for a return with a below-canonical odometer reading, using the vehicle's own current odometer.""" db = SessionLocal() try: reset_and_seed(db) booking = _by_ref(db, Booking, "BK-DEMO-RETURN") vehicle = _by_ref(db, Vehicle, "MO-024") assert booking is not None and vehicle is not None assert booking.vehicle_id == vehicle.id assert booking.status == "active" assert booking.end_odometer_km is None # A demo return reading must sit below the vehicle's canonical odometer to # reproduce the odometer-regression anomaly deterministically. assert vehicle.odometer_km > 0 finally: db.close() def test_seed_scenario_s2_duplicate_customer_pair(): """S2: CUS-0012/CUS-0178 form a possible-duplicate pair with a matching open issue.""" db = SessionLocal() try: reset_and_seed(db) primary = _by_ref(db, Customer, "CUS-0012") duplicate = _by_ref(db, Customer, "CUS-0178") assert primary is not None and duplicate is not None assert primary.email == duplicate.email assert duplicate.merged_into_customer_id is None issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-DUPLICATE") assert issue is not None assert issue.rule_type == "possible_duplicate_customer" assert issue.status == "open" related = issue.evidence_json.get("related_refs", []) assert "CUS-0012" in related or "CUS-0178" in related finally: db.close() def test_seed_scenario_s4_booking_overlap(): """S4: MO-016 carries two overlapping reservations plus a matching open issue.""" db = SessionLocal() try: reset_and_seed(db) vehicle = _by_ref(db, Vehicle, "MO-016") booking_a = _by_ref(db, Booking, "BK-DEMO-OVERLAP-A") booking_b = _by_ref(db, Booking, "BK-DEMO-OVERLAP-B") assert vehicle is not None and booking_a is not None and booking_b is not None assert booking_a.vehicle_id == vehicle.id assert booking_b.vehicle_id == vehicle.id assert booking_a.starts_at < booking_b.ends_at assert booking_b.starts_at < booking_a.ends_at issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-OVERLAP") assert issue is not None assert issue.rule_type == "booking_overlap" assert issue.status == "open" finally: db.close() def test_seed_scenario_s5_failed_workflow_run(): """S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely pending, so the background dispatcher never silently auto-heals it away.""" db = SessionLocal() try: reset_and_seed(db) failed = db.scalar( select(OutboxEvent).where( OutboxEvent.event_id == "00000000-0000-4000-8000-000000000020" ) ) assert failed is not None assert failed.delivery_status == "failed" assert failed.attempts >= 1 assert failed.last_error # Coded as a prepared demo scenario, not as a real connectionError: the whole # point of this row is to demonstrate retry and audit, so nothing downstream # may read it as evidence that the n8n integration is unhealthy. assert failed.last_error_code == DEMO_SCENARIO_ERROR_CODE finally: db.close() def test_seed_dates_are_anchored_to_reset_moment(): """Every reset shifts seeded dates by (real today - authored anchor), so scenario bookings stay 'today'/'near-future' relative to whenever the reset actually ran, instead of decaying back to the fixed 2026-08-01 authoring date.""" db = SessionLocal() try: result = reset_and_seed(db) today = datetime.now(UTC).date() assert result.anchor_date == today shift = today - SEED_AUTHORED_ANCHOR booking = _by_ref(db, Booking, "BK-DEMO-RETURN") assert booking is not None # Authored ends_at was 2026-08-01T09:00Z; after shifting it must land on the # real reset date, not the frozen authoring date (unless shift is exactly zero). assert booking.ends_at.date() == today or shift.days == 0 marker = db.scalar( select(AuditEvent) .where(AuditEvent.action == "demo_data_seeded") .order_by(AuditEvent.occurred_at.desc()) ) assert marker is not None assert marker.metadata_json["anchor_date"] == today.isoformat() assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat() finally: db.close() def test_seed_today_movements_are_a_credible_mix(): """A fresh reset must not land on a thin, always-identical 'Today's movements' dashboard section: a real day of fleet traffic (>=5 departures, >=5 returns, across more than 4 distinct vehicles) should fall on the reset day, mirroring the same status/date rule the dashboard router uses to build the today list.""" db = SessionLocal() try: reset_and_seed(db) today = datetime.now(UTC).date() bookings = db.scalars(select(Booking)).all() departures = [ b for b in bookings if b.starts_at.date() == today and b.status in ("reserved", "active") ] returns = [ b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned") ] assert len(departures) >= 5 assert len(returns) >= 5 vehicles_involved = {b.vehicle_id for b in departures} | {b.vehicle_id for b in returns} assert len(vehicles_involved) > 4 finally: db.close() def test_every_blocked_vehicle_has_a_real_open_issue(): """A live reviewer found blocked vehicles with no explanation anywhere in the UI -- 5 with zero quality issues at all, one (MO-049) with only a resolved one. Every vehicle seeded as 'blocked' must now have at least one real, currently open DataQualityIssue an operator can click through to.""" db = SessionLocal() try: reset_and_seed(db) blocked = db.scalars(select(Vehicle).where(Vehicle.operational_status == "blocked")).all() assert len(blocked) > 0 for vehicle in blocked: open_issue = db.scalar( select(DataQualityIssue).where( DataQualityIssue.entity_type == "vehicle", DataQualityIssue.entity_id == vehicle.id, DataQualityIssue.status == "open", ) ) assert open_issue is not None, f"{vehicle.public_ref} is blocked with no open issue" finally: db.close() def test_seed_scenario_mo024_service_conflict_is_flagged(): """Regression lock-in for the live-reported MO-024 defect: 'rented' with a real active booking (BK-DEMO-RETURN) yet already 14,820 km past its service threshold, with nothing surfacing the contradiction. The shared vehicle-status evaluator already catches this (a real conflict, not a fabricated third status) -- this test exists so a future change can't silently regress it back to unexplained.""" db = SessionLocal() try: reset_and_seed(db) vehicle = _by_ref(db, Vehicle, "MO-024") assert vehicle is not None assert vehicle.operational_status == "rented" assert vehicle.odometer_km >= vehicle.next_service_km issue = db.scalar( select(DataQualityIssue).where( DataQualityIssue.entity_type == "vehicle", DataQualityIssue.entity_id == vehicle.id, DataQualityIssue.status == "open", DataQualityIssue.rule_type == "vehicle_status_conflict", ) ) assert issue is not None signals = issue.evidence_json.get("signals", []) assert any(s["code"] == "vehicle.manual_review_required" for s in signals) finally: db.close() def test_seed_evidence_has_no_placeholder_summary(): """Every seed-only data-quality issue used to carry the vacuous evidence 'Synthetic deterministic seed issue' with no structured signal at all -- a visitor had no way to understand why it needed attention. Every issue must now carry a real summary and at least one localizable signal (code + params).""" db = SessionLocal() try: reset_and_seed(db) issues = db.scalars(select(DataQualityIssue)).all() assert len(issues) > 0 for issue in issues: summary = issue.evidence_json.get("summary", "") assert summary != "Synthetic deterministic seed issue", ( f"{issue.public_ref} still has the meaningless placeholder summary" ) signals = issue.evidence_json.get("signals", []) assert len(signals) > 0, f"{issue.public_ref} has no structured evidence signal" finally: db.close()