The demo seed plants exactly one failed delivery (BK-H-0020) to demonstrate retry and audit. Because derive_n8n_status() counted any failure, every fresh reset pinned the n8n integration to "degraded" -- the demo showed a warning about a prop, which tells a viewer something untrue about the automation. The seeded failure now carries its own error code, demoScenarioTimeout, rather than the generic connectionError a real timeout produces. No column and no migration: last_error_code already existed, is already surfaced to the UI and is already localizable. - integration status splits failed into unexpected_failed and demo_scenario_failed; only unexpected failures may move the state. A staged failure alone leaves n8n operational. - latest_failure_at is a health signal and now ignores the staged failure; latest_demo_scenario_at reports it separately. - /api/v1/workflows exposes is_demo_scenario. The Automation page labels the run as a prepared demo scenario, explains that it is a simulated temporary failure that does not affect automation health, and offers a distinct "retry demo scenario" action. Translated in nl-BE, en-GB and fr-BE. - the carve-out stays narrow: a real failure still degrades n8n, and a genuine later failure of the same event overwrites the demo code with the real one. - the retry itself is unchanged and real: the event goes back on the outbox and the dispatcher delivers it to n8n like any other, so 19+1 becomes 20+0 only on an actual round trip. The audit records which kind of failure was retried. Tests that assert on the seeded scenario now reseed first, since earlier test files legitimately mutate the outbox and the suite shares one database. Verified locally against a real PostgreSQL 16: 181 passed, ruff clean, mypy clean (50 files), tsc clean, frontend build clean. Not deployed and not browser-verified.
203 lines
8.1 KiB
Python
203 lines
8.1 KiB
Python
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
|
|
assert db.scalar(select(func.count()).select_from(Booking)) == 246
|
|
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The
|
|
# shared vehicle-status evaluator (app.services.vehicle_status) now 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)) == 27
|
|
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 dead 'Today's movements' dashboard section:
|
|
at least two departures and two returns 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) >= 2
|
|
assert len(returns) >= 2
|
|
finally:
|
|
db.close()
|