Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions db/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand All @@ -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'
Expand All @@ -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:
Expand All @@ -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
"""),
{
Expand All @@ -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)
Expand All @@ -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))

Expand Down
3 changes: 2 additions & 1 deletion docs/pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 재수집 시 새 결과에 트랙이 있으면 기존 트랙을 교체한다
2 changes: 1 addition & 1 deletion scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 87 additions & 13 deletions tests/test_repository.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -377,16 +422,17 @@ 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": []}],
mock_session,
)

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되어야 한다."""
Expand All @@ -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()
Expand Down
16 changes: 15 additions & 1 deletion tests/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from unittest.mock import patch

import pytest
from sqlalchemy.exc import SQLAlchemyError

from scheduler import (
_build_scheduler,
Expand Down Expand Up @@ -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,
Expand All @@ -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 (잡별 진행 상태)
Expand Down
Loading