"""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