"""일정 생성 오케스트레이션 — 언제 부르고 언제 안 부르나. ★ 실제 Perplexity 를 부르지 않는다. 유료·느리고, APP_ENV=test 는 .env 를 안 읽어 키도 없다. `perplexity.call` 을 가로채 호출 횟수와 프롬프트를 본다. 이 서비스가 지켜야 하는 것: - 이미 있는 기간은 다시 부르지 않는다(같은 업체는 그대로 재사용 — 2026-09-11 결정) - 지역을 모르면 아예 부르지 않는다(모델이 아무 도시나 고른다) - 한 기간이 실패해도 다른 기간은 저장한다 """ import json import uuid import pytest from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import place_itineraries, places from common.enums import DBWRType, ErrorType, PlaceCategory, SourceType from crud.place_itinerary_crud import PlaceItineraryCRUD from services import itinerary_llm_service as service from services.llm import perplexity from services.prompts import itinerary as prompts from services.prompts.itinerary import DAY_SCHEDULE class _FakePlace: """ORM 행 대신 쓰는 최소 객체 — 서비스는 getattr 로만 읽는다.""" def __init__(self, place_id, name="스테이,머뭄", road_address="전북특별자치도 군산시 절골길 18"): self.place_id = place_id self.name = name self.road_address = road_address self.address = road_address def _response(names: list[str], duration: str) -> dict: """가짜 Perplexity 응답. `_apply_schedule`(grounding)이 하루 수·시각을 검사하므로 DAY_SCHEDULE 이 요구하는 일수만큼 하루를 채운다 — 안 채우면 전부 드롭된다(실측 회귀).""" items = [ {"name": n, "duration": duration, "audience": "누구에게나", "why": "이유.", "days": [ {"stops": [{"name": f"{n}-{day}일차-장소{i}", "minutes": 60, "moveMinutes": 10, "searchQuery": f"{n}-{day}일차-장소{i}", "latitude": 35.99, "longitude": 126.71} for i in range(2)]} for day in range(len(DAY_SCHEDULE[duration])) ]} for n in names ] return { "choices": [{"message": {"content": json.dumps( {"kind": "itinerary", "version": 1, "items": items}, ensure_ascii=False)}}], "search_results": [{"title": "군산문화관광", "url": "https://www.gunsan.go.kr/tour/"}], } @pytest.fixture def spy_perplexity(monkeypatch): """perplexity.call 을 가로채고 (호출 기록, 응답 설정) 을 준다.""" calls: list[dict] = [] plan: dict = {} async def fake_call(body, *, client=None): calls.append(body) duration = "2박 3일" if "2박 3일" in body["messages"][1]["content"] else "1박 2일" outcome = plan.get(duration, "ok") if outcome == "error": raise perplexity.PerplexityError("일부러 실패") if outcome == "garbage": return {"choices": [{"message": {"content": "일정을 만들 수 없습니다."}}]} return _response([f"{duration} 코스{i}" for i in range(5)], duration) monkeypatch.setattr(perplexity, "call", fake_call) monkeypatch.setattr(perplexity, "is_configured", lambda: True) return calls, plan async def _rows(place_id): err, rows = await DB_SESSION_MNG.execute_lambda( place_itineraries.DBType(), DBWRType.DB_READ.value, lambda s: PlaceItineraryCRUD().list_by_place(s, place_id), ) assert err == ErrorType.SUCCESS return list(rows or []) async def test_generates_both_durations(db_engine, spy_perplexity): """검증: 아무것도 없는 업장. 기대결과: 기간 둘을 각각 한 번씩 부르고(총 2회) 각각 5개 코스를 저장한다.""" calls, _ = spy_perplexity pid = uuid.uuid4() out = await service.ensure_generated(_FakePlace(pid)) assert len(calls) == 2 assert out["counts"] == {"1박 2일": 5, "2박 3일": 5} rows = await _rows(pid) assert {r.duration for r in rows} == {"1박 2일", "2박 3일"} assert all(r.generated_by == SourceType.LLM.value for r in rows) assert all(r.model == f"perplexity:{perplexity.DEFAULT_MODEL}" for r in rows) async def test_does_not_call_again_for_durations_already_stored(db_engine, spy_perplexity): """검증: 한 번 만든 업장을 다시 부른다. 기대결과: 추가 호출 0회 — 유료 호출을 두 번 하지 않는다(요금 가드).""" calls, _ = spy_perplexity pid = uuid.uuid4() await service.ensure_generated(_FakePlace(pid)) calls.clear() out = await service.ensure_generated(_FakePlace(pid)) assert calls == [] assert out["counts"] == {} assert "이미 있다" in " ".join(out["notes"]) async def test_fills_only_the_missing_duration(db_engine, spy_perplexity): """검증: 1박2일만 있는 업장. 기대결과: 2박3일만 부른다 — missing_durations 가 기준이다.""" calls, _ = spy_perplexity pid = uuid.uuid4() await service.ensure_generated(_FakePlace(pid)) # 2박 3일 행만 지운다(직접 SQL — 서비스에 삭제 창구가 없다) from sqlalchemy import delete await DB_SESSION_MNG.execute_lambda_run( [place_itineraries.DBType()], [lambda s: DB_SESSION_MNG.add(s, delete(place_itineraries).where( place_itineraries.place_id == pid, place_itineraries.duration == "2박 3일"))], ) calls.clear() out = await service.ensure_generated(_FakePlace(pid)) assert len(calls) == 1 assert "2박 3일" in calls[0]["messages"][1]["content"] assert out["counts"] == {"2박 3일": 5} async def test_skips_when_region_is_unknown(db_engine, spy_perplexity): """검증: 주소가 없는 업장. 기대결과: 호출하지 않는다 — 지역을 모른 채 물으면 모델이 아무 도시나 고른다.""" calls, _ = spy_perplexity place = _FakePlace(uuid.uuid4(), road_address="") place.address = "" out = await service.ensure_generated(place) assert calls == [] assert "지역" in " ".join(out["notes"]) async def test_skips_when_key_is_missing(db_engine, monkeypatch): """검증: PERPLEXITY_API_KEY 미설정. 기대결과: 조용히 건너뛴다 — 키가 없다고 빌드나 에디터를 막지 않는다.""" monkeypatch.setattr(perplexity, "is_configured", lambda: False) out = await service.ensure_generated(_FakePlace(uuid.uuid4())) assert out["counts"] == {} assert "PERPLEXITY_API_KEY" in " ".join(out["notes"]) async def test_one_duration_failing_does_not_lose_the_other(db_engine, spy_perplexity): """검증: 1박2일 호출이 실패한다. 기대결과: 2박3일은 저장된다 — 한 기간의 실패가 다른 기간을 끌고 내려가지 않는다.""" calls, plan = spy_perplexity plan["1박 2일"] = "error" pid = uuid.uuid4() out = await service.ensure_generated(_FakePlace(pid)) assert out["counts"] == {"2박 3일": 5} assert [r.duration for r in await _rows(pid)] == ["2박 3일"] async def test_unparseable_response_stores_nothing_for_that_duration(db_engine, spy_perplexity): """검증: JSON 이 아닌 응답. 기대결과: 그 기간은 저장하지 않는다(빈 body 로 행을 만들지 않는다).""" _calls, plan = spy_perplexity plan["2박 3일"] = "garbage" pid = uuid.uuid4() out = await service.ensure_generated(_FakePlace(pid)) assert out["counts"] == {"1박 2일": 5} assert [r.duration for r in await _rows(pid)] == ["1박 2일"] async def test_prompt_carries_place_and_region(db_engine, spy_perplexity): """검증: 실제로 보낸 프롬프트. 기대결과: 상호와 지역(주소 앞 두 토막)이 들어간다.""" calls, _ = spy_perplexity await service.ensure_generated(_FakePlace(uuid.uuid4())) user_prompt = calls[0]["messages"][1]["content"] assert "스테이,머뭄" in user_prompt assert "전북특별자치도 군산시" in user_prompt assert calls[0]["messages"][0]["content"] == prompts.SYSTEM_PROMPT assert calls[0]["max_tokens"] == prompts.MAX_TOKENS async def test_get_itineraries_returns_courses_in_duration_order(db_engine, spy_perplexity): """검증: 읽기. 기대결과: DURATIONS 순(1박2일 → 2박3일)으로 이어 붙인 ItineraryItem[] 이다 — 화면 탭 순서가 저장 순서에 흔들리지 않아야 한다.""" pid = uuid.uuid4() await service.ensure_generated(_FakePlace(pid)) items = await service.get_itineraries(pid) assert len(items) == 10 assert [i["duration"] for i in items[:5]] == ["1박 2일"] * 5 assert [i["duration"] for i in items[5:]] == ["2박 3일"] * 5 async def test_missing_durations_reports_what_is_absent(db_engine, spy_perplexity): """검증: missing_durations. 기대결과: 비었을 때 둘 다, 채운 뒤엔 빈 목록 — 잡 가드가 이 값을 본다.""" pid = uuid.uuid4() assert await service.missing_durations(pid) == list(prompts.DURATIONS) await service.ensure_generated(_FakePlace(pid)) assert await service.missing_durations(pid) == [] async def test_ensure_generated_by_id_loads_the_place(db_engine, spy_perplexity, owner_id): """검증: 잡이 쓰는 입구(place_id 만 있다). 기대결과: places 행을 읽어 같은 일을 한다.""" calls, _ = spy_perplexity pid = uuid.uuid4() from sqlalchemy import text async with db_engine.begin() as conn: await conn.execute( text("INSERT INTO places (place_id, owner_user_id, name, category, road_address, status) " "VALUES (:p, :o, :n, :c, :r, 1)"), {"p": pid, "o": uuid.UUID(owner_id), "n": "스테이,머뭄", "c": PlaceCategory.LODGING.value, "r": "전북특별자치도 군산시 절골길 18"}, ) out = await service.ensure_generated_by_id(pid) assert len(calls) == 2 assert out["counts"] == {"1박 2일": 5, "2박 3일": 5} async def test_ensure_generated_by_id_on_unknown_place(db_engine, spy_perplexity): """검증: 없는 업장 id. 기대결과: 호출하지 않고 이유만 남긴다 — 잡이 죽지 않는다.""" calls, _ = spy_perplexity out = await service.ensure_generated_by_id(uuid.uuid4()) assert calls == [] assert out["counts"] == {} assert out["notes"] async def test_lodging_coordinates_flow_from_place_to_generated_stops(db_engine, spy_perplexity): """검증: place 에 좌표가 있으면 저장된 일정의 업소 정거장에도 실린다(2026-09-11 결정: "업소는 출발지에 포함"). 기대결과: ensure_generated 가 place.latitude/longitude 를 grounding 의 _apply_schedule 까지 넘긴다 — 모델에게 묻지 않는다.""" place = _FakePlace(uuid.uuid4()) place.latitude = 35.98642 place.longitude = 126.70612 await service.ensure_generated(place) items = await service.get_itineraries(place.place_id) lodging = items[0]["days"][0]["stops"][0] assert lodging["name"] == "스테이,머뭄" assert (lodging["latitude"], lodging["longitude"]) == (35.98642, 126.70612)