"""Gemini Vision 클라이언트 — 사진 분류·alt 생성의 계약.""" import base64 import json import struct import zlib import httpx import pytest from common.enums import PlaceCategory from services.external import gemini from services.llm import gemini as llm from services.external.gemini import ( GeminiError, GeminiNotConfigured, ImageInput, analyze_images, ) # 도구 def _png(rgb=(10, 20, 30), size=8) -> bytes: """테스트용 최소 PNG.""" def chunk(tag, data): body = tag + data return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) raw = b"".join(b"\x00" + bytes(rgb) * size for _ in range(size)) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")) def _inputs(n: int) -> list[ImageInput]: return [ImageInput(origin_url=f"https://ota.test/p{i}.png", data=_png((i * 20 % 255, 40, 60))) for i in range(n)] def _reply(items: list[dict], *, prompt_tokens=1000, out_tokens=50) -> dict: """generateContent 성공 응답 흉내.""" return { "candidates": [{ "content": {"parts": [{"text": json.dumps({"items": items}, ensure_ascii=False), "thoughtSignature": "xxx"}]}, "finishReason": "STOP", }], "usageMetadata": {"promptTokenCount": prompt_tokens, "candidatesTokenCount": out_tokens}, } def _item(ref, label="침실", alt="침대와 협탁이 놓인 방", conf=0.95): return {"ref": ref, "label": label, "alt_text": alt, "confidence": conf} def _client(handler) -> httpx.AsyncClient: return httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=5.0) @pytest.fixture(autouse=True) def _api_key(monkeypatch): """테스트 환경엔 키가 없다(의도된 것) — 클라이언트 경로를 타려면 넣어줘야 한다.""" monkeypatch.setattr(llm.external_api_config, "gemini_api_key", "test-key") # 기본 경로 async def test_parses_label_alt_and_confidence(): """검증: 정상 응답 1장.""" def handler(request): return httpx.Response(200, json=_reply([_item("img-0", "침실", "침대와 협탁이 놓인 방", 0.93)])) async with _client(handler) as c: res = await analyze_images(_inputs(1), category=PlaceCategory.LODGING, client=c) assert len(res) == 1 assert res[0].ok is True assert res[0].label == "침실" assert res[0].alt_text == "침대와 협탁이 놓인 방" assert res[0].confidence == pytest.approx(0.93) assert res[0].needs_review is False async def test_result_length_always_matches_input(): """검증: 입력 5장인데 응답에 3장만 온다.""" def handler(request): return httpx.Response(200, json=_reply([_item("img-0"), _item("img-1"), _item("img-2")])) images = _inputs(5) async with _client(handler) as c: res = await analyze_images(images, client=c) assert len(res) == 5 assert [r.origin_url for r in res] == [i.origin_url for i in images] assert all(r.ok for r in res[:3]) for r in res[3:]: assert r.ok is False and r.needs_review is True assert "ref" in (r.error or "") async def test_matches_by_ref_not_by_order(): """검증: 모델이 순서를 뒤집어 돌려준다(img-2, img-0, img-1).""" def handler(request): return httpx.Response(200, json=_reply([ _item("img-2", "주방", "싱크대와 조리대"), _item("img-0", "외관", "건물 정면"), _item("img-1", "욕실", "세면대와 샤워부스"), ])) images = _inputs(3) async with _client(handler) as c: res = await analyze_images(images, client=c) by_url = {r.origin_url: r for r in res} assert by_url[images[0].origin_url].label == "외관" assert by_url[images[1].origin_url].label == "욕실" assert by_url[images[2].origin_url].label == "주방" async def test_unknown_ref_in_response_is_discarded(): """검증: 모델이 존재하지 않는 ref(img-99)를 지어낸다.""" def handler(request): return httpx.Response(200, json=_reply([_item("img-99", "침실")])) async with _client(handler) as c: res = await analyze_images(_inputs(1), client=c) assert len(res) == 1 assert res[0].ok is False and res[0].needs_review is True # 신뢰도 게이트 async def test_low_confidence_goes_to_review_queue(): """검증: 신뢰도 0.4 로 돌아온 사진(임계값 0.7).""" def handler(request): return httpx.Response(200, json=_reply([_item("img-0", "침실", "흐릿한 실내", 0.4)])) async with _client(handler) as c: res = await analyze_images(_inputs(1), confidence_threshold=0.7, client=c) assert res[0].ok is True assert res[0].needs_review is True async def test_threshold_is_configurable(): """검증: 같은 0.4 응답에 임계값을 0.3 으로 낮춘다.""" def handler(request): return httpx.Response(200, json=_reply([_item("img-0", "침실", "실내", 0.4)])) async with _client(handler) as c: res = await analyze_images(_inputs(1), confidence_threshold=0.3, client=c) assert res[0].needs_review is False async def test_empty_label_forces_review(): """검증: 신뢰도는 높은데 label 이 빈 문자열이다.""" def handler(request): return httpx.Response(200, json=_reply([_item("img-0", "", "설명", 0.99)])) async with _client(handler) as c: res = await analyze_images(_inputs(1), client=c) assert res[0].label is None assert res[0].needs_review is True # 배치 async def test_splits_into_batches(): """검증: 12장을 batch_size=5 로 보낸다.""" calls = [] def handler(request): body = json.loads(request.content) refs = [p["text"].split("]")[0][1:] for p in body["contents"][0]["parts"] if "text" in p and p["text"].startswith("[img-")] calls.append(len(refs)) return httpx.Response(200, json=_reply([_item(r) for r in refs])) async with _client(handler) as c: res = await analyze_images(_inputs(12), batch_size=5, client=c) assert calls == [5, 5, 2] assert len(res) == 12 assert all(r.ok for r in res) async def test_one_failed_batch_does_not_kill_the_rest(): """검증: 3배치 중 두 번째만 500 을 반환한다.""" state = {"n": 0} def handler(request): state["n"] += 1 body = json.loads(request.content) refs = [p["text"][1:-1] for p in body["contents"][0]["parts"] if "text" in p and p["text"].startswith("[img-")] if state["n"] == 2: return httpx.Response(500, text="boom") return httpx.Response(200, json=_reply([_item(r) for r in refs])) async with _client(handler) as c: res = await analyze_images(_inputs(6), batch_size=2, max_retries=0, client=c) assert len(res) == 6 assert [r.ok for r in res] == [True, True, False, False, True, True] assert all(r.needs_review for r in res[2:4]) # 재시도 async def test_retries_5xx_then_succeeds(): """검증: 첫 호출 503, 두 번째 200. 기대결과: 재시도로 성공한다 — 일시적 장애로 사진을 버리지 않는다.""" state = {"n": 0} def handler(request): state["n"] += 1 if state["n"] == 1: return httpx.Response(503, text="unavailable") return httpx.Response(200, json=_reply([_item("img-0")])) async with _client(handler) as c: res = await analyze_images(_inputs(1), max_retries=2, client=c) assert state["n"] == 2 assert res[0].ok is True async def test_does_not_retry_4xx(): """검증: 400(잘못된 요청)을 반환한다.""" state = {"n": 0} def handler(request): state["n"] += 1 return httpx.Response(400, text="bad request") async with _client(handler) as c: res = await analyze_images(_inputs(1), max_retries=3, client=c) assert state["n"] == 1 assert res[0].ok is False async def test_timeout_is_retried_then_reported(): """검증: 매번 타임아웃이 난다.""" state = {"n": 0} def handler(request): state["n"] += 1 raise httpx.ReadTimeout("timed out", request=request) async with _client(handler) as c: res = await analyze_images(_inputs(1), max_retries=1, client=c) assert state["n"] == 2 assert res[0].ok is False and res[0].needs_review is True # 오류 처리 async def test_broken_json_fails_only_that_batch(): """검증: 구조화 출력이 깨진 JSON 으로 온다.""" def handler(request): return httpx.Response(200, json={ "candidates": [{"content": {"parts": [{"text": "{items: [oops"}]}, "finishReason": "STOP"}] }) async with _client(handler) as c: res = await analyze_images(_inputs(2), max_retries=0, client=c) assert len(res) == 2 assert all(r.ok is False and r.needs_review for r in res) async def test_safety_blocked_response_is_handled(): """검증: candidates 가 비어 오는 경우(안전 필터 차단).""" def handler(request): return httpx.Response(200, json={"candidates": []}) async with _client(handler) as c: res = await analyze_images(_inputs(1), max_retries=0, client=c) assert res[0].ok is False async def test_missing_key_raises_not_configured(monkeypatch): """검증: GEMINI_API_KEY 가 비어 있다.""" monkeypatch.setattr(llm.external_api_config, "gemini_api_key", "") assert llm.is_configured() is False with pytest.raises(GeminiNotConfigured): await analyze_images(_inputs(1)) async def test_auth_error_stops_everything(): """검증: 401 이 돌아온다.""" def handler(request): return httpx.Response(401, text="unauthorized") async with _client(handler) as c: with pytest.raises(GeminiNotConfigured): await analyze_images(_inputs(4), batch_size=2, client=c) async def test_image_download_failure_is_isolated(): """검증: 바이트 없이 URL 만 준 사진의 내려받기가 실패한다.""" def handler(request): if request.method == "GET": return httpx.Response(404) body = json.loads(request.content) refs = [p["text"][1:-1] for p in body["contents"][0]["parts"] if "text" in p and p["text"].startswith("[img-")] return httpx.Response(200, json=_reply([_item(r) for r in refs])) images = [ImageInput(origin_url="https://ota.test/gone.png"), _inputs(1)[0]] async with _client(handler) as c: res = await analyze_images(images, client=c) assert len(res) == 2 assert res[0].ok is False and "로드 실패" in res[0].error assert res[1].ok is True async def test_empty_input_returns_empty(): """검증: 빈 목록을 넘긴다.""" assert await analyze_images([]) == [] # 프롬프트 계약 async def test_prompt_forbids_promotional_adjectives(): """검증: 실제로 보내는 요청 본문의 지시문.""" captured = {} def handler(request): captured["body"] = json.loads(request.content) return httpx.Response(200, json=_reply([_item("img-0")])) async with _client(handler) as c: await analyze_images(_inputs(1), category=PlaceCategory.LODGING, client=c) prompt = captured["body"]["contents"][0]["parts"][0]["text"] assert "아름다운" in prompt and "홍보성" in prompt assert "지어내지 마라" in prompt assert "보이는 것만" in prompt async def test_prompt_uses_category_vocabulary(): """검증: 업종별 라벨 어휘.""" captured = {} def handler(request): captured.setdefault("prompts", []).append( json.loads(request.content)["contents"][0]["parts"][0]["text"] ) return httpx.Response(200, json=_reply([_item("img-0")])) async with _client(handler) as c: await analyze_images(_inputs(1), category=PlaceCategory.LODGING, client=c) await analyze_images(_inputs(1), category=PlaceCategory.CAFE, client=c) assert "침실" in captured["prompts"][0] assert "디저트" in captured["prompts"][1] async def test_unit_names_are_passed_as_hint(): """검증: 객실 이름 후보를 넘긴다.""" captured = {} def handler(request): captured["prompt"] = json.loads(request.content)["contents"][0]["parts"][0]["text"] return httpx.Response(200, json=_reply([_item("img-0")])) async with _client(handler) as c: await analyze_images(_inputs(1), unit_names=["A동 스탠다드", "B동 복층"], client=c) assert "A동 스탠다드" in captured["prompt"] async def test_structured_output_schema_is_sent(): """검증: 요청의 generationConfig.""" captured = {} def handler(request): captured["body"] = json.loads(request.content) return httpx.Response(200, json=_reply([_item("img-0")])) async with _client(handler) as c: await analyze_images(_inputs(1), client=c) cfg = captured["body"]["generationConfig"] assert cfg["responseMimeType"] == "application/json" assert cfg["responseSchema"]["properties"]["items"]["items"]["required"] == [ "ref", "label", "alt_text", "confidence" ] assert cfg["temperature"] == 0 async def test_png_mime_is_sniffed_not_guessed(): """검증: PNG 바이트를 mime_type 없이 넘긴다.""" captured = {} def handler(request): captured["body"] = json.loads(request.content) return httpx.Response(200, json=_reply([_item("img-0")])) async with _client(handler) as c: await analyze_images(_inputs(1), client=c) inline = [p["inline_data"] for p in captured["body"]["contents"][0]["parts"] if "inline_data" in p] assert inline[0]["mime_type"] == "image/png" assert base64.b64decode(inline[0]["data"]).startswith(b"\x89PNG") async def test_confidence_is_clamped(): """검증: 모델이 범위 밖 신뢰도(1.7, -0.2)를 준다.""" def handler(request): return httpx.Response(200, json=_reply([ _item("img-0", conf=1.7), _item("img-1", conf=-0.2), ])) async with _client(handler) as c: res = await analyze_images(_inputs(2), client=c) assert res[0].confidence == 1.0 assert res[1].confidence == 0.0 assert res[1].needs_review is True