PawTrace / backend /tests /test_search.py
Elliott Duke
Demo: multi-image search, breed-on-demand, Tech page, cleanup
a270696
Raw
History Blame Contribute Delete
8.62 kB
"""Public photo-search endpoint (no auth): upload a photo, rank the found/unknown pool (spec §9)."""
def _img(seed):
from scripts.make_sample_images import make_image
return make_image(seed)
def _report_found(client, seed, *, event_zip="20001"):
"""Seed a found/unknown dog with a photo (the search pool)."""
r = client.post(
"/cases/found",
data={
"event_zip": event_zip,
"event_date": "2026-06-03",
"finder_email": "finder@example.com",
},
files={"files": ("f.jpg", _img(seed), "image/jpeg")},
)
assert r.status_code == 201, r.text
return r.json()
def test_search_by_photo_ranks_identical_dog_first(client):
# Two found dogs in the pool; the matching one shares the query's image seed (identical mock vec).
_report_found(client, seed=101)
_report_found(client, seed=202)
r = client.post(
"/search/by-photo",
files={"files": ("q.jpg", _img(101), "image/jpeg")},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["candidate_count"] == 2
assert len(body["results"]) == 2
top = body["results"][0]
assert top["dog"]["kind"] == "unknown"
assert top["score"] > 0.99 # identical vectors
assert top["photos"] # results carry their photos for the viewer
# No ZIP supplied -> nationwide, no distance computed.
assert top["distance_miles"] is None
assert body["zip"] is None
def _report_lost(client, seed, *, event_zip="20001", email="owner_s@example.com"):
"""Register an owner and seed a KNOWN dog reported lost (the 'lost' search pool)."""
token = client.post(
"/auth/register",
json={"name": "O", "email": email, "password": "password123", "zip": event_zip},
).json()["access_token"]
h = {"Authorization": f"Bearer {token}"}
dog = client.post("/dogs", headers=h, json={"name": "Rex"}).json()
client.post(f"/dogs/{dog['id']}/photos", headers=h, files={"files": ("d.jpg", _img(seed), "image/jpeg")})
client.post(
"/cases/lost", headers=h,
json={"known_dog_id": dog["id"], "event_zip": event_zip, "event_date": "2026-06-01"},
)
return dog
def test_search_by_photo_lost_pool_finds_known_lost_dog(client):
# A finder searches the KNOWN/lost pool for the owner of a dog they found.
_report_lost(client, seed=101, event_zip="20001")
_report_found(client, seed=101) # same vector, but a FOUND dog — must not appear in the lost pool
body = client.post(
"/search/by-photo",
data={"pool": "lost"},
files={"files": ("q.jpg", _img(101), "image/jpeg")},
).json()
assert body["pool"] == "lost"
assert body["candidate_count"] == 1 # only the known/lost dog
assert body["results"][0]["dog"]["kind"] == "known"
assert body["results"][0]["score"] > 0.99
# The default pool is still the found/unknown side.
default = client.post(
"/search/by-photo", files={"files": ("q.jpg", _img(101), "image/jpeg")}
).json()
assert default["pool"] == "found"
assert default["results"][0]["dog"]["kind"] == "unknown"
def test_search_by_photo_requires_no_auth_and_persists_nothing(client):
_report_found(client, seed=101)
# Anonymous (no Authorization header) is allowed.
before = client.post("/search/by-photo", files={"files": ("q.jpg", _img(101), "image/jpeg")})
assert before.status_code == 200
# The query image must not have created a new dog/case — pool size is unchanged.
again = client.post("/search/by-photo", files={"files": ("q.jpg", _img(999), "image/jpeg")})
assert again.json()["candidate_count"] == 1
def test_search_by_photo_zip_scopes_and_reports_distance(client):
_report_found(client, seed=101, event_zip="20001")
r = client.post(
"/search/by-photo",
data={"zip": "20001"},
files={"files": ("q.jpg", _img(101), "image/jpeg")},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["zip"] == "20001"
assert body["radius_miles"] == 100
# Same ZIP -> distance ~0.
assert body["results"][0]["distance_miles"] == 0.0
def test_search_by_photo_zip_excludes_out_of_radius(client):
# A found dog in Houston; a query from Seattle (far) must filter it out, Houston must keep it.
_report_found(client, seed=101, event_zip="77002")
far = client.post(
"/search/by-photo",
data={"zip": "98101"}, # Seattle — ~1900 mi from Houston, outside the 100 mi radius
files={"files": ("q.jpg", _img(101), "image/jpeg")},
).json()
assert far["candidate_count"] == 0
assert far["results"] == []
near = client.post(
"/search/by-photo",
data={"zip": "77036"}, # another Houston ZIP — same metro centroid, within radius
files={"files": ("q.jpg", _img(101), "image/jpeg")},
).json()
assert near["candidate_count"] == 1
def test_search_by_photo_rejects_bad_image(client):
r = client.post(
"/search/by-photo",
files={"files": ("q.jpg", b"not an image", "image/jpeg")},
)
assert r.status_code == 400
def test_search_by_photo_accepts_multiple_images_of_the_same_dog(client):
# Two photos of the SAME dog (both seed=101) — max-over-pairs scoring should still find it.
_report_found(client, seed=101)
_report_found(client, seed=202)
r = client.post(
"/search/by-photo",
files=[
("files", ("q1.jpg", _img(101), "image/jpeg")),
("files", ("q2.jpg", _img(999), "image/jpeg")), # a second, unrelated angle
],
)
assert r.status_code == 200, r.text
body = r.json()
assert body["candidate_count"] == 2
assert body["results"][0]["score"] > 0.99 # the seed=101 photo still finds its exact match
def test_search_by_photo_caps_at_six_images(client):
_report_found(client, seed=101)
files = [("files", (f"q{i}.jpg", _img(101), "image/jpeg")) for i in range(9)]
r = client.post("/search/by-photo", files=files)
assert r.status_code == 200, r.text
assert r.json()["results"][0]["score"] > 0.99
def test_estimate_breed_returns_top_n_ranked_labels(client):
r = client.post(
"/search/breed",
data={"top_n": "3"},
files={"files": ("q.jpg", _img(101), "image/jpeg")},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["model"].startswith("mock-breed/") # mock classifier in tests
assert body["images"] == 1
assert len(body["breeds"]) == 3
labels = body["breeds"]
# Each entry is {label, score}; scores are ranked descending.
assert all("label" in b and "score" in b for b in labels)
assert [b["score"] for b in labels] == sorted((b["score"] for b in labels), reverse=True)
# Same image -> deterministic labels (mock classifier is hash-based).
again = client.post(
"/search/breed", data={"top_n": "3"}, files={"files": ("q.jpg", _img(101), "image/jpeg")}
).json()
assert [b["label"] for b in again["breeds"]] == [b["label"] for b in labels]
def test_estimate_breed_averages_across_multiple_images(client):
"""Several photos of a dog are averaged, so one odd angle can't decide the breed alone."""
one = client.post(
"/search/breed", data={"top_n": "5"}, files={"files": ("a.jpg", _img(101), "image/jpeg")}
).json()
many = client.post(
"/search/breed",
data={"top_n": "5"},
files=[
("files", ("a.jpg", _img(101), "image/jpeg")),
("files", ("b.jpg", _img(202), "image/jpeg")),
("files", ("c.jpg", _img(303), "image/jpeg")),
],
).json()
assert many["images"] == 3
assert [b["score"] for b in many["breeds"]] == sorted(
(b["score"] for b in many["breeds"]), reverse=True
)
# Averaging over differing images pulls the top score below the single-image case.
assert many["breeds"][0]["score"] <= one["breeds"][0]["score"]
# A label seen in only one of three images is damped to at most a third of its score.
assert all(b["score"] <= 1.0 for b in many["breeds"])
def test_estimate_breed_clamps_top_n_and_rejects_bad_image(client):
# top_n is clamped to 10; ask for more and never get more than the classifier provides.
r = client.post(
"/search/breed", data={"top_n": "50"}, files={"files": ("q.jpg", _img(7), "image/jpeg")}
)
assert r.status_code == 200
assert len(r.json()["breeds"]) <= 10
bad = client.post("/search/breed", files={"files": ("q.jpg", b"nope", "image/jpeg")})
assert bad.status_code == 400