PawTrace / backend /tests /test_matching.py
Elliott Duke
HomingPet: lost-dog reunification (FastAPI + React) with Render deploy
de1e3fc
Raw
History Blame Contribute Delete
20.4 kB
"""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")
# Anonymous finder reports a found dog in the SAME ZIP with the SAME image seed
# (identical mock vector) -> strong match at radius 0 (same-ZIP).
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"] # found reports get shelter guidance
# The found case should match the known lost dog strongly.
assert len(body["matches"]) >= 1
top = body["matches"][0]
assert top["candidate"]["type"] == "known"
assert top["similarity_score"] > 0.99 # identical vectors
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):
# Dog lost 2026-06-10; an IDENTICAL-photo found report dated 2026-06-01 (9 days earlier, beyond
# the 2-day grace) is temporally implausible -> must NOT match despite the perfect photo score.
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):
# Found one day before the reported loss — inside the 2-day grace -> still compared.
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):
# 12 identical found dogs in-range, then a lost dog with the same photo -> capped at top_n=10.
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) # identical, normalized -> cosine 1.0
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") # non-intersecting breeds -> gate would exclude
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()
# Narrow (radius 0): breed gate ON, breeds disjoint -> no match despite identical photo.
assert run_matching_for_case(db, case) == []
db.commit()
# Widen all the way to nationwide (-1): breed gate OFF -> now matches.
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"]
# Two identical found dogs (same photo -> same score); the second is reported later.
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"] # tied score
assert matches[0]["candidate_id"] == later_id # most recently reported wins the tie
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"]
# A matching found dog appears after the lost case was opened.
_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
# Not your case.
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"]
# A found dog far away (Seattle) with same image — should NOT match at radius 0.
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 == []
# Widen repeatedly to nationwide; now it should match.
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)) # surface the new found dog
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"]
# Two identical found dogs -> two strong candidates for the same lost dog.
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)) # resolves the case
# Matching is now closed: rematch/widen refused, and a different candidate can't be confirmed.
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)) # re-run
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"]
# The rejected dog does not return as a candidate...
assert all(m["candidate_id"] != rejected_cid for m in pending)
# ...and it appears exactly once (in the rejected list), never duplicated.
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"
# Reconsidered dog is a candidate again and survives a re-run (no longer suppressed).
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)
# A pending (non-rejected) match can't be reconsidered.
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)) # surface the new found dog
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"]
# Before confirming, reclaim details (incl. finder contact) are withheld.
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 # at a public place, no finder contact needed
assert len(info["photos"]) >= 1
assert len(info["your_photos"]) >= 1 # owner's own dog photos for side-by-side comparison
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):
# The shelter/vet holding a found dog is a PUBLIC place and IS shown to a matched owner so they
# can reclaim it; home locations stay ZIP-level only.
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)) # surface the new found dog
matches = client.get(f"/cases/{case_id}/matches", headers=auth(owner)).json()
cand = matches[0]["candidate"]
assert cand["current_location_detail"] == "Happy Paws Shelter" # public place, shown
assert cand["current_zip"] == "20001" # location still ZIP-level only
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