| """End-to-end matching, widening, notification-trigger, and authorization tests (spec §17).""" |
| from tests.conftest import auth |
|
|
|
|
| def _register(client, email, zip_="20001"): |
| return client.post( |
| "/auth/register", |
| json={"name": "U", "email": email, "password": "password123", "zip": zip_}, |
| ).json()["access_token"] |
|
|
|
|
| def _lost_dog_with_photo(client, token, seed, zip_="20001"): |
| dog = client.post( |
| "/dogs", headers=auth(token), |
| json={"name": "Rex", "breed": "Lab", "color": "black", "size": "large"}, |
| ).json() |
| client.post( |
| f"/dogs/{dog['id']}/photos", headers=auth(token), |
| files={"files": ("d.jpg", _img(seed), "image/jpeg")}, |
| ) |
| case = client.post( |
| "/cases/lost", headers=auth(token), |
| json={"known_dog_id": dog["id"], "event_zip": zip_, "event_date": "2026-06-01"}, |
| ).json() |
| return dog, case |
|
|
|
|
| def _img(seed): |
| from scripts.make_sample_images import make_image |
|
|
| return make_image(seed) |
|
|
|
|
| def test_found_report_matches_lost_dog(client): |
| owner = _register(client, "owner@example.com") |
| dog, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
|
|
| |
| |
| r = client.post( |
| "/cases/found", |
| data={ |
| "event_zip": "20001", "event_date": "2026-06-03", |
| "color": "black", "size": "large", |
| "finder_email": "finder@example.com", |
| }, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| assert r.status_code == 201, r.text |
| body = r.json() |
| assert body["vet_guidance"] |
| |
| assert len(body["matches"]) >= 1 |
| top = body["matches"][0] |
| assert top["candidate"]["type"] == "known" |
| assert top["similarity_score"] > 0.99 |
|
|
|
|
| def _lost_on(client, token, seed, event_date, zip_="20001"): |
| dog = client.post( |
| "/dogs", headers=auth(token), json={"name": "Rex", "breed": "Lab"} |
| ).json() |
| client.post( |
| f"/dogs/{dog['id']}/photos", headers=auth(token), |
| files={"files": ("d.jpg", _img(seed), "image/jpeg")}, |
| ) |
| client.post( |
| "/cases/lost", headers=auth(token), |
| json={"known_dog_id": dog["id"], "event_zip": zip_, "event_date": event_date}, |
| ) |
| return dog |
|
|
|
|
| def _found_on(client, seed, event_date, zip_="20001"): |
| return client.post( |
| "/cases/found", |
| data={"event_zip": zip_, "event_date": event_date, "finder_email": "f@example.com"}, |
| files={"files": ("f.jpg", _img(seed), "image/jpeg")}, |
| ) |
|
|
|
|
| def test_found_before_lost_is_excluded_by_date_gate(client): |
| |
| |
| owner = _register(client, "date_excl@example.com") |
| _lost_on(client, owner, seed=101, event_date="2026-06-10") |
| r = _found_on(client, seed=101, event_date="2026-06-01") |
| assert r.status_code == 201, r.text |
| assert r.json()["matches"] == [] |
|
|
|
|
| def test_found_within_grace_before_lost_still_matches(client): |
| |
| owner = _register(client, "date_grace@example.com") |
| _lost_on(client, owner, seed=101, event_date="2026-06-10") |
| r = _found_on(client, seed=101, event_date="2026-06-09") |
| matches = r.json()["matches"] |
| assert len(matches) >= 1 and matches[0]["candidate"]["type"] == "known" |
|
|
|
|
| def test_narrow_pass_caps_at_ten_matches(client): |
| |
| for _ in range(12): |
| assert _found_on(client, seed=101, event_date="2026-06-05").status_code == 201 |
| owner = _register(client, "cap@example.com") |
| dog = client.post("/dogs", headers=auth(owner), json={"name": "Rex", "breed": "Lab"}).json() |
| client.post( |
| f"/dogs/{dog['id']}/photos", headers=auth(owner), |
| files={"files": ("d.jpg", _img(101), "image/jpeg")}, |
| ) |
| resp = client.post( |
| "/cases/lost", headers=auth(owner), |
| json={"known_dog_id": dog["id"], "event_zip": "20001", "event_date": "2026-06-06"}, |
| ).json() |
| assert len(resp["matches"]) == 10 |
|
|
|
|
| def test_breed_gate_drops_at_nationwide(client): |
| """The top-10 breed gate constrains the narrow pass but is relaxed at the widest (nationwide) |
| rung, so widening loosens location then breed.""" |
| from datetime import date |
|
|
| import numpy as np |
|
|
| from app.db import SessionLocal |
| from app.ml import get_breed_classifier, get_embedder |
| from app.models import BreedPrediction, Case, Embedding, KnownDog, Picture, UnknownDog, User |
| from app.models.base import ( |
| CaseStatus, CaseType, KnownDogStatus, SubjectType, UnknownDogStatus, UserRole, |
| ) |
| from app.services.matching import run_matching_for_case |
|
|
| db = SessionLocal() |
| try: |
| emb, bc = get_embedder(), get_breed_classifier() |
| vec = np.ones(emb.dim, dtype=np.float32) |
| vec /= np.linalg.norm(vec) |
| owner = User(name="o", email="bg@example.com", zip="20001", password_hash="x", |
| role=UserRole.owner) |
| db.add(owner) |
| db.flush() |
| lost = KnownDog(owner_id=owner.id, name="Rex", description="", |
| status=KnownDogStatus.lost, last_known_zip="20001") |
| found = UnknownDog(description="", status=UnknownDogStatus.pending, current_zip="20001") |
| db.add_all([lost, found]) |
| db.flush() |
|
|
| def seed(stype, sid, label): |
| p = Picture(subject_type=stype, subject_id=sid, file_path=f"{stype.value}-{sid}.jpg", |
| thumb_path=f"{stype.value}-{sid}.jpg", mime_type="image/jpeg", is_primary=True) |
| db.add(p) |
| db.flush() |
| db.add(Embedding(picture_id=p.id, model_name=emb.name, model_version=emb.version, |
| dim=emb.dim, vector=Embedding.to_bytes(vec))) |
| db.add(BreedPrediction(picture_id=p.id, model_name=bc.name, model_version=bc.version, |
| rank=0, label=label, score=1.0)) |
|
|
| seed(SubjectType.known, lost.id, "collie") |
| seed(SubjectType.unknown, found.id, "poodle") |
| case = Case(person_id=owner.id, known_dog_id=lost.id, type=CaseType.lost, |
| event_zip="20001", event_date=date(2026, 6, 1), search_radius_miles=0, |
| status=CaseStatus.open) |
| db.add(case) |
| db.commit() |
|
|
| |
| assert run_matching_for_case(db, case) == [] |
| db.commit() |
| |
| case.search_radius_miles = -1 |
| matches = run_matching_for_case(db, case) |
| assert len(matches) == 1 and matches[0].candidate_id == found.id |
| finally: |
| db.close() |
|
|
|
|
| def test_equal_score_matches_break_ties_by_most_recent_report(client): |
| owner = _register(client, "tiebreak@example.com") |
| _dog, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| cid = case["case"]["id"] |
| |
| r1 = _found_on(client, seed=101, event_date="2026-06-01", zip_="20001") |
| r2 = _found_on(client, seed=101, event_date="2026-06-01", zip_="20001") |
| later_id = r2.json()["case"]["unknown_dog_id"] |
|
|
| matches = client.post(f"/cases/{cid}/rematch", headers=auth(owner)).json()["matches"] |
| assert len(matches) >= 2 |
| assert matches[0]["similarity_score"] == matches[1]["similarity_score"] |
| assert matches[0]["candidate_id"] == later_id |
|
|
|
|
| def test_owner_rematch_case(client): |
| owner = _register(client, "rematch@example.com") |
| _dog, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| cid = case["case"]["id"] |
| |
| _found_on(client, seed=101, event_date="2026-06-01", zip_="20001") |
| r = client.post(f"/cases/{cid}/rematch", headers=auth(owner)) |
| assert r.status_code == 200, r.text |
| assert len(r.json()["matches"]) >= 1 |
| |
| other = _register(client, "other_rematch@example.com") |
| assert client.post(f"/cases/{cid}/rematch", headers=auth(other)).status_code == 403 |
|
|
|
|
| def test_case_dog_endpoint_returns_photos_and_guards_owner(client): |
| owner = _register(client, "casedog@example.com") |
| dog, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| cid = case["case"]["id"] |
| r = client.get(f"/cases/{cid}/dog", headers=auth(owner)).json() |
| assert r["kind"] == "known" and len(r["photos"]) >= 1 |
| other = _register(client, "notowner_casedog@example.com") |
| assert client.get(f"/cases/{cid}/dog", headers=auth(other)).status_code == 403 |
|
|
|
|
| def test_found_report_requires_contact_when_anonymous(client): |
| r = client.post( |
| "/cases/found", |
| data={"event_zip": "20002", "event_date": "2026-06-03"}, |
| files={"files": ("f.jpg", _img(1), "image/jpeg")}, |
| ) |
| assert r.status_code == 400 |
|
|
|
|
| def test_distant_dog_not_matched_until_widened(client): |
| owner = _register(client, "owner2@example.com") |
| dog, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
|
|
| |
| client.post( |
| "/cases/found", |
| data={ |
| "event_zip": "98101", "event_date": "2026-06-03", |
| "finder_email": "f@example.com", |
| }, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| matches = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json() |
| assert matches == [] |
|
|
| |
| last = None |
| for _ in range(6): |
| last = client.post(f"/cases/{case_id}/widen", headers=auth(owner)) |
| if last.status_code == 400: |
| break |
| matches = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json() |
| assert len(matches) >= 1 |
|
|
|
|
| def test_confirm_match_resolves_case(client): |
| owner = _register(client, "owner3@example.com") |
| dog, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
| client.post( |
| "/cases/found", |
| data={"event_zip": "20001", "event_date": "2026-06-03", "finder_email": "f@example.com"}, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| client.post(f"/cases/{case_id}/rematch", headers=auth(owner)) |
| matches = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json() |
| match_id = matches[0]["id"] |
| r = client.post(f"/matches/{match_id}/confirm", headers=auth(owner)) |
| assert r.status_code == 200 |
| assert r.json()["status"] == "confirmed" |
| case_after = client.get(f"/cases/{case_id}", headers=auth(owner)).json() |
| assert case_after["status"] == "resolved" |
|
|
|
|
| def test_resolved_case_blocks_further_matching(client): |
| owner = _register(client, "locked@example.com") |
| _, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
| |
| for finder in ("f1@example.com", "f2@example.com"): |
| client.post( |
| "/cases/found", |
| data={"event_zip": "20001", "event_date": "2026-06-03", "finder_email": finder}, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| client.post(f"/cases/{case_id}/rematch", headers=auth(owner)) |
| matches = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json() |
| assert len(matches) >= 2 |
| client.post(f"/matches/{matches[0]['id']}/confirm", headers=auth(owner)) |
|
|
| |
| assert client.post(f"/cases/{case_id}/rematch", headers=auth(owner)).status_code == 400 |
| assert client.post(f"/cases/{case_id}/widen", headers=auth(owner)).status_code == 400 |
| assert client.post(f"/matches/{matches[1]['id']}/confirm", headers=auth(owner)).status_code == 400 |
|
|
|
|
| def test_rejected_candidate_stays_out_of_rematch(client): |
| owner = _register(client, "reject_flow@example.com") |
| _, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
| for finder in ("a1@example.com", "b1@example.com"): |
| client.post( |
| "/cases/found", |
| data={"event_zip": "20001", "event_date": "2026-06-03", "finder_email": finder}, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| client.post(f"/cases/{case_id}/rematch", headers=auth(owner)) |
| matches = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json() |
| assert len(matches) == 2 |
| rejected_cid = matches[0]["candidate_id"] |
| client.post(f"/matches/{matches[0]['id']}/reject", headers=auth(owner)) |
|
|
| client.post(f"/cases/{case_id}/rematch", headers=auth(owner)) |
| after = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json() |
| pending = [m for m in after if m["status"] == "pending"] |
| rejected = [m for m in after if m["status"] == "rejected"] |
| |
| assert all(m["candidate_id"] != rejected_cid for m in pending) |
| |
| assert len(rejected) == 1 |
| assert not ({m["candidate_id"] for m in pending} & {m["candidate_id"] for m in rejected}) |
|
|
|
|
| def test_reconsider_restores_rejected_candidate(client): |
| owner = _register(client, "reconsider@example.com") |
| _, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
| client.post( |
| "/cases/found", |
| data={"event_zip": "20001", "event_date": "2026-06-03", "finder_email": "a2@example.com"}, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| client.post(f"/cases/{case_id}/rematch", headers=auth(owner)) |
| m = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json()[0] |
| client.post(f"/matches/{m['id']}/reject", headers=auth(owner)) |
|
|
| r = client.post(f"/matches/{m['id']}/reconsider", headers=auth(owner)) |
| assert r.status_code == 200 and r.json()["status"] == "pending" |
|
|
| |
| client.post(f"/cases/{case_id}/rematch", headers=auth(owner)) |
| after = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json() |
| assert any(x["candidate_id"] == m["candidate_id"] and x["status"] == "pending" for x in after) |
| |
| pend = next(x for x in after if x["status"] == "pending") |
| assert client.post(f"/matches/{pend['id']}/reconsider", headers=auth(owner)).status_code == 400 |
|
|
|
|
| def _confirm_first_match(client, owner, case_id): |
| client.post(f"/cases/{case_id}/rematch", headers=auth(owner)) |
| matches = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json() |
| match_id = matches[0]["id"] |
| client.post(f"/matches/{match_id}/confirm", headers=auth(owner)) |
| return match_id |
|
|
|
|
| def test_reclaim_requires_confirmation(client): |
| owner = _register(client, "reclaim0@example.com") |
| _, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
| client.post( |
| "/cases/found", |
| data={"event_zip": "20001", "event_date": "2026-06-03", "finder_email": "f@example.com"}, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| client.post(f"/cases/{case_id}/rematch", headers=auth(owner)) |
| match_id = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json()[0]["id"] |
| |
| assert client.get(f"/matches/{match_id}/reclaim", headers=auth(owner)).status_code == 400 |
|
|
|
|
| def test_reclaim_at_shelter_shows_public_location(client): |
| owner = _register(client, "reclaim1@example.com") |
| _, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
| client.post( |
| "/cases/found", |
| data={ |
| "event_zip": "20001", "event_date": "2026-06-03", |
| "finder_email": "f@example.com", |
| "current_location_detail": "Happy Paws Shelter", |
| }, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| match_id = _confirm_first_match(client, owner, case_id) |
| info = client.get(f"/matches/{match_id}/reclaim", headers=auth(owner)).json() |
| assert info["found"] is True |
| assert info["held_at"] == "shelter_or_vet" |
| assert info["location_name"] == "Happy Paws Shelter" |
| assert info["current_zip"] == "20001" |
| assert info["contact"] is None |
| assert len(info["photos"]) >= 1 |
| assert len(info["your_photos"]) >= 1 |
|
|
|
|
| def test_reclaim_in_custody_reveals_finder_contact(client): |
| owner = _register(client, "reclaim2@example.com") |
| _, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
| client.post( |
| "/cases/found", |
| data={ |
| "event_zip": "20001", "event_date": "2026-06-03", |
| "finder_name": "Finn", "finder_email": "finn@example.com", "finder_phone": "555-1234", |
| }, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| match_id = _confirm_first_match(client, owner, case_id) |
| info = client.get(f"/matches/{match_id}/reclaim", headers=auth(owner)).json() |
| assert info["held_at"] == "finder_custody" |
| assert info["contact"]["email"] == "finn@example.com" |
| assert info["contact"]["phone"] == "555-1234" |
|
|
|
|
| def test_reclaim_guards_owner(client): |
| owner = _register(client, "reclaim3@example.com") |
| _, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
| client.post( |
| "/cases/found", |
| data={"event_zip": "20001", "event_date": "2026-06-03", "finder_email": "f@example.com"}, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| match_id = _confirm_first_match(client, owner, case_id) |
| intruder = _register(client, "reclaim_intruder@example.com") |
| assert client.get(f"/matches/{match_id}/reclaim", headers=auth(intruder)).status_code == 403 |
|
|
|
|
| def test_unknown_dog_shelter_shown_but_location_stays_zip_level(client): |
| |
| |
| owner = _register(client, "owner4@example.com") |
| dog, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
| client.post( |
| "/cases/found", |
| data={ |
| "event_zip": "20001", "event_date": "2026-06-03", |
| "finder_email": "f@example.com", |
| "current_location_detail": "Happy Paws Shelter", |
| }, |
| files={"files": ("f.jpg", _img(101), "image/jpeg")}, |
| ) |
| client.post(f"/cases/{case_id}/rematch", headers=auth(owner)) |
| matches = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json() |
| cand = matches[0]["candidate"] |
| assert cand["current_location_detail"] == "Happy Paws Shelter" |
| assert cand["current_zip"] == "20001" |
|
|
|
|
| def test_cannot_view_others_case_matches(client): |
| owner = _register(client, "owner5@example.com") |
| dog, case = _lost_dog_with_photo(client, owner, seed=101, zip_="20001") |
| case_id = case["case"]["id"] |
| intruder = _register(client, "intruder@example.com") |
| assert client.get(f"/cases/{case_id}/matches", headers=auth(intruder)).status_code == 403 |
|
|