M39: harden application and acceptance gates
MobilityOps acceptance / backend (push) Failing after 45s
MobilityOps acceptance / frontend (push) Successful in 32s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-17 03:17:44 +02:00
parent a9f48d6880
commit ae39a8947f
62 changed files with 5277 additions and 202 deletions
+73 -28
View File
@@ -130,7 +130,12 @@ def _open_issue(
def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
customers = list(
db.scalars(select(Customer).where(Customer.merged_into_customer_id.is_(None))).all()
db.scalars(
select(Customer).where(
Customer.merged_into_customer_id.is_(None),
Customer.anonymized_at.is_(None),
)
).all()
)
customers.sort(key=lambda c: c.public_ref)
# The threshold cannot be reached without an exact email (60 points) or phone
@@ -170,9 +175,7 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
signals.append({"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}})
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
@@ -191,8 +194,13 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
# Anonymised customers have had their contact data removed on purpose; flagging
# them as "missing required field" would only be resolvable by re-entering PII.
for customer in db.scalars(
select(Customer).where(Customer.merged_into_customer_id.is_(None))
select(Customer).where(
Customer.merged_into_customer_id.is_(None),
Customer.anonymized_at.is_(None),
)
).all():
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(customer, f)]
if not customer.email and not customer.phone:
@@ -519,6 +527,30 @@ def resolve_odometer_regression(
"This issue is not an odometer_regression issue.",
status_code=409,
)
# Lock order is booking -> vehicle everywhere (checkout, return, reschedule); taking
# the vehicle lock first here would be a deadlock waiting to happen under concurrency.
booking: Booking | None = None
if body.decision != "retain_canonical":
related_refs = issue.evidence_json.get("related_refs", [])
if body.booking_ref not in related_refs:
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
if body.corrected_odometer_km is None:
raise AppError(
"CORRECTED_VALUE_REQUIRED",
"corrected_odometer_km is required when correcting a reading.",
status_code=422,
)
booking = db.scalar(
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
)
if booking is None:
raise AppError(
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
if vehicle is None:
raise AppError(
@@ -539,19 +571,7 @@ def resolve_odometer_regression(
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
)
else:
related_refs = issue.evidence_json.get("related_refs", [])
if body.booking_ref not in related_refs:
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
if body.corrected_odometer_km is None:
raise AppError(
"CORRECTED_VALUE_REQUIRED",
"corrected_odometer_km is required when correcting a reading.",
status_code=422,
)
assert booking is not None and body.corrected_odometer_km is not None
# Never silently lower the canonical odometer: a correction must be at or above
# the current canonical value, otherwise it would just create a new regression.
if body.corrected_odometer_km < vehicle.odometer_km:
@@ -563,13 +583,6 @@ def resolve_odometer_regression(
),
status_code=422,
)
booking = db.scalar(
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
)
if booking is None:
raise AppError(
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
)
before = {
"booking_end_odometer_km": booking.end_odometer_km,
@@ -810,6 +823,16 @@ def apply_recommended_status(
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
# Mirrors the column lengths in app/models/customer.py so an override can never fail with
# a database DataError (500) instead of a validation error.
_MERGEABLE_FIELD_MAX_LENGTH = {
"first_name": 80,
"last_name": 80,
"email": 200,
"phone": 40,
"postal_code": 20,
"city": 120,
}
def merge_customers(
@@ -839,12 +862,26 @@ def merge_customers(
)
loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref)
survivor = db.scalar(select(Customer).where(Customer.public_ref == survivor_ref))
loser = db.scalar(select(Customer).where(Customer.public_ref == loser_ref))
# Lock both rows in a deterministic order (by public_ref) so two concurrent merges
# touching the same customers serialise instead of deadlocking or double-merging.
survivor = None
loser = None
for ref in sorted((survivor_ref, loser_ref)):
customer = db.scalar(select(Customer).where(Customer.public_ref == ref).with_for_update())
if ref == survivor_ref:
survivor = customer
else:
loser = customer
if survivor is None or loser is None:
raise AppError(
"CUSTOMER_NOT_FOUND", "One of the customers could not be found.", status_code=404
)
if survivor.merged_into_customer_id is not None or loser.merged_into_customer_id is not None:
raise AppError(
"CUSTOMER_ALREADY_MERGED",
"One of the customers has already been merged into another record.",
status_code=409,
)
before = {
"survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS},
@@ -856,7 +893,15 @@ def merge_customers(
raise AppError(
"INVALID_FIELD_OVERRIDE", f"Field '{field_name}' cannot be merged.", status_code=422
)
setattr(survivor, field_name, value)
cleaned = value.strip() if isinstance(value, str) else value
max_length = _MERGEABLE_FIELD_MAX_LENGTH[field_name]
if not cleaned or len(cleaned) > max_length:
raise AppError(
"INVALID_FIELD_OVERRIDE",
f"Field '{field_name}' must be 1 to {max_length} characters.",
status_code=422,
)
setattr(survivor, field_name, cleaned)
rewired = db.execute(
update(Booking).where(Booking.customer_id == loser.id).values(customer_id=survivor.id)