diff --git a/collectors/kopis.py b/collectors/kopis.py index 91c63d0..d0f3f85 100644 --- a/collectors/kopis.py +++ b/collectors/kopis.py @@ -124,12 +124,16 @@ def _parse_concert(item: ET.Element) -> dict: def search_concerts(title: str) -> list[dict]: - """공연명으로 KOPIS 검색. 2020-01-01~1년 후 범위, 최대 20건 반환.""" + """공연명으로 KOPIS 검색 (전체 장르 대상). 2020-01-01~1년 후 범위, 최대 20건 반환. + + 어드민이 자동 매칭에서 누락된 공연을 수동으로 찾는 용도이므로 + genrenm(장르) 제약 없이 KOPIS 전체 장르를 검색 대상으로 한다. + """ today = datetime.date.today() stdate = _SEARCH_STDATE eddate = (today + datetime.timedelta(days=_DEFAULT_LOOKAHEAD_DAYS)).strftime("%Y%m%d") params = { - **_DEFAULT_PARAMS, + "service": _API_KEY, "shprfnm": title, "stdate": stdate, "eddate": eddate, diff --git a/db/repository.py b/db/repository.py index 65d075f..d61b52d 100644 --- a/db/repository.py +++ b/db/repository.py @@ -159,16 +159,42 @@ def save_releases(artist_id: int, releases: list[dict]) -> None: logger.info("릴리즈 저장 완료: %d / %d건 처리", saved, len(releases)) -def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> None: +def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set: """수집된 공연 목록을 concert 테이블에 저장한다. kopis_id 중복 시 무시. + title·start_date·end_date가 모두 일치하는 공연이 이미 존재하면(예: 어드민이 + kopis_id 없이 수동 등록한 공연과 동일 공연) 신규 저장을 건너뛴다. + use_prfstate=True이면 prfstate → _KOPIS_STATUS_MAP으로 status를 결정한다. 기본값(False)은 배치 수집용 PENDING을 사용한다. + + 반환값: title·기간 중복으로 저장을 건너뛴 concert들의 kopis_id 집합 """ saved = 0 + duplicate_kopis_ids = set() for concert in concerts: try: with get_session() as session: + title_match = session.execute( + text(""" + SELECT id FROM concert + WHERE title = :title AND start_date = :start_date + AND end_date = :end_date + """), + { + "title": concert["prfnm"], + "start_date": concert["prfpdfrom"], + "end_date": concert["prfpdto"], + }, + ).fetchone() + if title_match is not None: + logger.info( + "동일 공연명·기간 이미 존재 — 저장 건너뜀: title=%s, kopis_id=%s", + concert["prfnm"], concert["kopis_id"], + ) + duplicate_kopis_ids.add(concert["kopis_id"]) + continue + status = ( _KOPIS_STATUS_MAP.get(concert.get("prfstate"), "PENDING") if use_prfstate @@ -237,7 +263,11 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> None: saved += 1 except SQLAlchemyError as e: logger.error("공연 저장 실패 — 건너뜀: kopis_id=%s, 오류=%s", concert["kopis_id"], e) - logger.info("공연 저장 완료: %d / %d건 처리", saved, len(concerts)) + logger.info( + "공연 저장 완료: %d / %d건 처리 (title·기간 중복 %d건 제외)", + saved, len(concerts), len(duplicate_kopis_ids), + ) + return duplicate_kopis_ids def get_completed_concerts() -> list[dict]: diff --git a/scheduler.py b/scheduler.py index f7746f8..b633862 100644 --- a/scheduler.py +++ b/scheduler.py @@ -657,30 +657,29 @@ def run_setlist_collect() -> None: def collect_and_save_concert(kopis_id: str) -> dict: """단건 KOPIS 공연을 수집해 alias 매칭 후 DB에 저장한다. - KOPIS에 데이터가 없으면 {"status": "not_found"}, 내한 공연이 아니거나 - alias 매칭이 없으면 {"status": "skipped", "reason": ...}, 성공 시 - {"status": "ok", "concert_id", "title", "matched_artists"}를 반환한다. + 어드민이 KOPIS에서 직접 골라 트리거하는 수집이므로 (자동 매칭에서 누락된 공연을 + 구제하는 용도) 내한 여부·alias 매칭 여부와 무관하게 저장한다. + + KOPIS에 데이터가 없으면 {"status": "not_found"}, 동일 title·기간의 공연이 + 이미 존재하면(예: 어드민 수동 등록과 중복) {"status": "skipped", + "reason": "duplicate_title"}, 성공 시 {"status": "ok", "concert_id", "title", + "matched_artists"}를 반환한다. 저장 직후 재조회에 실패하면 (있어선 안 되는 내부 불일치) RuntimeError를 발생시킨다. """ concert = kopis.collect_by_id(kopis_id) if concert is None: logger.warning("KOPIS 공연 데이터 없음: kopis_id=%s", kopis_id) return {"status": "not_found"} - if concert.get("visit") != "Y": - logger.info("내한 공연 아님 — 저장 건너뜀: kopis_id=%s", kopis_id) - return {"status": "skipped", "reason": "not_touring"} - - aliases = get_all_aliases() - if not has_match(concert, aliases): - logger.info("alias 매칭 없음 — 저장 건너뜀: kopis_id=%s", kopis_id) - return {"status": "skipped", "reason": "no_alias_match"} - save_concerts([concert], use_prfstate=True) + duplicate_kopis_ids = save_concerts([concert], use_prfstate=True) + if kopis_id in duplicate_kopis_ids: + return {"status": "skipped", "reason": "duplicate_title"} concert_row = get_concert_by_kopis_id(kopis_id) if concert_row is None: raise RuntimeError(f"공연 저장 후 조회 실패: kopis_id={kopis_id}") + aliases = get_all_aliases() matched_artists = [] matches, _ = match_concert(concert_row, aliases) if matches: diff --git a/tests/test_api.py b/tests/test_api.py index 6124fa0..de25c2d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -74,14 +74,14 @@ def test_returns_404_when_not_found(self, client): assert res.status_code == 404 def test_returns_200_with_success_false_when_skipped(self, client): - """비즈니스 스킵(내한 아님 등)이면 200 + success:false를 반환해야 한다.""" + """비즈니스 스킵이면 200 + success:false를 반환해야 한다.""" with patch( "api.collect_and_save_concert", - return_value={"status": "skipped", "reason": "not_touring"}, + return_value={"status": "skipped", "reason": "some_reason"}, ): res = client.post("/collect/concert", json={"kopis_id": "PF001"}, headers=_AUTH) assert res.status_code == 200 - assert res.json() == {"success": False, "reason": "not_touring"} + assert res.json() == {"success": False, "reason": "some_reason"} def test_called_with_kopis_id(self, client): """collect_and_save_concert가 kopis_id로 직접 호출되어야 한다.""" diff --git a/tests/test_kopis.py b/tests/test_kopis.py index a3004d0..25f394b 100644 --- a/tests/test_kopis.py +++ b/tests/test_kopis.py @@ -622,6 +622,7 @@ def _make_concert(self, **kwargs) -> dict: def test_insert_sql_contains_on_conflict(self): """INSERT SQL에 ON CONFLICT가 포함되어야 한다.""" mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -637,6 +638,7 @@ def test_insert_sql_contains_on_conflict(self): def test_insert_sql_includes_poster_url(self): """INSERT SQL에 poster_url 컬럼이 포함되어야 한다.""" mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -651,6 +653,7 @@ def test_insert_sql_includes_poster_url(self): def test_poster_url_param_passed(self): """INSERT 파라미터에 poster_url 값이 전달되어야 한다.""" mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -665,6 +668,7 @@ def test_poster_url_param_passed(self): def test_insert_sql_includes_price(self): """INSERT SQL에 price 컬럼이 포함되어야 한다.""" mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -679,6 +683,7 @@ def test_insert_sql_includes_price(self): def test_price_param_passed(self): """INSERT 파라미터에 price 값이 전달되어야 한다.""" mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -693,7 +698,7 @@ def test_price_param_passed(self): def test_booking_links_inserted_for_new_concert(self): """신규 공연 저장 시 relates가 concert_booking_link 테이블에 별도 INSERT되어야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.return_value = (1,) + mock_session.execute.return_value.fetchone.side_effect = [None, (1,)] with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -713,7 +718,7 @@ def test_booking_links_inserted_for_new_concert(self): def test_booking_links_inserted_for_existing_concert(self): """기존 공연(INSERT DO NOTHING)도 SELECT fallback으로 booking_link가 INSERT되어야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.side_effect = [None, (5,)] + mock_session.execute.return_value.fetchone.side_effect = [None, None, (5,)] with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -731,7 +736,7 @@ def test_booking_links_inserted_for_existing_concert(self): def test_booking_link_insert_has_on_conflict(self): """concert_booking_link INSERT에 ON CONFLICT DO NOTHING이 포함되어야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.return_value = (1,) + mock_session.execute.return_value.fetchone.side_effect = [None, (1,)] with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -749,7 +754,7 @@ def test_booking_link_insert_has_on_conflict(self): def test_concert_image_inserted_for_new_concert(self): """신규 공연 저장 시 still_urls가 concert_image에 position 순으로 INSERT되어야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.return_value = (1,) + mock_session.execute.return_value.fetchone.side_effect = [None, (1,)] concert = self._make_concert(still_urls=["http://still1.jpg", "http://still2.jpg"]) with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) @@ -768,7 +773,7 @@ def test_concert_image_inserted_for_new_concert(self): def test_concert_image_not_inserted_for_existing_concert(self): """기존 공연(RETURNING None)이면 concert_image INSERT가 실행되지 않아야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.side_effect = [None, (5,)] + mock_session.execute.return_value.fetchone.side_effect = [None, None, (5,)] concert = self._make_concert(still_urls=["http://still1.jpg"]) with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) @@ -780,10 +785,40 @@ def test_concert_image_not_inserted_for_existing_concert(self): ] assert len(image_inserts) == 0 + def test_skips_insert_when_title_and_dates_match_existing(self): + """title·start_date·end_date가 모두 일치하는 공연이 있으면 INSERT를 건너뛴다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = (99,) + with patch("db.repository.get_session") as mock_get_session: + mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) + mock_get_session.return_value.__exit__ = MagicMock(return_value=False) + duplicates = save_concerts([self._make_concert()]) + + insert_sqls = [ + str(c.args[0]) for c in mock_session.execute.call_args_list + if "INSERT INTO concert" in str(c.args[0]) + ] + assert insert_sqls == [] + assert duplicates == {"PF123456"} + + def test_title_date_check_uses_concert_fields(self): + """중복 검사 쿼리에 title·start_date·end_date 파라미터가 전달되어야 한다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None + with patch("db.repository.get_session") as mock_get_session: + mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) + mock_get_session.return_value.__exit__ = MagicMock(return_value=False) + save_concerts([self._make_concert()]) + + check_call = mock_session.execute.call_args_list[0] + assert check_call.args[1] == { + "title": "공연명", "start_date": "2024-01-01", "end_date": "2024-01-31", + } + def test_concert_image_not_inserted_when_still_urls_empty(self): """still_urls가 빈 리스트이면 concert_image INSERT가 실행되지 않아야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.return_value = (1,) + mock_session.execute.return_value.fetchone.side_effect = [None, (1,)] concert = self._make_concert(still_urls=[]) with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 3139ae4..e52b3b1 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -654,29 +654,25 @@ def test_returns_not_found_when_kopis_has_no_data(self): with patch("scheduler.kopis.collect_by_id", return_value=None): assert collect_and_save_concert("PF000") == {"status": "not_found"} - def test_returns_skipped_when_not_touring(self): - """내한 공연이 아니면 status:skipped, reason:not_touring을 반환해야 한다.""" + def test_saves_even_when_not_touring(self): + """내한 공연이 아니어도 저장된다 (어드민이 직접 고른 공연이므로 visit 무관).""" concert = {**self._CONCERT_RAW, "visit": "N"} - with patch("scheduler.kopis.collect_by_id", return_value=concert): - result = collect_and_save_concert("PF001") - assert result == {"status": "skipped", "reason": "not_touring"} - - def test_returns_skipped_when_no_alias_match(self): - """alias 매칭이 없으면 status:skipped, reason:no_alias_match를 반환해야 한다.""" with ( - patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW), + patch("scheduler.kopis.collect_by_id", return_value=concert), patch("scheduler.get_all_aliases", return_value=self._ALIASES), - patch("scheduler.has_match", return_value=False), + patch("scheduler.save_concerts") as mock_save, + patch("scheduler.get_concert_by_kopis_id", return_value=self._CONCERT_ROW), + patch("scheduler.match_concert", return_value=([], [{"concert_id": 10}])), ): result = collect_and_save_concert("PF001") - assert result == {"status": "skipped", "reason": "no_alias_match"} + mock_save.assert_called_once() + assert result["status"] == "ok" def test_returns_ok_with_matched_artists_on_success(self): """매칭 성공 시 status:ok와 matched_artists를 반환해야 한다.""" with ( patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW), patch("scheduler.get_all_aliases", return_value=self._ALIASES), - patch("scheduler.has_match", return_value=True), patch("scheduler.save_concerts"), patch("scheduler.get_concert_by_kopis_id", return_value=self._CONCERT_ROW), patch( @@ -695,16 +691,16 @@ def test_returns_ok_with_matched_artists_on_success(self): } def test_returns_ok_with_empty_matches_when_no_match_found(self): - """저장은 됐지만 실제 매칭이 0건이면 matched_artists 빈 리스트로 성공 반환해야 한다.""" + """alias 매칭이 없어도 저장되고, matched_artists 빈 리스트로 성공 반환해야 한다.""" with ( patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW), patch("scheduler.get_all_aliases", return_value=self._ALIASES), - patch("scheduler.has_match", return_value=True), - patch("scheduler.save_concerts"), + patch("scheduler.save_concerts") as mock_save, patch("scheduler.get_concert_by_kopis_id", return_value=self._CONCERT_ROW), patch("scheduler.match_concert", return_value=([], [{"concert_id": 10}])), ): result = collect_and_save_concert("PF001") + mock_save.assert_called_once() assert result["status"] == "ok" assert result["matched_artists"] == [] @@ -713,13 +709,21 @@ def test_raises_when_db_lookup_fails_after_save(self): with ( patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW), patch("scheduler.get_all_aliases", return_value=self._ALIASES), - patch("scheduler.has_match", return_value=True), patch("scheduler.save_concerts"), patch("scheduler.get_concert_by_kopis_id", return_value=None), pytest.raises(RuntimeError), ): collect_and_save_concert("PF001") + def test_returns_skipped_when_duplicate_title(self): + """save_concerts가 kopis_id를 중복으로 반환하면 status:skipped를 반환해야 한다.""" + with ( + patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW), + patch("scheduler.save_concerts", return_value={"PF001"}), + ): + result = collect_and_save_concert("PF001") + assert result == {"status": "skipped", "reason": "duplicate_title"} + class TestCollectAndSaveSetlist: _CONCERT = {