music on atproto
plyr.fm
1"""regression tests for banana mix incident fixes.
2
3tests cover three critical fixes:
41. duplicate detection prevents re-uploading same file
52. refcount check prevents deleting shared R2 files
63. ATProto cleanup removes orphaned records
7"""
8
9from collections.abc import Generator
10from unittest.mock import AsyncMock, patch
11
12import pytest
13from fastapi import FastAPI
14from httpx import ASGITransport, AsyncClient
15from sqlalchemy import select
16from sqlalchemy.ext.asyncio import AsyncSession
17
18from backend._internal import Session, require_auth
19from backend.main import app
20from backend.models import Album, Artist, Track
21
22
23class MockSession(Session):
24 """mock session for auth bypass in tests."""
25
26 def __init__(self, did: str = "did:test:user123"):
27 self.did = did
28 self.handle = "testuser.bsky.social"
29 self.session_id = "test_session_id"
30 self.access_token = "test_token"
31 self.refresh_token = "test_refresh"
32 self.oauth_session = {
33 "did": did,
34 "handle": "testuser.bsky.social",
35 "pds_url": "https://test.pds",
36 "authserver_iss": "https://auth.test",
37 "scope": "atproto transition:generic",
38 "access_token": "test_token",
39 "refresh_token": "test_refresh",
40 "dpop_private_key_pem": "fake_key",
41 "dpop_authserver_nonce": "",
42 "dpop_pds_nonce": "",
43 }
44
45
46@pytest.fixture
47async def test_artist(db_session: AsyncSession) -> Artist:
48 """create a test artist."""
49 artist = Artist(
50 did="did:plc:artist123",
51 handle="artist.bsky.social",
52 display_name="Test Artist",
53 )
54 db_session.add(artist)
55 await db_session.commit()
56 await db_session.refresh(artist)
57 return artist
58
59
60@pytest.fixture
61def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]:
62 """create test app with mocked auth."""
63
64 async def mock_require_auth() -> Session:
65 return MockSession(did="did:plc:artist123")
66
67 app.dependency_overrides[require_auth] = mock_require_auth
68
69 yield app
70
71 app.dependency_overrides.clear()
72
73
74async def test_duplicate_upload_detection(
75 db_session: AsyncSession, test_artist: Artist
76):
77 """test that duplicate detection logic prevents multiple tracks with same file_id.
78
79 regression test for banana mix incident where stellz uploaded same file twice,
80 creating two tracks pointing to the same R2 object.
81 """
82 file_id = "test_file_id_123"
83
84 # create first track with this file_id
85 track1 = Track(
86 title="original upload",
87 artist_did=test_artist.did,
88 file_id=file_id,
89 file_type="mp3",
90 extra={"duration": 180},
91 )
92 db_session.add(track1)
93 await db_session.commit()
94
95 # verify duplicate detection query works correctly
96 # this is the same logic used in src/backend/api/tracks.py:181-203
97 stmt = select(Track).where(
98 Track.file_id == file_id,
99 Track.artist_did == test_artist.did,
100 )
101 result = await db_session.execute(stmt)
102 existing_track = result.scalar_one_or_none()
103
104 # should find the existing track
105 assert existing_track is not None
106 assert existing_track.id == track1.id
107
108 # attempting to create another track with same file_id should be detected
109 # (in practice, the upload endpoint would reject this before DB insert)
110 track2_attempt = Track(
111 title="duplicate upload",
112 artist_did=test_artist.did,
113 file_id=file_id,
114 file_type="mp3",
115 extra={"duration": 180},
116 )
117
118 # verify that querying before insert finds the duplicate
119 stmt = select(Track).where(
120 Track.file_id == track2_attempt.file_id,
121 Track.artist_did == track2_attempt.artist_did,
122 )
123 result = await db_session.execute(stmt)
124 duplicate_check = result.scalar_one_or_none()
125
126 # should find the original track, preventing duplicate
127 assert duplicate_check is not None
128 assert duplicate_check.id == track1.id
129
130
131async def test_refcount_prevents_r2_deletion(db_session: AsyncSession):
132 """test that R2 delete is skipped when multiple tracks reference the same file.
133
134 regression test for banana mix incident where deleting track 57 removed
135 the R2 file that track 56 was still using.
136 """
137
138 file_id = "shared_file_id"
139
140 # create two tracks with same file_id (duplicates that slipped through)
141 artist = Artist(
142 did="did:plc:artist456",
143 handle="artist2.bsky.social",
144 display_name="Artist Two",
145 )
146 db_session.add(artist)
147 await db_session.flush()
148
149 track1 = Track(
150 title="track 1",
151 artist_did=artist.did,
152 file_id=file_id,
153 file_type="mp3",
154 extra={},
155 )
156 track2 = Track(
157 title="track 2",
158 artist_did=artist.did,
159 file_id=file_id,
160 file_type="mp3",
161 extra={},
162 )
163 db_session.add(track1)
164 db_session.add(track2)
165 await db_session.commit()
166
167 # try to delete the file
168 # this should be skipped because refcount = 2
169 # mock R2Storage to avoid requiring credentials
170 with patch("backend.storage.r2.R2Storage") as MockR2Storage:
171 mock_storage = AsyncMock()
172 MockR2Storage.return_value = mock_storage
173 mock_storage.delete = AsyncMock(return_value=False)
174
175 storage = MockR2Storage()
176 result = await storage.delete(file_id)
177
178 # deletion should be skipped (returns False)
179 assert result is False
180
181
182async def test_atproto_cleanup_on_track_delete(
183 test_app: FastAPI, db_session: AsyncSession, test_artist: Artist
184):
185 """test that ATProto records are cleaned up when track is deleted.
186
187 regression test for banana mix incident where deleting track 57 left
188 orphaned ATProto record on stellz's PDS.
189 """
190 # create track with ATProto record
191 track = Track(
192 title="test track",
193 artist_did=test_artist.did,
194 file_id="test_file_789",
195 file_type="mp3",
196 extra={},
197 atproto_record_uri="at://did:plc:artist123/fm.plyr.track/abc123",
198 atproto_record_cid="bafytest123",
199 )
200 db_session.add(track)
201 await db_session.commit()
202 await db_session.refresh(track)
203
204 # mock storage delete to avoid R2 errors
205 # mock ATProto delete at the records module level before import
206 with (
207 patch("backend.api.tracks.mutations.storage.delete", new_callable=AsyncMock),
208 patch(
209 "backend.api.tracks.mutations.delete_record_by_uri",
210 new_callable=AsyncMock,
211 ) as mock_delete_atproto,
212 ):
213 async with AsyncClient(
214 transport=ASGITransport(app=test_app), base_url="http://test"
215 ) as client:
216 response = await client.delete(f"/tracks/{track.id}")
217
218 assert response.status_code == 200
219
220 # verify ATProto record deletion was called
221 mock_delete_atproto.assert_called_once()
222 call_args = mock_delete_atproto.call_args
223 assert call_args.args[1] == track.atproto_record_uri
224
225 # verify track was deleted from DB
226 result = await db_session.execute(select(Track).where(Track.id == track.id))
227 assert result.scalar_one_or_none() is None
228
229
230async def test_atproto_cleanup_handles_404(
231 test_app: FastAPI, db_session: AsyncSession, test_artist: Artist
232):
233 """test that track deletion continues even if ATProto record is already gone."""
234 # create track with ATProto record
235 track = Track(
236 title="test track",
237 artist_did=test_artist.did,
238 file_id="test_file_999",
239 file_type="mp3",
240 extra={},
241 atproto_record_uri="at://did:plc:artist123/fm.plyr.track/xyz123",
242 atproto_record_cid="bafytest456",
243 )
244 db_session.add(track)
245 await db_session.commit()
246 await db_session.refresh(track)
247
248 # mock ATProto delete to raise 404
249 with (
250 patch("backend.api.tracks.mutations.storage.delete", new_callable=AsyncMock),
251 patch(
252 "backend.api.tracks.mutations.delete_record_by_uri",
253 side_effect=Exception("404 not found"),
254 ) as mock_delete_atproto,
255 ):
256 async with AsyncClient(
257 transport=ASGITransport(app=test_app), base_url="http://test"
258 ) as client:
259 response = await client.delete(f"/tracks/{track.id}")
260
261 # should still succeed despite 404
262 assert response.status_code == 200
263
264 # verify ATProto delete was attempted
265 mock_delete_atproto.assert_called_once()
266
267 # verify track was still deleted from DB
268 result = await db_session.execute(select(Track).where(Track.id == track.id))
269 assert result.scalar_one_or_none() is None
270
271
272async def test_track_deletion_preserves_shared_album_image(
273 test_app: FastAPI, db_session: AsyncSession, test_artist: Artist
274):
275 """test that deleting a track doesn't delete the image if album shares it.
276
277 regression test for issue where track and album share the same image_id.
278 when the track is deleted, the image should NOT be deleted from R2 if
279 the album still references it.
280 """
281 shared_image_id = "shared_image_abc123"
282
283 # create album with image
284 album = Album(
285 artist_did=test_artist.did,
286 slug="test-album",
287 title="Test Album",
288 image_id=shared_image_id,
289 image_url="https://example.com/images/shared_image_abc123.jpg",
290 )
291 db_session.add(album)
292 await db_session.flush()
293
294 # create track with same image (this happens when album inherits track's image)
295 track = Track(
296 title="test track",
297 artist_did=test_artist.did,
298 file_id="test_file_shared_img",
299 file_type="mp3",
300 extra={},
301 album_id=album.id,
302 image_id=shared_image_id,
303 image_url="https://example.com/images/shared_image_abc123.jpg",
304 )
305 db_session.add(track)
306 await db_session.commit()
307 await db_session.refresh(track)
308
309 # track storage.delete calls
310 delete_calls: list[str] = []
311
312 async def mock_delete(file_id: str, file_type: str | None = None):
313 delete_calls.append(file_id)
314
315 with (
316 patch(
317 "backend.api.tracks.mutations.storage.delete",
318 side_effect=mock_delete,
319 ),
320 patch(
321 "backend.api.tracks.mutations.schedule_album_list_sync",
322 new_callable=AsyncMock,
323 ),
324 ):
325 async with AsyncClient(
326 transport=ASGITransport(app=test_app), base_url="http://test"
327 ) as client:
328 response = await client.delete(f"/tracks/{track.id}")
329
330 assert response.status_code == 200
331
332 # audio file should be deleted
333 assert "test_file_shared_img" in delete_calls
334
335 # shared image should NOT be deleted (album still uses it)
336 assert shared_image_id not in delete_calls
337
338 # verify album still has its image
339 await db_session.refresh(album)
340 assert album.image_id == shared_image_id
341
342
343async def test_track_deletion_deletes_unshared_image(
344 test_app: FastAPI, db_session: AsyncSession, test_artist: Artist
345):
346 """test that deleting a track DOES delete the image if album doesn't share it.
347
348 when the track has an image but the album has a different image (or no album),
349 the track's image should be deleted from R2.
350 """
351 track_image_id = "track_only_image_xyz"
352
353 # create track with image but no album
354 track = Track(
355 title="test track",
356 artist_did=test_artist.did,
357 file_id="test_file_unshared_img",
358 file_type="mp3",
359 extra={},
360 image_id=track_image_id,
361 image_url="https://example.com/images/track_only_image_xyz.jpg",
362 )
363 db_session.add(track)
364 await db_session.commit()
365 await db_session.refresh(track)
366
367 # track storage.delete calls
368 delete_calls: list[str] = []
369
370 async def mock_delete(file_id: str, file_type: str | None = None):
371 delete_calls.append(file_id)
372
373 with (
374 patch(
375 "backend.api.tracks.mutations.storage.delete",
376 side_effect=mock_delete,
377 ),
378 patch(
379 "backend.api.tracks.mutations.schedule_album_list_sync",
380 new_callable=AsyncMock,
381 ),
382 ):
383 async with AsyncClient(
384 transport=ASGITransport(app=test_app), base_url="http://test"
385 ) as client:
386 response = await client.delete(f"/tracks/{track.id}")
387
388 assert response.status_code == 200
389
390 # both audio file and image should be deleted (no album shares the image)
391 assert "test_file_unshared_img" in delete_calls
392 assert track_image_id in delete_calls