M3: implement Data Quality Workbench
Five rule scanners (duplicate customers, missing fields, odometer regression, booking overlap, status conflict) run automatically after seed and via an explicit scan endpoint. Issue defer/reject/merge-customers endpoints with transactional customer merge (booking rewiring, tombstone, audit). Data Quality nav + workbench UI with two-column duplicate comparison and inline (non-native) confirm. Dashboard attention items now link to issues. 35 backend tests passing, ruff clean. Fixed a real false-positive bug in odometer-regression detection found through iteration on seed data, and two TS narrowing errors. Verified end-to-end via browser: S2 merge and S4 overlap scenarios.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
def test_list_includes_all_five_rule_types(ops_client):
|
||||
response = ops_client.get("/api/v1/data-quality/issues")
|
||||
assert response.status_code == 200
|
||||
issues = response.json()
|
||||
rule_types = {i["rule_type"] for i in issues}
|
||||
assert rule_types == {
|
||||
"possible_duplicate_customer",
|
||||
"missing_required_field",
|
||||
"odometer_regression",
|
||||
"booking_overlap",
|
||||
"vehicle_status_conflict",
|
||||
}
|
||||
|
||||
|
||||
def test_scan_is_idempotent_once_seeded(ops_client):
|
||||
# The session fixture already ran a scan as part of seeding; running again
|
||||
# must not create duplicate open issues for the same (rule_type, entity).
|
||||
response = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["created"] == {}
|
||||
|
||||
|
||||
def test_scan_requires_operations_manager(employee_client):
|
||||
response = employee_client.post("/api/v1/data-quality/scan")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_s2_duplicate_customer_issue_detail(ops_client):
|
||||
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["entity_ref"] == "CUS-0012"
|
||||
assert body["entity_snapshot"]["public_ref"] == "CUS-0012"
|
||||
assert [s["public_ref"] for s in body["related_snapshots"]] == ["CUS-0178"]
|
||||
|
||||
|
||||
def test_s4_booking_overlap_issue_detail(ops_client):
|
||||
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["entity_ref"] == "MO-016"
|
||||
assert set(body["evidence"]["related_refs"]) == {"BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B"}
|
||||
|
||||
|
||||
def test_defer_then_reject_are_rejected_on_closed_issue(ops_client):
|
||||
issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"rule_type": "missing_required_field", "status": "open"},
|
||||
).json()
|
||||
target = issues[0]["public_ref"]
|
||||
|
||||
deferred = ops_client.post(f"/api/v1/data-quality/issues/{target}/defer")
|
||||
assert deferred.status_code == 200
|
||||
assert deferred.json()["status"] == "deferred"
|
||||
|
||||
again = ops_client.post(f"/api/v1/data-quality/issues/{target}/reject")
|
||||
assert again.status_code == 409
|
||||
assert again.json()["error"]["code"] == "ISSUE_NOT_OPEN"
|
||||
|
||||
|
||||
def test_merge_customers_requires_operations_manager(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers",
|
||||
json={"survivor_ref": "CUS-0012"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_merge_customers_rejects_unrelated_survivor(ops_client):
|
||||
response = ops_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers",
|
||||
json={"survivor_ref": "CUS-0099"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert response.json()["error"]["code"] == "INVALID_SURVIVOR"
|
||||
|
||||
|
||||
def test_merge_customers_s2_scenario_rewires_and_audits(ops_client):
|
||||
before_bookings = ops_client.get(
|
||||
"/api/v1/bookings", params={"vehicle_ref": "MO-001"}
|
||||
) # warm the client session; irrelevant vehicle, just a cheap authenticated call
|
||||
assert before_bookings.status_code == 200
|
||||
|
||||
response = ops_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers",
|
||||
json={"survivor_ref": "CUS-0012", "field_overrides": {"city": "Turnhout"}},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["survivor_ref"] == "CUS-0012"
|
||||
assert body["loser_ref"] == "CUS-0178"
|
||||
|
||||
issue = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE").json()
|
||||
assert issue["status"] == "resolved"
|
||||
|
||||
audit_events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "customer_merged"}
|
||||
).json()
|
||||
assert len(audit_events) >= 1
|
||||
|
||||
# Already-resolved issue cannot be merged again.
|
||||
replay = ops_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers",
|
||||
json={"survivor_ref": "CUS-0012"},
|
||||
)
|
||||
assert replay.status_code == 409
|
||||
@@ -20,7 +20,8 @@ def test_seed_counts_match_deterministic_dataset():
|
||||
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
|
||||
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 15
|
||||
# 15 from the CSV plus a deterministic set discovered by the post-seed scan.
|
||||
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 26
|
||||
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
||||
assert db.scalar(select(func.count()).select_from(User)) == 2
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user