at main 13 kB view raw
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