music on atproto
plyr.fm
1"""integration tests for track upload/delete using real API token.
2
3these tests require:
4- PLYRFM_API_TOKEN or PLYR_TOKEN env var
5- running backend (local or remote)
6- set PLYR_API_URL for non-local testing (default: http://localhost:8001)
7
8run with: uv run pytest tests/test_integration_upload.py -m integration -v
9"""
10
11import json
12import os
13import struct
14import tempfile
15from collections.abc import Generator
16from pathlib import Path
17
18import httpx
19import pytest
20
21API_URL = os.getenv("PLYR_API_URL", "http://localhost:8001")
22TOKEN = os.getenv("PLYR_TOKEN") or os.getenv("PLYRFM_API_TOKEN")
23
24
25def generate_wav_file(duration_seconds: float = 1.0, sample_rate: int = 44100) -> bytes:
26 """generate a minimal valid WAV file with silence."""
27 num_channels = 1
28 bits_per_sample = 16
29 num_samples = int(sample_rate * duration_seconds)
30 data_size = num_samples * num_channels * (bits_per_sample // 8)
31
32 # WAV header
33 header = struct.pack(
34 "<4sI4s4sIHHIIHH4sI",
35 b"RIFF",
36 36 + data_size, # file size - 8
37 b"WAVE",
38 b"fmt ",
39 16, # fmt chunk size
40 1, # audio format (PCM)
41 num_channels,
42 sample_rate,
43 sample_rate * num_channels * (bits_per_sample // 8), # byte rate
44 num_channels * (bits_per_sample // 8), # block align
45 bits_per_sample,
46 b"data",
47 data_size,
48 )
49
50 # silence (zeros)
51 audio_data = b"\x00" * data_size
52
53 return header + audio_data
54
55
56@pytest.fixture
57def test_audio_file() -> Generator[Path, None, None]:
58 """create a temporary test audio file."""
59 wav_data = generate_wav_file(duration_seconds=1.0)
60
61 with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
62 f.write(wav_data)
63 path = Path(f.name)
64
65 yield path
66
67 # cleanup
68 path.unlink(missing_ok=True)
69
70
71@pytest.mark.integration
72async def test_upload_and_delete_track(test_audio_file: Path):
73 """integration test: upload a track, wait for processing, then delete it."""
74 if not TOKEN:
75 pytest.skip("PLYR_TOKEN or PLYRFM_API_TOKEN not set")
76
77 async with httpx.AsyncClient(timeout=120.0) as client:
78 # 1. verify auth works
79 auth_response = await client.get(
80 f"{API_URL}/auth/me",
81 headers={"Authorization": f"Bearer {TOKEN}"},
82 )
83 if auth_response.status_code == 401:
84 pytest.skip("token is invalid or expired")
85 assert auth_response.status_code == 200, f"auth failed: {auth_response.text}"
86 user = auth_response.json()
87 print(f"authenticated as: {user['handle']}")
88
89 # 2. upload track
90 with open(test_audio_file, "rb") as f:
91 files = {"file": ("test_integration.wav", f, "audio/wav")}
92 data = {"title": "Integration Test Track (DELETE ME)"}
93
94 upload_response = await client.post(
95 f"{API_URL}/tracks/",
96 headers={"Authorization": f"Bearer {TOKEN}"},
97 files=files,
98 data=data,
99 )
100
101 if upload_response.status_code == 403:
102 detail = upload_response.json().get("detail", "")
103 if "artist_profile_required" in detail:
104 pytest.skip("user needs artist profile setup")
105 if "scope_upgrade_required" in detail:
106 pytest.skip("token needs re-authorization with new scopes")
107
108 assert upload_response.status_code == 200, (
109 f"upload failed: {upload_response.text}"
110 )
111
112 upload_data = upload_response.json()
113 upload_id = upload_data["upload_id"]
114 print(f"upload started: {upload_id}")
115
116 # 3. poll for completion via SSE
117 track_id = None
118 async with client.stream(
119 "GET",
120 f"{API_URL}/tracks/uploads/{upload_id}/progress",
121 headers={"Authorization": f"Bearer {TOKEN}"},
122 ) as response:
123 async for line in response.aiter_lines():
124 if line.startswith("data: "):
125 data = json.loads(line[6:])
126 status = data.get("status")
127 print(f" status: {status} - {data.get('message', '')}")
128
129 if status == "completed":
130 track_id = data.get("track_id")
131 print(f"upload complete! track_id: {track_id}")
132 break
133 elif status == "failed":
134 error = data.get("error", "unknown error")
135 pytest.fail(f"upload failed: {error}")
136
137 assert track_id is not None, "upload completed but no track_id returned"
138
139 # 4. verify track exists
140 track_response = await client.get(f"{API_URL}/tracks/{track_id}")
141 assert track_response.status_code == 200, (
142 f"track not found: {track_response.text}"
143 )
144 track = track_response.json()
145 print(f"track created: {track['title']} by {track['artist']['handle']}")
146
147 # 5. delete track
148 delete_response = await client.delete(
149 f"{API_URL}/tracks/{track_id}",
150 headers={"Authorization": f"Bearer {TOKEN}"},
151 )
152 assert delete_response.status_code == 200, (
153 f"delete failed: {delete_response.text}"
154 )
155 print(f"track {track_id} deleted successfully")
156
157 # 6. verify track is gone
158 verify_response = await client.get(f"{API_URL}/tracks/{track_id}")
159 assert verify_response.status_code == 404, "track should be deleted"
160 print("verified track no longer exists")