"""수집 시작 e2e — 비동기 잡 적재와 진입 게이트. 수집은 한 건에 몇 분(Perplexity 10~30s + 크롤링 + Vision 사진 배치)이라 동기로 못 한다. API 는 잡만 넣고 즉시 응답하고, 클라이언트는 job_id 로 폴링한다. ★ 진입 게이트는 하나 — 동일 업소 검증. 검증 없이 긁으면 남의 가게가 섞인다. 채널 URL 발견(Perplexity)과 확정은 잡 안에서 일어나므로, 링크가 없어도 수집은 시작된다. """ from common.enums import ErrorType, JobStatus, JobType, LinkChannel, SourceType OTA = "https://www.yanolja.com/pension/1" async def _place(client, h, kakao=None, name="테스트펜션"): pid = (await client.post("/v1/place", headers=h, json={"name": name, "category": 1})).json()["place"]["place_id"] if kakao: await client.post(f"/v1/place/{pid}/verify", headers=h, json={"external_place_id": kakao}) return pid async def _confirmed_link(client, h, pid, url=OTA): lid = (await client.post(f"/v1/place/{pid}/link", headers=h, json={ "channel": LinkChannel.YANOLJA.value, "url": url, "discovered_by": SourceType.API.value, })).json()["link"]["link_id"] await client.post(f"/v1/place/{pid}/link/{lid}/confirm", headers=h) return lid async def test_collect_requires_verified_place(auth_headers, client): """검증: 동일 업소 검증 전에 수집을 시작한다. 기대결과: PLACE_NOT_VERIFIED — ★ 검증 없이는 크롤링이 열리지 않는다.""" h = await auth_headers("u1") pid = await _place(client, h) r = await client.post(f"/v1/place/{pid}/collect", headers=h, json={}) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_VERIFIED.value async def test_collect_starts_without_any_link(auth_headers, client): """검증: 링크가 하나도 없는 검증된 사업장에서 수집을 시작한다. 기대결과: 시작된다 — 채널 URL 발견(Perplexity)이 잡의 첫 단계이기 때문.""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c1") body = (await client.post(f"/v1/place/{pid}/collect", headers=h, json={})).json() assert body["result"]["success"] is True assert body["job_id"] assert body["confirmed_links"] == 0 async def test_targeted_recrawl_needs_confirmed_link(auth_headers, client): """검증: link_ids 로 특정 링크만 재크롤하라고 했는데 그게 확정 상태가 아니다. 기대결과: LINK_NOT_CONFIRMED — 콕 집은 대상이 없으면 시작할 이유가 없다.""" import uuid as _uuid h = await auth_headers("u1") pid = await _place(client, h, kakao="c2") lid = (await client.post(f"/v1/place/{pid}/link", headers=h, json={"channel": 1, "url": OTA})).json()["link"]["link_id"] r = await client.post(f"/v1/place/{pid}/collect", headers=h, json={"link_ids": [lid]}) assert r.json()["result"]["code"] == ErrorType.LINK_NOT_CONFIRMED.value async def test_collect_enqueues_job_and_returns_immediately(auth_headers, client): """검증: 검증 + 링크 확정이 끝난 뒤 수집을 시작한다. 기대결과: job_id 를 즉시 돌려주고 잡은 PENDING — 요청이 몇 분을 기다리지 않는다.""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c3") await _confirmed_link(client, h, pid) body = (await client.post(f"/v1/place/{pid}/collect", headers=h, json={})).json() assert body["result"]["success"] is True assert body["job_id"] assert body["status"] == JobStatus.PENDING.value assert body["created"] is True assert body["confirmed_links"] == 1 job = (await client.get(f"/v1/job/{body['job_id']}", headers=h)).json() assert job["job"]["job_type"] == JobType.COLLECT.value assert job["job"]["status"] == JobStatus.PENDING.value async def test_collect_marks_place_as_collecting(auth_headers, client): """검증: 수집을 시작한 뒤 사업장 상태. 기대결과: DRAFT → COLLECTING (관리 화면 배지).""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c4") await _confirmed_link(client, h, pid) await client.post(f"/v1/place/{pid}/collect", headers=h, json={}) place = (await client.get(f"/v1/place/{pid}", headers=h)).json()["place"] assert place["status"] == 2 # PlaceStatus.COLLECTING async def test_double_click_does_not_run_twice(auth_headers, client): """검증: 수집 버튼을 두 번 누른다. 기대결과: 같은 job_id 를 돌려주고 created=False — dedupe_key 로 활성 중복이 막힌다.""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c5") await _confirmed_link(client, h, pid) first = (await client.post(f"/v1/place/{pid}/collect", headers=h, json={})).json() second = (await client.post(f"/v1/place/{pid}/collect", headers=h, json={})).json() assert second["result"]["success"] is True assert second["job_id"] == first["job_id"] assert second["created"] is False async def test_default_collect_payload_does_not_freeze_existing_links(auth_headers, client): """기본 수집은 잡 안에서 새로 발견·확정되는 링크도 대상으로 삼는다.""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c6") confirmed = await _confirmed_link(client, h, pid, "https://www.yanolja.com/pension/ok") await client.post(f"/v1/place/{pid}/link", headers=h, json={"channel": 3, "url": "https://place.naver.com/nope"}) body = (await client.post(f"/v1/place/{pid}/collect", headers=h, json={})).json() assert body["confirmed_links"] == 1 from crud.job_crud import JobQueue job = await JobQueue().get(body["job_id"]) assert job["payload"]["link_ids"] == [] assert job["payload"]["place_id"] == pid assert job["payload"]["discover_channels"] is False async def test_collect_payload_carries_explicit_channel_discovery_option(auth_headers, client): """Perplexity URL 발견은 사용자가 선택한 수집 회차에만 잡 payload 로 전달한다.""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c6-discovery") body = (await client.post( f"/v1/place/{pid}/collect", headers=h, json={"discover_channels": True} )).json() from crud.job_crud import JobQueue job = await JobQueue().get(body["job_id"]) assert job["payload"]["discover_channels"] is True async def test_targeted_collect_payload_carries_requested_confirmed_link(auth_headers, client): """사용자가 특정 링크를 고른 재수집만 그 링크로 제한한다.""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c6-targeted") confirmed = await _confirmed_link(client, h, pid, "https://www.yanolja.com/pension/target") body = (await client.post( f"/v1/place/{pid}/collect", headers=h, json={"link_ids": [confirmed]} )).json() from crud.job_crud import JobQueue job = await JobQueue().get(body["job_id"]) assert job["payload"]["link_ids"] == [confirmed] async def test_collect_is_scoped_to_company(auth_headers, client, other_company_id): """검증: 다른 회사 계정으로 남의 사업장 수집을 시작한다. 기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 수집도 못 건다.""" h1 = await auth_headers("o1") pid = await _place(client, h1, kakao="c7") await _confirmed_link(client, h1, pid) h2 = await auth_headers("o2", other_company_id) r = await client.post(f"/v1/place/{pid}/collect", headers=h2, json={}) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value