diff --git a/CLAUDE.md b/CLAUDE.md index 3551230..655f7ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,8 +79,8 @@ python scheduler.py collect-setlist # 단건 setlist 수집 | 단계 | 수집 주기 | 진입점 | |------|---------|--------| | ① MusicBrainz 아티스트 | 초기 1회 | `run_initial_collect()` | -| ② 공연 상태 갱신 | 매일 04:00 | `run_concert_status_update()` | -| ③ 신규 공연 수집·매칭 | 매일 04:30 | `run_new_concert_collect()` | +| ② 공연 상태 갱신 | 매일 00:00 | `run_concert_status_update()` | +| ③ 신규 공연 수집·매칭 | 매일 00:30 | `run_new_concert_collect()` | | ④ 릴리즈 (앨범·트랙·커버) | 초기 + 매일 05:00 | `run_release_update()` | | ⑤ 아티스트 이미지 | 매주 목 02:00 | `run_artist_image_update()` | | ⑥ 로마자→한글 alias 변환 | 매주 목 03:00 | `run_ja_romanize_collect()` | diff --git a/collectors/musicbrainz.py b/collectors/musicbrainz.py index 4eecc3a..292043a 100644 --- a/collectors/musicbrainz.py +++ b/collectors/musicbrainz.py @@ -52,6 +52,25 @@ def _get(path: str, params: dict) -> dict: return response.json() +def _with_retry(fn, retries: int = 3, backoff: float = 1.0): + """5xx·네트워크 오류만 재시도한다. 4xx는 즉시 재발생시킨다.""" + last_exc = None + for attempt in range(1, retries + 1): + try: + return fn() + except requests.HTTPError as e: + status = e.response.status_code if e.response is not None else None + if status is not None and 400 <= status < 500: + raise + last_exc = e + except requests.RequestException as e: + last_exc = e + if attempt < retries: + logger.debug("MusicBrainz 요청 재시도 %d/%d: %s", attempt, retries, last_exc) + time.sleep(backoff * attempt) + raise last_exc + + def _search_artists(offset: int) -> dict: return _get( "/artist/", @@ -127,13 +146,42 @@ def _parse_artist(detail: dict) -> dict: } +_LUCENE_SPECIAL_CHARS = re.compile(r'([+\-&|!(){}\[\]^"~*?:\\/])') + + +def _escape_lucene(name: str) -> str: + return _LUCENE_SPECIAL_CHARS.sub(r"\\\1", name) + + def search_artists(name: str) -> list[dict]: - """아티스트명으로 MusicBrainz 검색. 최대 10건 반환.""" + """아티스트명으로 MusicBrainz 검색. 최대 10건 반환. + + 1차로 구문(phrase) 검색을 시도하고, 결과가 없으면 unquoted 쿼리로 폴백한다. + """ + escaped = _escape_lucene(name) try: - data = _get("/artist/", {"query": f"artist:{name}", "fmt": "json", "limit": 10}) + data = _with_retry( + lambda: _get( + "/artist/", + {"query": f'artist:"{escaped}"', "fmt": "json", "limit": 10}, + ) + ) except requests.RequestException as e: logger.error("아티스트 검색 실패 name=%s: %s", name, e) return [] + + if not data.get("artists"): + try: + data = _with_retry( + lambda: _get( + "/artist/", + {"query": f"artist:{escaped}", "fmt": "json", "limit": 10}, + ) + ) + except requests.RequestException as e: + logger.error("아티스트 검색 실패(폴백) name=%s: %s", name, e) + return [] + return [ { "mbid": item.get("id"), @@ -153,7 +201,7 @@ def collect_single_artist(mbid: str) -> Optional[dict]: 수집 실패 시 None을 반환한다. """ try: - detail = _fetch_artist_detail(mbid) + detail = _with_retry(lambda: _fetch_artist_detail(mbid)) except requests.RequestException as e: logger.error("단건 아티스트 수집 실패 mbid=%s: %s", mbid, e) return None diff --git a/db/repository.py b/db/repository.py index e79f6be..0391f66 100644 --- a/db/repository.py +++ b/db/repository.py @@ -163,7 +163,9 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set: """수집된 공연 목록을 concert 테이블에 저장한다. kopis_id 중복 시 무시. title·start_date·end_date가 모두 일치하는 공연이 이미 존재하면(예: 어드민이 - kopis_id 없이 수동 등록한 공연과 동일 공연) 신규 저장을 건너뛴다. + kopis_id 없이 수동 등록한 공연과 동일 공연) 신규 저장을 건너뛰고, 기존 행의 + kopis_id·kopis_update_date만 채워 KOPIS 연동 상태로 전환한다(다른 필드는 + 어드민 입력값을 유지하기 위해 건드리지 않음). use_prfstate=True이면 prfstate → _KOPIS_STATUS_MAP으로 status를 결정한다. 기본값(False)은 배치 수집용 PENDING을 사용한다. @@ -179,7 +181,7 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set: text(""" SELECT id FROM concert WHERE title = :title AND start_date = :start_date - AND end_date = :end_date + AND end_date = :end_date AND kopis_id IS NULL """), { "title": concert["prfnm"], @@ -187,10 +189,42 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set: "end_date": concert["prfpdto"], }, ).fetchone() - if title_match is not None: + artist_match = None + if title_match is None and concert.get("_matched_artist_ids"): + artist_match = session.execute( + text(""" + SELECT c.id FROM concert c + JOIN concert_artist ca ON ca.concert_id = c.id + WHERE ca.artist_id = ANY(:artist_ids) + AND c.start_date = :start_date AND c.end_date = :end_date + AND c.kopis_id IS NULL + LIMIT 1 + """), + { + "artist_ids": concert["_matched_artist_ids"], + "start_date": concert["prfpdfrom"], + "end_date": concert["prfpdto"], + }, + ).fetchone() + + if title_match is not None or artist_match is not None: + matched_id = title_match[0] if title_match is not None else artist_match[0] + session.execute( + text(""" + UPDATE concert + SET kopis_id = :kopis_id, kopis_update_date = :kopis_update_date + WHERE id = :id AND kopis_id IS NULL + """), + { + "kopis_id": concert["kopis_id"], + "kopis_update_date": concert["updatedate"], + "id": matched_id, + }, + ) logger.info( - "동일 공연명·기간 이미 존재 — 저장 건너뜀: title=%s, kopis_id=%s", - concert["prfnm"], concert["kopis_id"], + "동일 공연명·기간 또는 동일 아티스트·기간 이미 존재 — 저장 대신 " + "kopis_id 연동: title=%s, kopis_id=%s, concert_id=%s", + concert["prfnm"], concert["kopis_id"], matched_id, ) duplicate_kopis_ids.add(concert["kopis_id"]) continue @@ -721,14 +755,32 @@ def update_artist_is_coming() -> int: return updated +def end_expired_concerts() -> int: + """KOPIS 연동 여부와 무관하게 종료일이 지난 UPCOMING/ONGOING 공연을 ENDED로 전환한다.""" + with get_session() as session: + result = session.execute( + text(""" + UPDATE concert + SET status = 'ENDED' + WHERE status IN ('UPCOMING', 'ONGOING') + AND end_date < CURRENT_DATE + """) + ) + updated = result.rowcount + logger.info("종료일 경과 공연 강제 종료 처리: %d건", updated) + return updated + + def get_active_concerts() -> list[dict]: - """status가 UPCOMING 또는 ONGOING인 공연의 kopis_id·kopis_update_date를 반환한다.""" + """status가 UPCOMING 또는 ONGOING이고 kopis_id가 있는 공연의 kopis_id·kopis_update_date를 + 반환한다.""" with get_session() as session: rows = session.execute( text(""" SELECT kopis_id, kopis_update_date FROM concert WHERE status IN ('UPCOMING', 'ONGOING') + AND kopis_id IS NOT NULL """) ).fetchall() return [ diff --git a/matchers/artist_matcher.py b/matchers/artist_matcher.py index f203275..000a93f 100644 --- a/matchers/artist_matcher.py +++ b/matchers/artist_matcher.py @@ -47,6 +47,11 @@ def has_match(concert_raw: dict, aliases: list[dict]) -> bool: return bool(_phrase_match_title(title, aliases)) +def matched_artist_ids(title: str, aliases: list[dict]) -> list[int]: + """제목에서 매칭되는 artist_id 목록을 반환한다.""" + return [a["artist_id"] for a in _phrase_match_title(title, aliases)] + + def match_concert(concert: dict, aliases: list[dict]) -> tuple[list[dict], list[dict]]: """공연-아티스트 매칭 실행. diff --git a/scheduler.py b/scheduler.py index 9238e6b..38ef9d9 100644 --- a/scheduler.py +++ b/scheduler.py @@ -15,6 +15,7 @@ from collectors import artist_image, ja_romanize, kopis, musicbrainz, release, setlist from collectors.spotify_client import SpotifyRateLimitError from db.repository import ( + end_expired_concerts, get_active_concerts, get_all_aliases, get_all_artist_mbids, @@ -45,7 +46,7 @@ update_spotify_album_total, upsert_artist_url, ) -from matchers.artist_matcher import has_match, match_concert +from matchers.artist_matcher import has_match, match_concert, matched_artist_ids from notifier.discord import notify_new_concert logging.basicConfig( @@ -386,6 +387,8 @@ def run_concert_status_update() -> None: with _job_timer("공연 상태 갱신 잡"): logger.info("=== 공연 상태 갱신 잡 시작 ===") + end_expired_concerts() + active = get_active_concerts() if active: logger.info("활성 공연 %d건 상태 갱신 시작", len(active)) @@ -434,6 +437,8 @@ def run_new_concert_collect(stdate: Optional[str] = None, use_prfstate: bool = F c for c in concerts if c["kopis_id"] not in existing_ids and has_match(c, aliases) ] + for c in new_concerts: + c["_matched_artist_ids"] = matched_artist_ids(c["prfnm"], aliases) if new_concerts: logger.info("신규 공연 %d건 저장 시작", len(new_concerts)) save_concerts(new_concerts, use_prfstate=use_prfstate) @@ -778,8 +783,8 @@ def collect_and_save_setlist(concert_id: int) -> dict: def _build_scheduler() -> BackgroundScheduler: scheduler = BackgroundScheduler(timezone="Asia/Seoul") - scheduler.add_job(run_concert_status_update, "cron", hour=4, minute=0) - scheduler.add_job(run_new_concert_collect, "cron", hour=4, minute=30) + scheduler.add_job(run_concert_status_update, "cron", hour=0, minute=0) + scheduler.add_job(run_new_concert_collect, "cron", hour=0, minute=30) scheduler.add_job(run_release_update, "cron", hour=5) scheduler.add_job(run_ja_romanize_collect, "cron", day_of_week="thu", hour=3) scheduler.add_job(run_artist_image_update, "cron", day_of_week="thu", hour=2) diff --git a/tests/test_artist_matcher.py b/tests/test_artist_matcher.py index 203986f..93ca706 100644 --- a/tests/test_artist_matcher.py +++ b/tests/test_artist_matcher.py @@ -1,4 +1,4 @@ -from matchers.artist_matcher import has_match, match_concert +from matchers.artist_matcher import has_match, match_concert, matched_artist_ids _ALIASES = [ {"artist_id": 1, "name": "아이유"}, @@ -187,3 +187,28 @@ def test_empty_aliases_always_fails(self): assert matches == [] assert failures == [{"concert_id": 41}] + + +class TestMatchedArtistIds: + def test_returns_matched_artist_ids(self): + """매칭되는 alias가 있으면 해당 artist_id 리스트를 반환해야 한다.""" + result = matched_artist_ids("BTS World Tour 콘서트", _ALIASES) + + assert result == [2] + + def test_returns_empty_list_when_no_match(self): + """매칭 없으면 빈 리스트를 반환해야 한다.""" + result = matched_artist_ids("전혀 관계없는 공연 제목 xyzxyz", _ALIASES) + + assert result == [] + + def test_returns_all_matched_ids_for_joint_concert(self): + """합동 공연처럼 복수 아티스트가 매칭되는 경우 전부 반환해야 한다.""" + aliases = [ + {"artist_id": 30, "name": "Perfume"}, + {"artist_id": 31, "name": "BABYMETAL"}, + ] + result = matched_artist_ids("Perfume × BABYMETAL LIVE IN SEOUL", aliases) + + assert set(result) == {30, 31} + assert len(result) == 2 diff --git a/tests/test_musicbrainz.py b/tests/test_musicbrainz.py index 3d884e6..e5c3529 100644 --- a/tests/test_musicbrainz.py +++ b/tests/test_musicbrainz.py @@ -1,13 +1,16 @@ from unittest.mock import MagicMock, patch +import pytest import requests from collectors.musicbrainz import ( _parse_aliases, _parse_artist, _parse_url_rels, + _with_retry, collect_artists, collect_single_artist, + search_artists, ) from db.repository import save_artists @@ -388,12 +391,43 @@ def test_includes_spotify_url_in_url_rels(self, mock_detail): spotify = next((u for u in result["url_rels"] if u["type"] == "Spotify"), None) assert spotify is not None + @patch("collectors.musicbrainz.time.sleep") @patch("collectors.musicbrainz._fetch_artist_detail") - def test_returns_none_on_request_exception(self, mock_detail): - """네트워크 오류 시 None을 반환해야 한다.""" + def test_returns_none_on_request_exception(self, mock_detail, mock_sleep): + """네트워크 오류 시 (재시도 3회 소진 후) None을 반환해야 한다.""" mock_detail.side_effect = requests.RequestException("timeout") result = collect_single_artist("mbid-fail") assert result is None + assert mock_detail.call_count == 3 + + @patch("collectors.musicbrainz.time.sleep") + @patch("collectors.musicbrainz._fetch_artist_detail") + def test_retries_on_5xx_then_succeeds(self, mock_detail, mock_sleep): + """5xx로 실패해도 재시도 끝에 성공하면 정상 결과를 반환해야 한다.""" + response = MagicMock() + response.status_code = 503 + error = requests.HTTPError(response=response) + mock_detail.side_effect = [error, self._DETAIL] + + result = collect_single_artist("mbid-single") + + assert result is not None + assert result["mbid"] == "mbid-single" + assert mock_detail.call_count == 2 + + @patch("collectors.musicbrainz.time.sleep") + @patch("collectors.musicbrainz._fetch_artist_detail") + def test_does_not_retry_on_4xx(self, mock_detail, mock_sleep): + """4xx는 즉시 실패해 재시도하지 않고 None을 반환해야 한다.""" + response = MagicMock() + response.status_code = 404 + error = requests.HTTPError(response=response) + mock_detail.side_effect = error + + result = collect_single_artist("mbid-notfound") + + assert result is None + assert mock_detail.call_count == 1 @patch("collectors.musicbrainz._fetch_artist_detail") def test_skips_no_listener_filter(self, mock_detail): @@ -422,3 +456,154 @@ def test_returns_artist_without_spotify(self, mock_detail): result = collect_single_artist("mbid-nospot") assert result is not None assert result["url_rels"] == [] + + +class TestWithRetry: + @patch("collectors.musicbrainz.time.sleep") + def test_retries_5xx_twice_then_succeeds_on_third_attempt(self, mock_sleep): + """5xx로 2회 실패 후 3번째 시도에서 성공하면 결과를 반환하고 3회 호출되어야 한다.""" + response = MagicMock() + response.status_code = 503 + error = requests.HTTPError(response=response) + fn = MagicMock(side_effect=[error, error, "ok"]) + + result = _with_retry(fn) + + assert result == "ok" + assert fn.call_count == 3 + + @patch("collectors.musicbrainz.time.sleep") + def test_retries_on_network_error_without_status_code(self, mock_sleep): + """response가 없는 네트워크 오류(RequestException)도 재시도되어야 한다.""" + error = requests.ConnectionError("network down") + fn = MagicMock(side_effect=[error, "ok"]) + + result = _with_retry(fn) + + assert result == "ok" + assert fn.call_count == 2 + + def test_does_not_retry_on_4xx(self): + """4xx는 즉시 재발생시키고 재시도하지 않아야 한다.""" + response = MagicMock() + response.status_code = 400 + error = requests.HTTPError(response=response) + fn = MagicMock(side_effect=error) + + with pytest.raises(requests.HTTPError): + _with_retry(fn) + + assert fn.call_count == 1 + + @patch("collectors.musicbrainz.time.sleep") + def test_raises_last_exception_after_all_retries_fail(self, mock_sleep): + """5xx로 3회 모두 실패하면 마지막 예외가 그대로 raise되어야 한다.""" + response = MagicMock() + response.status_code = 503 + error = requests.HTTPError(response=response) + fn = MagicMock(side_effect=[error, error, error]) + + with pytest.raises(requests.HTTPError): + _with_retry(fn) + + assert fn.call_count == 3 + + +class TestSearchArtists: + @patch("collectors.musicbrainz.time.sleep") + @patch("collectors.musicbrainz._get") + def test_uses_quoted_query_for_phrase_search(self, mock_get, mock_sleep): + """1차 시도는 큰따옴표로 감싼 구문검색 쿼리를 사용해야 한다.""" + mock_get.return_value = { + "artists": [{"id": "m1", "name": "IU", "country": "KR", "type": "Person"}] + } + + search_artists("IU") + + params = mock_get.call_args.args[1] + assert params["query"] == 'artist:"IU"' + + @patch("collectors.musicbrainz.time.sleep") + @patch("collectors.musicbrainz._get") + def test_returns_first_result_without_fallback_when_found(self, mock_get, mock_sleep): + """1차 구문검색에서 결과가 있으면 폴백 쿼리를 호출하지 않아야 한다.""" + mock_get.return_value = { + "artists": [{"id": "m1", "name": "IU", "country": "KR", "type": "Person"}] + } + + result = search_artists("IU") + + assert mock_get.call_count == 1 + assert result == [ + { + "mbid": "m1", + "name": "IU", + "country": "KR", + "type": "Person", + "url": "https://musicbrainz.org/artist/m1", + } + ] + + @patch("collectors.musicbrainz.time.sleep") + @patch("collectors.musicbrainz._get") + def test_falls_back_to_unquoted_query_when_phrase_search_empty(self, mock_get, mock_sleep): + """1차 구문검색 결과가 비어 있으면 unquoted 쿼리로 폴백해야 한다.""" + mock_get.side_effect = [ + {"artists": []}, + {"artists": [{"id": "m2", "name": "Found", "country": None, "type": "Group"}]}, + ] + + result = search_artists("Found") + + assert mock_get.call_count == 2 + second_params = mock_get.call_args_list[1].args[1] + assert second_params["query"] == "artist:Found" + assert result[0]["mbid"] == "m2" + + @patch("collectors.musicbrainz.time.sleep") + @patch("collectors.musicbrainz._get") + def test_escapes_quotes_and_backslashes_in_query(self, mock_get, mock_sleep): + """이름에 큰따옴표·백슬래시가 있으면 이스케이프되어 쿼리에 들어가야 한다.""" + mock_get.side_effect = [{"artists": []}, {"artists": []}] + name = 'Weird\\Name "Quote"' + + search_artists(name) + + first_params = mock_get.call_args_list[0].args[1] + assert first_params["query"] == 'artist:"Weird\\\\Name \\"Quote\\""' + + @patch("collectors.musicbrainz.time.sleep") + @patch("collectors.musicbrainz._get") + def test_escapes_lucene_reserved_characters_in_query(self, mock_get, mock_sleep): + """이름에 하이픈·괄호 등 Lucene 예약문자가 있으면 이스케이프되어야 한다.""" + mock_get.side_effect = [{"artists": []}, {"artists": []}] + name = "w-inds." + + search_artists(name) + + first_params = mock_get.call_args_list[0].args[1] + second_params = mock_get.call_args_list[1].args[1] + assert first_params["query"] == 'artist:"w\\-inds."' + assert second_params["query"] == "artist:w\\-inds." + + @patch("collectors.musicbrainz.time.sleep") + @patch("collectors.musicbrainz._get") + def test_returns_empty_list_when_phrase_search_fails_after_retries(self, mock_get, mock_sleep): + """1차 구문검색이 재시도 소진 후에도 실패하면 빈 리스트를 반환해야 한다.""" + mock_get.side_effect = requests.ConnectionError("network down") + + result = search_artists("X") + + assert result == [] + assert mock_get.call_count == 3 + + @patch("collectors.musicbrainz.time.sleep") + @patch("collectors.musicbrainz._get") + def test_returns_empty_list_when_fallback_fails_after_retries(self, mock_get, mock_sleep): + """폴백 쿼리가 재시도 소진 후에도 실패하면 빈 리스트를 반환해야 한다.""" + mock_get.side_effect = [{"artists": []}] + [requests.ConnectionError("down")] * 3 + + result = search_artists("X") + + assert result == [] + assert mock_get.call_count == 4 diff --git a/tests/test_repository.py b/tests/test_repository.py index 1b72141..f33e8bd 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -3,12 +3,14 @@ from sqlalchemy.exc import SQLAlchemyError from db.repository import ( + end_expired_concerts, get_active_concerts, get_artist_names_by_ids, get_concert_ids_by_kopis_ids, save_artists, save_concert_artist_candidates, save_concert_artists, + save_concerts, save_setlists, update_artist_is_coming, upsert_artist_url, @@ -37,6 +39,18 @@ def test_filters_by_active_status(self): assert "UPCOMING" in sql assert "ONGOING" in sql + def test_filters_by_kopis_id_not_null(self): + """kopis_id IS NOT NULL 조건이 SQL에 포함되어야 한다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchall.return_value = [] + 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) + get_active_concerts() + + sql = str(mock_session.execute.call_args_list[0].args[0]) + assert "kopis_id IS NOT NULL" in sql + def test_returns_kopis_id_and_update_date(self): """반환값에 kopis_id와 kopis_update_date 키가 포함되어야 한다.""" result = self._run([("PF123", "2024-01-01"), ("PF456", "2024-02-01")]) @@ -594,3 +608,218 @@ def test_returns_updated_row_count(self): result = self._run(mock_session) assert result == 3 + + +class TestEndExpiredConcerts: + def _run(self, mock_session): + 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) + return end_expired_concerts() + + def _make_session_mock(self, rowcount=0): + mock_session = MagicMock() + mock_session.execute.return_value.rowcount = rowcount + return mock_session + + def test_filters_end_date_and_active_status(self): + """종료일 경과(end_date < CURRENT_DATE) + UPCOMING/ONGOING 상태 필터가 포함되어야 한다.""" + mock_session = self._make_session_mock() + self._run(mock_session) + + sql = str(mock_session.execute.call_args_list[0].args[0]) + assert "end_date < CURRENT_DATE" in sql + assert "status IN" in sql + assert "UPCOMING" in sql + assert "ONGOING" in sql + + def test_returns_rowcount(self): + """result.rowcount를 그대로 반환해야 한다.""" + mock_session = self._make_session_mock(rowcount=5) + result = self._run(mock_session) + + assert result == 5 + + def test_returns_zero_without_error(self): + """rowcount=0이어도 예외 없이 0을 반환해야 한다.""" + mock_session = self._make_session_mock(rowcount=0) + result = self._run(mock_session) + + assert result == 0 + + +class TestSaveConcerts: + _CONCERT_WITH_MATCH = { + "kopis_id": "PF100", + "prfnm": "New Concert Title", + "prfcast": "Cast", + "prfpdfrom": "20240101", + "prfpdto": "20240102", + "fcltynm": "Venue", + "updatedate": "20240101120000", + "_matched_artist_ids": [1, 2], + } + + def _run(self, concerts, mock_session): + 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) + return save_concerts(concerts) + + def test_skips_when_title_and_period_match_found(self): + """title·기간 완전일치 시 저장을 건너뛰어야 한다 (회귀).""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = (5,) + concert = { + "kopis_id": "PF300", + "prfnm": "Existing Title", + "prfcast": "Cast", + "prfpdfrom": "20240401", + "prfpdto": "20240402", + "fcltynm": "Venue", + "updatedate": "20240401120000", + } + + duplicates = self._run([concert], mock_session) + + assert duplicates == {"PF300"} + assert mock_session.execute.call_count == 2 + sql, params = mock_session.execute.call_args_list[1].args + assert "UPDATE concert" in str(sql) + assert params == {"kopis_id": "PF300", "kopis_update_date": "20240401120000", "id": 5} + + def test_skips_when_artist_and_period_match_found(self): + """title 불일치 + artist_match(동일 아티스트·기간) 쿼리 히트 시 저장을 건너뛰어야 한다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.side_effect = [None, (55,)] + concert = dict(self._CONCERT_WITH_MATCH) + + duplicates = self._run([concert], mock_session) + + assert duplicates == {"PF100"} + sqls = [str(c.args[0]) for c in mock_session.execute.call_args_list] + assert not any("INSERT INTO concert" in s for s in sqls) + assert mock_session.execute.call_count == 3 + sql, params = mock_session.execute.call_args_list[2].args + assert "UPDATE concert" in str(sql) + assert params["id"] == 55 + assert params["kopis_id"] == "PF100" + + def test_artist_match_query_uses_matched_artist_ids_and_period(self): + """artist_match 쿼리 파라미터에 _matched_artist_ids·기간이 그대로 전달되어야 한다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.side_effect = [None, None, (1,)] + concert = dict(self._CONCERT_WITH_MATCH) + + self._run([concert], mock_session) + + sql, params = mock_session.execute.call_args_list[1].args + assert "concert_artist" in str(sql) + assert params["artist_ids"] == [1, 2] + assert params["start_date"] == "20240101" + assert params["end_date"] == "20240102" + + def test_title_match_query_excludes_already_linked_rows(self): + """title_match 쿼리는 kopis_id가 이미 채워진 행을 후보에서 제외해야 한다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = (5,) + concert = { + "kopis_id": "PF300", + "prfnm": "Existing Title", + "prfcast": "Cast", + "prfpdfrom": "20240401", + "prfpdto": "20240402", + "fcltynm": "Venue", + "updatedate": "20240401120000", + } + + self._run([concert], mock_session) + + sql, _ = mock_session.execute.call_args_list[0].args + assert "kopis_id IS NULL" in str(sql) + + def test_artist_match_query_excludes_already_linked_rows(self): + """artist_match 쿼리는 kopis_id가 이미 채워진 행을 후보에서 제외해야 한다. + + LIMIT 1로 임의의 행을 뽑는 특성상, 이 조건이 없으면 이미 다른 kopis_id로 + 연동된 행을 잘못 골라 UPDATE가 0건 처리되고 신규 공연이 유실될 수 있다. + """ + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.side_effect = [None, (1,)] + concert = dict(self._CONCERT_WITH_MATCH) + + self._run([concert], mock_session) + + sql, _ = mock_session.execute.call_args_list[1].args + assert "kopis_id IS NULL" in str(sql) + + def test_update_query_only_touches_kopis_id_and_update_date(self): + """매치 시 UPDATE는 kopis_id·kopis_update_date만 채우고 kopis_id IS NULL 조건을 + 가져야 한다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = (5,) + concert = { + "kopis_id": "PF300", + "prfnm": "Existing Title", + "prfcast": "Cast", + "prfpdfrom": "20240401", + "prfpdto": "20240402", + "fcltynm": "Venue", + "updatedate": "20240401120000", + } + + self._run([concert], mock_session) + + sql, params = mock_session.execute.call_args_list[1].args + assert "kopis_id IS NULL" in str(sql) + assert set(params.keys()) == {"kopis_id", "kopis_update_date", "id"} + + def test_proceeds_to_insert_when_artist_match_misses(self): + """artist_match 쿼리가 미스면 정상적으로 INSERT가 진행되어야 한다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.side_effect = [None, None, (10,)] + concert = dict(self._CONCERT_WITH_MATCH) + + duplicates = self._run([concert], mock_session) + + assert duplicates == set() + sqls = [str(c.args[0]) for c in mock_session.execute.call_args_list] + assert any("INSERT INTO concert" in s for s in sqls) + + def test_skips_artist_match_query_when_matched_artist_ids_missing(self): + """_matched_artist_ids가 없으면 artist_match 2차 쿼리가 실행되지 않아야 한다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.side_effect = [None, (20,)] + concert = { + "kopis_id": "PF200", + "prfnm": "No Match Concert", + "prfcast": "Cast", + "prfpdfrom": "20240201", + "prfpdto": "20240202", + "fcltynm": "Venue", + "updatedate": "20240201120000", + } + + self._run([concert], mock_session) + + assert mock_session.execute.call_count == 2 + + def test_skips_artist_match_query_when_matched_artist_ids_empty(self): + """_matched_artist_ids가 빈 리스트면 artist_match 2차 쿼리가 실행되지 않아야 한다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.side_effect = [None, (21,)] + concert = { + "kopis_id": "PF201", + "prfnm": "Empty Match Concert", + "prfcast": "Cast", + "prfpdfrom": "20240301", + "prfpdto": "20240302", + "fcltynm": "Venue", + "updatedate": "20240301120000", + "_matched_artist_ids": [], + } + + self._run([concert], mock_session) + + assert mock_session.execute.call_count == 2 + diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 93de9a7..768bbe7 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -190,6 +190,7 @@ def test_calls_collect_by_id_for_each_active_concert(self): ] fetched = {"kopis_id": "PF001", "prfstate": "공연중", "updatedate": "2024-01-01"} with ( + patch("scheduler.end_expired_concerts"), patch("scheduler.get_active_concerts", return_value=active), patch("scheduler.kopis.collect_by_id", return_value=fetched) as mock_by_id, patch("scheduler.update_concert_status"), @@ -205,6 +206,7 @@ def test_update_concert_status_called_with_fetched_results(self): active = [{"kopis_id": "PF001", "kopis_update_date": "2024-01-01"}] fetched = {"kopis_id": "PF001", "prfstate": "공연완료", "updatedate": "2024-02-01"} with ( + patch("scheduler.end_expired_concerts"), patch("scheduler.get_active_concerts", return_value=active), patch("scheduler.kopis.collect_by_id", return_value=fetched), patch("scheduler.update_concert_status") as mock_update, @@ -216,6 +218,7 @@ def test_update_concert_status_called_with_fetched_results(self): def test_skips_status_update_when_no_active_concerts(self): """활성 공연이 없으면 collect_by_id·update_concert_status가 호출되지 않아야 한다.""" with ( + patch("scheduler.end_expired_concerts"), patch("scheduler.get_active_concerts", return_value=[]), patch("scheduler.kopis.collect_by_id") as mock_by_id, patch("scheduler.update_concert_status") as mock_update, @@ -229,6 +232,7 @@ def test_skips_none_results_from_collect_by_id(self): """collect_by_id가 None을 반환하면 update_concert_status 호출 대상에서 제외되어야 한다.""" active = [{"kopis_id": "PF001", "kopis_update_date": "2024-01-01"}] with ( + patch("scheduler.end_expired_concerts"), patch("scheduler.get_active_concerts", return_value=active), patch("scheduler.kopis.collect_by_id", return_value=None), patch("scheduler.update_concert_status") as mock_update, @@ -240,6 +244,7 @@ def test_skips_none_results_from_collect_by_id(self): def test_does_not_update_is_coming(self): """is_coming 갱신은 run_new_concert_collect로 일원화됐으므로 이 잡에서는 호출 안 된다.""" with ( + patch("scheduler.end_expired_concerts"), patch("scheduler.get_active_concerts", return_value=[]), patch("scheduler.update_artist_is_coming") as mock_update, ): @@ -247,6 +252,35 @@ def test_does_not_update_is_coming(self): mock_update.assert_not_called() + def test_calls_end_expired_concerts_before_get_active_concerts(self): + """end_expired_concerts가 get_active_concerts보다 먼저 호출되어야 한다.""" + call_order = [] + with ( + patch( + "scheduler.end_expired_concerts", + side_effect=lambda: call_order.append("end_expired"), + ), + patch( + "scheduler.get_active_concerts", + side_effect=lambda: call_order.append("get_active") or [], + ), + patch("scheduler.kopis.collect_by_id"), + patch("scheduler.update_concert_status"), + ): + run_concert_status_update() + + assert call_order == ["end_expired", "get_active"] + + def test_end_expired_concerts_return_value_unused(self): + """end_expired_concerts 반환값은 사용되지 않고, 예외 없이 잡이 완료되어야 한다.""" + with ( + patch("scheduler.end_expired_concerts", return_value=7) as mock_end_expired, + patch("scheduler.get_active_concerts", return_value=[]), + ): + run_concert_status_update() + + mock_end_expired.assert_called_once_with() + class TestRunNewConcertCollect: # ── 신규 공연 탐지 잡 ───────────────────────────────────────────────────── @@ -406,6 +440,48 @@ def test_does_not_notify_when_no_new_concerts(self): mock_save.assert_not_called() mock_notify.assert_not_called() + def test_attaches_matched_artist_ids_to_new_concerts_before_save(self): + """save_concerts 호출 시 각 concert에 matched_artist_ids 결과가 채워져 있어야 한다.""" + new_concert = {"kopis_id": "PF999", "prfnm": "NewJeans 내한공연", "prfstate": "공연예정"} + aliases = [{"artist_id": 1, "name": "NewJeans"}] + with ( + patch("scheduler.kopis.collect", return_value=[new_concert]), + patch("scheduler.get_existing_kopis_ids", return_value=set()), + patch("scheduler.get_all_aliases", return_value=aliases), + patch("scheduler.has_match", return_value=True), + patch("scheduler.matched_artist_ids", return_value=[1, 2]) as mock_matched, + patch("scheduler.save_concerts") as mock_save, + patch("scheduler.get_unmatched_concerts", return_value=[]), + patch("scheduler.get_concert_ids_by_kopis_ids", return_value={}), + patch("scheduler.update_artist_is_coming"), + ): + run_new_concert_collect() + + mock_matched.assert_called_once_with("NewJeans 내한공연", aliases) + saved_concerts = mock_save.call_args.args[0] + assert saved_concerts[0]["_matched_artist_ids"] == [1, 2] + + def test_attaches_empty_list_when_matched_artist_ids_finds_nothing(self): + """matched_artist_ids가 빈 리스트를 반환해도 _matched_artist_ids 키가 명시적으로 붙어야 + 한다.""" + new_concert = {"kopis_id": "PF998", "prfnm": "매칭 안되는 공연", "prfstate": "공연예정"} + with ( + patch("scheduler.kopis.collect", return_value=[new_concert]), + patch("scheduler.get_existing_kopis_ids", return_value=set()), + patch("scheduler.get_all_aliases", return_value=[]), + patch("scheduler.has_match", return_value=True), + patch("scheduler.matched_artist_ids", return_value=[]), + patch("scheduler.save_concerts") as mock_save, + patch("scheduler.get_unmatched_concerts", return_value=[]), + patch("scheduler.get_concert_ids_by_kopis_ids", return_value={}), + patch("scheduler.update_artist_is_coming"), + ): + run_new_concert_collect() + + saved_concerts = mock_save.call_args.args[0] + assert "_matched_artist_ids" in saved_concerts[0] + assert saved_concerts[0]["_matched_artist_ids"] == [] + class TestRunReleaseUpdate: def test_collects_releases_for_all_spotify_artists(self):