at main 6.5 kB view raw
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()