| """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): |
| |
| _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 |
| assert top["photos"] |
| |
| 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): |
| |
| _report_lost(client, seed=101, event_zip="20001") |
| _report_found(client, seed=101) |
|
|
| 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 |
| assert body["results"][0]["dog"]["kind"] == "known" |
| assert body["results"][0]["score"] > 0.99 |
|
|
| |
| 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) |
| |
| before = client.post("/search/by-photo", files={"files": ("q.jpg", _img(101), "image/jpeg")}) |
| assert before.status_code == 200 |
| |
| 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 |
| |
| assert body["results"][0]["distance_miles"] == 0.0 |
|
|
|
|
| def test_search_by_photo_zip_excludes_out_of_radius(client): |
| |
| _report_found(client, seed=101, event_zip="77002") |
|
|
| far = client.post( |
| "/search/by-photo", |
| data={"zip": "98101"}, |
| 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"}, |
| 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): |
| |
| _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")), |
| ], |
| ) |
| assert r.status_code == 200, r.text |
| body = r.json() |
| assert body["candidate_count"] == 2 |
| assert body["results"][0]["score"] > 0.99 |
|
|
|
|
| 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/") |
| assert body["images"] == 1 |
| assert len(body["breeds"]) == 3 |
| labels = body["breeds"] |
| |
| 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) |
| |
| 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 |
| ) |
| |
| assert many["breeds"][0]["score"] <= one["breeds"][0]["score"] |
| |
| assert all(b["score"] <= 1.0 for b in many["breeds"]) |
|
|
|
|
| def test_estimate_breed_clamps_top_n_and_rejects_bad_image(client): |
| |
| 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 |
|
|