diff --git a/db/repository.py b/db/repository.py index 8745134..4c05880 100644 --- a/db/repository.py +++ b/db/repository.py @@ -309,7 +309,10 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set: def get_completed_concerts() -> list[dict]: - """setlist 미수집·미시도(또는 7일 경과) 공연완료 건을 아티스트 MBID와 함께 반환한다.""" + """setlist 트랙 미수집·미시도(또는 7일 경과) 공연완료 건을 아티스트 MBID와 함께 반환한다. + + setlist 행이 없거나, 있어도 트랙이 0개인 공연을 대상으로 한다. + """ with get_session() as session: rows = session.execute( text(""" @@ -318,9 +321,12 @@ def get_completed_concerts() -> list[dict]: FROM concert c JOIN concert_artist ca ON ca.concert_id = c.id JOIN artist a ON a.id = ca.artist_id - LEFT JOIN setlist s ON s.concert_id = c.id WHERE c.status = 'ENDED' - AND s.id IS NULL + AND NOT EXISTS ( + SELECT 1 FROM setlist s + JOIN setlist_track t ON t.setlist_id = s.id + WHERE s.concert_id = c.id + ) AND ( c.fetch_attempted_at IS NULL OR c.fetch_attempted_at < NOW() - INTERVAL '7 days' @@ -339,10 +345,13 @@ def get_completed_concerts() -> list[dict]: ] -def save_setlists(setlists: list[dict]) -> None: - """수집된 셋리스트를 setlist·setlist_track 테이블에 저장한다. 중복 시 무시. +def save_setlists(setlists: list[dict], raise_on_error: bool = False) -> None: + """수집된 셋리스트를 setlist·setlist_track 테이블에 저장한다. + 같은 setlist_fm_id가 이미 있으면 attribution_url·collected_at을 갱신하고, + 새 결과에 tracks가 있으면 기존 트랙을 지운 뒤 다시 저장한다 (tracks가 비면 기존 트랙 유지). 셋리스트별 독립 트랜잭션을 사용해 한 건 실패가 전체에 영향을 주지 않는다. + raise_on_error=True면 저장 실패 시 예외를 그대로 올린다 (단건 수집 경로용). """ saved = 0 for item in setlists: @@ -353,7 +362,9 @@ def save_setlists(setlists: list[dict]) -> None: INSERT INTO setlist (concert_id, setlist_fm_id, attribution_url, collected_at) VALUES (:concert_id, :setlist_fm_id, :attribution_url, NOW()) - ON CONFLICT (setlist_fm_id) DO NOTHING + ON CONFLICT (setlist_fm_id) DO UPDATE + SET attribution_url = EXCLUDED.attribution_url, + collected_at = NOW() RETURNING id """), { @@ -363,8 +374,12 @@ def save_setlists(setlists: list[dict]) -> None: }, ).fetchone() - if row and item.get("tracks"): + if item.get("tracks"): setlist_id = row[0] + session.execute( + text("DELETE FROM setlist_track WHERE setlist_id = :setlist_id"), + {"setlist_id": setlist_id}, + ) session.execute( text(""" INSERT INTO setlist_track (setlist_id, position, song_name, info) @@ -387,6 +402,8 @@ def save_setlists(setlists: list[dict]) -> None: item.get("setlist_fm_id"), e, ) + if raise_on_error: + raise logger.info("셋리스트 저장 완료: %d / %d건 처리", saved, len(setlists)) diff --git a/docs/pipeline.md b/docs/pipeline.md index 1d15eb4..339b33a 100644 --- a/docs/pipeline.md +++ b/docs/pipeline.md @@ -46,5 +46,6 @@ Data는 `concert_id`, `artist_id`만 INSERT(`ON CONFLICT (concert_id, artist_id) ## ⑤ setlist.fm 수집 (매일 06:00, 공연완료 상태 대상) -- 대상: `prfstate=공연완료` +- 대상: `prfstate=공연완료` 중 setlist가 없거나 트랙이 0개인 공연 (조회 후 7일간 재조회 안 함) - 데이터 미존재 시 빈 상태 유지 +- 같은 `setlist_fm_id` 재수집 시 새 결과에 트랙이 있으면 기존 트랙을 교체한다 diff --git a/scheduler.py b/scheduler.py index 12bf5b5..799a3a6 100644 --- a/scheduler.py +++ b/scheduler.py @@ -763,7 +763,7 @@ def collect_and_save_setlist(concert_id: int) -> dict: if result is None: logger.info("셋리스트 없음: concert_id=%d", concert_id) return {"status": "skipped", "reason": "no_setlist_found"} - save_setlists([result]) + save_setlists([result], raise_on_error=True) logger.info("단건 셋리스트 수집 완료: concert_id=%d", concert_id) return { "status": "ok", diff --git a/tests/test_repository.py b/tests/test_repository.py index 2124c4e..511d54c 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1,11 +1,14 @@ +from datetime import date from unittest.mock import MagicMock, patch +import pytest from sqlalchemy.exc import SQLAlchemyError from db.repository import ( end_expired_concerts, get_active_concerts, get_artist_names_by_ids, + get_completed_concerts, get_concert_ids_by_kopis_ids, save_artists, save_concert_artist_candidates, @@ -335,6 +338,48 @@ def test_on_conflict_artist_id_type_do_nothing(self): assert "ON CONFLICT (artist_id, type) DO NOTHING" in sql +class TestGetCompletedConcerts: + def _run(self, mock_rows): + mock_session = MagicMock() + mock_session.execute.return_value.fetchall.return_value = mock_rows + 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 get_completed_concerts(), mock_session + + def test_targets_concerts_without_setlist_tracks(self): + """setlist 행이 없거나 트랙이 0개인 공연을 대상으로 하는 조건이 SQL에 포함되어야 한다.""" + _, mock_session = self._run([]) + + sql = " ".join(str(mock_session.execute.call_args.args[0]).split()) + assert "status = 'ENDED'" in sql + assert "NOT EXISTS ( SELECT 1 FROM setlist s JOIN setlist_track t" in sql + assert "s.id IS NULL" not in sql + + def test_keeps_fetch_attempted_backoff(self): + """fetch_attempted_at 7일 재시도 간격 조건이 유지되어야 한다.""" + _, mock_session = self._run([]) + + sql = str(mock_session.execute.call_args.args[0]) + assert "fetch_attempted_at IS NULL" in sql + assert "INTERVAL '7 days'" in sql + + def test_returns_concert_dicts(self): + """조회 결과를 concert dict 목록으로 변환해야 한다.""" + rows = [(3, "공연", date(2026, 9, 1), None, "mbid-1")] + result, _ = self._run(rows) + + assert result == [ + { + "concert_id": 3, + "title": "공연", + "start_date": "2026-09-01", + "end_date": None, + "artist_mbid": "mbid-1", + } + ] + + class TestSaveSetlists: def _run(self, setlists, mock_session): with patch("db.repository.get_session") as mock_get_session: @@ -377,8 +422,8 @@ def test_attribution_url_none_when_missing(self): _, params = mock_session.execute.call_args_list[0].args assert params["attribution_url"] is None - def test_on_conflict_setlist_fm_id_do_nothing(self): - """중복 setlist_fm_id 시 ON CONFLICT DO NOTHING이 포함되어야 한다.""" + def test_on_conflict_setlist_fm_id_do_update(self): + """중복 setlist_fm_id 시 DO UPDATE로 id를 항상 RETURNING해야 한다.""" mock_session = self._make_session_mock() self._run( [{"concert_id": 1, "setlist_fm_id": "abc123", "tracks": []}], @@ -386,7 +431,8 @@ def test_on_conflict_setlist_fm_id_do_nothing(self): ) sql = str(mock_session.execute.call_args_list[0].args[0]) - assert "ON CONFLICT (setlist_fm_id) DO NOTHING" in sql + assert "ON CONFLICT (setlist_fm_id) DO UPDATE" in sql + assert "RETURNING id" in sql def test_inserts_tracks_when_present(self): """tracks가 있으면 setlist_track 테이블에 INSERT되어야 한다.""" @@ -406,31 +452,59 @@ def test_inserts_tracks_when_present(self): mock_session, ) - track_calls = [ - c for c in mock_session.execute.call_args_list if "setlist_track" in str(c.args[0]) + insert_calls = [ + c + for c in mock_session.execute.call_args_list + if "INSERT INTO setlist_track" in str(c.args[0]) ] - assert len(track_calls) == 1 - assert len(track_calls[0].args[1]) == 2 + assert len(insert_calls) == 1 + assert len(insert_calls[0].args[1]) == 2 - def test_skips_tracks_when_conflict_returns_no_row(self): - """RETURNING id가 None(중복)이면 setlist_track INSERT를 건너뛰어야 한다.""" - mock_session = MagicMock() - mock_session.execute.return_value.fetchone.return_value = None + def test_refills_tracks_for_existing_setlist_without_tracks(self): + """트랙 0개로 저장된 setlist를 재수집하면 기존 id로 트랙을 교체 저장해야 한다.""" + mock_session = self._make_session_mock(setlist_id=3) self._run( [ { "concert_id": 1, - "setlist_fm_id": "dup", - "attribution_url": "https://www.setlist.fm/setlist/dup.html", + "setlist_fm_id": "4b4f4bb6", "tracks": [{"position": 1, "song_name": "Song A", "info": None}], } ], mock_session, ) + sqls = [str(c.args[0]) for c in mock_session.execute.call_args_list] + delete_idx = next(i for i, s in enumerate(sqls) if "DELETE FROM setlist_track" in s) + insert_idx = next(i for i, s in enumerate(sqls) if "INSERT INTO setlist_track" in s) + assert delete_idx < insert_idx + assert mock_session.execute.call_args_list[delete_idx].args[1] == {"setlist_id": 3} + assert mock_session.execute.call_args_list[insert_idx].args[1][0]["setlist_id"] == 3 + + def test_keeps_existing_tracks_when_new_tracks_empty(self): + """새 결과의 tracks가 비어 있으면 기존 트랙을 지우지 않아야 한다.""" + mock_session = self._make_session_mock(setlist_id=3) + self._run( + [{"concert_id": 1, "setlist_fm_id": "4b4f4bb6", "tracks": []}], + mock_session, + ) + sqls = [str(c.args[0]) for c in mock_session.execute.call_args_list] assert not any("setlist_track" in s for s in sqls) + def test_raise_on_error_propagates_failure(self): + """raise_on_error=True면 SQLAlchemyError를 호출자에게 올려야 한다.""" + mock_session = MagicMock() + mock_session.execute.side_effect = SQLAlchemyError("boom") + 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) + with pytest.raises(SQLAlchemyError): + save_setlists( + [{"concert_id": 1, "setlist_fm_id": "abc1", "tracks": []}], + raise_on_error=True, + ) + def test_one_failure_does_not_stop_others(self): """한 건이 SQLAlchemyError로 실패해도 나머지 건은 계속 저장 시도되어야 한다.""" mock_session = MagicMock() diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index fdc73d8..0f70358 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -7,6 +7,7 @@ from unittest.mock import patch import pytest +from sqlalchemy.exc import SQLAlchemyError from scheduler import ( _build_scheduler, @@ -873,7 +874,7 @@ def test_returns_ok_with_tracks_on_success(self): patch("scheduler.save_setlists") as mock_save, ): result = collect_and_save_setlist(10) - mock_save.assert_called_once_with([self._SETLIST_RESULT]) + mock_save.assert_called_once_with([self._SETLIST_RESULT], raise_on_error=True) assert result == { "status": "ok", "concert_id": 10, @@ -882,6 +883,19 @@ def test_returns_ok_with_tracks_on_success(self): "tracks": [{"position": 1, "song_name": "Song A", "info": None}], } + def test_raises_when_save_fails(self): + """저장 실패 시 예외를 올려 성공 응답을 반환하지 않아야 한다.""" + with ( + patch("scheduler.get_concert_with_artist", return_value=self._CONCERT), + patch( + "scheduler.setlist.collect_for_concert", + return_value=self._SETLIST_RESULT, + ), + patch("scheduler.save_setlists", side_effect=SQLAlchemyError("boom")), + pytest.raises(SQLAlchemyError), + ): + collect_and_save_setlist(10) + # --------------------------------------------------------------------------- # 체크포인트 헬퍼 — progress (잡별 진행 상태)