music on atproto
plyr.fm
1"""tests for ATProto profile record integration with artist endpoints."""
2
3from collections.abc import Generator
4from unittest.mock import AsyncMock, patch
5
6import pytest
7from fastapi import FastAPI
8from httpx import ASGITransport, AsyncClient
9from sqlalchemy.ext.asyncio import AsyncSession
10
11from backend._internal import Session, require_auth
12from backend.main import app
13from backend.models import Artist
14
15
16class MockSession(Session):
17 """mock session for auth bypass in tests."""
18
19 def __init__(self, did: str = "did:test:user123"):
20 self.did = did
21 self.handle = "testuser.bsky.social"
22 self.session_id = "test_session_id"
23 self.access_token = "test_token"
24 self.refresh_token = "test_refresh"
25 self.oauth_session = {
26 "did": did,
27 "handle": "testuser.bsky.social",
28 "pds_url": "https://test.pds",
29 "authserver_iss": "https://auth.test",
30 "scope": "atproto transition:generic",
31 "access_token": "test_token",
32 "refresh_token": "test_refresh",
33 "dpop_private_key_pem": "fake_key",
34 "dpop_authserver_nonce": "",
35 "dpop_pds_nonce": "",
36 }
37
38
39@pytest.fixture
40def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]:
41 """create test app with mocked auth."""
42
43 async def mock_require_auth() -> Session:
44 return MockSession(did="did:plc:testartist123")
45
46 app.dependency_overrides[require_auth] = mock_require_auth
47
48 yield app
49
50 app.dependency_overrides.clear()
51
52
53@pytest.fixture
54async def test_artist(db_session: AsyncSession) -> Artist:
55 """create a test artist."""
56 artist = Artist(
57 did="did:plc:testartist123",
58 handle="testartist.bsky.social",
59 display_name="Test Artist",
60 )
61 db_session.add(artist)
62 await db_session.commit()
63 await db_session.refresh(artist)
64 return artist
65
66
67async def test_update_bio_creates_atproto_profile_record(
68 test_app: FastAPI, db_session: AsyncSession, test_artist: Artist
69):
70 """test that updating bio triggers ATProto profile record upsert."""
71 with patch(
72 "backend.api.artists.upsert_profile_record",
73 new_callable=AsyncMock,
74 return_value=(
75 "at://did:plc:testartist123/fm.plyr.actor.profile/self",
76 "bafytest123",
77 ),
78 ) as mock_upsert:
79 async with AsyncClient(
80 transport=ASGITransport(app=test_app), base_url="http://test"
81 ) as client:
82 response = await client.put(
83 "/artists/me",
84 json={"bio": "my new artist bio"},
85 )
86
87 assert response.status_code == 200
88 data = response.json()
89 assert data["bio"] == "my new artist bio"
90
91 # verify ATProto record was created
92 mock_upsert.assert_called_once()
93 call_args = mock_upsert.call_args
94 assert call_args.kwargs["bio"] == "my new artist bio"
95
96
97async def test_update_bio_continues_on_atproto_failure(
98 test_app: FastAPI, db_session: AsyncSession, test_artist: Artist
99):
100 """test that bio update succeeds even if ATProto call fails.
101
102 database is source of truth, ATProto failure should not fail the request.
103 """
104 with patch(
105 "backend.api.artists.upsert_profile_record",
106 side_effect=Exception("PDS connection failed"),
107 ) as mock_upsert:
108 async with AsyncClient(
109 transport=ASGITransport(app=test_app), base_url="http://test"
110 ) as client:
111 response = await client.put(
112 "/artists/me",
113 json={"bio": "bio that should still save"},
114 )
115
116 # should still succeed despite ATProto failure
117 assert response.status_code == 200
118 data = response.json()
119 assert data["bio"] == "bio that should still save"
120
121 # verify ATProto was attempted
122 mock_upsert.assert_called_once()
123
124
125async def test_update_without_bio_skips_atproto(
126 test_app: FastAPI, db_session: AsyncSession, test_artist: Artist
127):
128 """test that updating only display_name does not call ATProto."""
129 with patch(
130 "backend.api.artists.upsert_profile_record",
131 new_callable=AsyncMock,
132 ) as mock_upsert:
133 async with AsyncClient(
134 transport=ASGITransport(app=test_app), base_url="http://test"
135 ) as client:
136 response = await client.put(
137 "/artists/me",
138 json={"display_name": "New Display Name"},
139 )
140
141 assert response.status_code == 200
142 data = response.json()
143 assert data["display_name"] == "New Display Name"
144
145 # ATProto should NOT be called when bio is not updated
146 mock_upsert.assert_not_called()
147
148
149async def test_create_artist_with_bio_creates_atproto_record(
150 test_app: FastAPI, db_session: AsyncSession
151):
152 """test that creating artist with bio triggers ATProto profile record creation."""
153 with patch(
154 "backend.api.artists.upsert_profile_record",
155 new_callable=AsyncMock,
156 return_value=(
157 "at://did:plc:testartist123/fm.plyr.actor.profile/self",
158 "bafytest456",
159 ),
160 ) as mock_upsert:
161 async with AsyncClient(
162 transport=ASGITransport(app=test_app), base_url="http://test"
163 ) as client:
164 response = await client.post(
165 "/artists/",
166 json={
167 "display_name": "New Artist",
168 "bio": "my initial bio",
169 },
170 )
171
172 assert response.status_code == 200
173 data = response.json()
174 assert data["bio"] == "my initial bio"
175
176 # verify ATProto record was created
177 mock_upsert.assert_called_once()
178 call_args = mock_upsert.call_args
179 assert call_args.kwargs["bio"] == "my initial bio"
180
181
182async def test_create_artist_without_bio_skips_atproto(
183 test_app: FastAPI, db_session: AsyncSession
184):
185 """test that creating artist without bio does not call ATProto."""
186 with patch(
187 "backend.api.artists.upsert_profile_record",
188 new_callable=AsyncMock,
189 ) as mock_upsert:
190 async with AsyncClient(
191 transport=ASGITransport(app=test_app), base_url="http://test"
192 ) as client:
193 response = await client.post(
194 "/artists/",
195 json={"display_name": "Artist Without Bio"},
196 )
197
198 assert response.status_code == 200
199 data = response.json()
200 assert data["bio"] is None
201
202 # ATProto should NOT be called when no bio provided
203 mock_upsert.assert_not_called()