audio streaming app plyr.fm

feat: add integration test harness with plyrfm SDK (#744)

* feat: add integration test harness with plyrfm SDK

adds a comprehensive integration test suite that runs against staging:

- **audio generation**: pure Python drone/sine wave generation (no FFmpeg)
- generates musical tones (A4, E4, C4) for testing uploads
- ~22KB per 2-second WAV file

- **test fixtures**: multi-user client fixtures with automatic skip
- PLYR_TEST_TOKEN_1/2/3 for up to 3 test users
- graceful skip when tokens not configured

- **track lifecycle tests**: upload → edit → delete
- test_upload_verify_delete: basic CRUD
- test_upload_edit_title: title modification
- test_upload_edit_tags: tag modification
- test_upload_appears_in_my_tracks: list verification
- test_upload_searchable: search indexing

- **cross-user tests**: permissions and interactions
- test_cross_user_like: like/unlike between users
- test_cannot_delete_others_track: permission boundary
- test_cannot_edit_others_track: permission boundary
- test_public_track_visibility: visibility verification

- **GitHub Actions workflow**: runs after staging deploy
- triggered by workflow_run on deploy-staging success
- manual dispatch support
- graceful skip when secrets not configured

secrets needed:
- PLYR_TEST_TOKEN_1 (required)
- PLYR_TEST_TOKEN_2 (optional, enables cross-user tests)
- PLYR_TEST_TOKEN_3 (optional)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add integration test documentation

documents test accounts, running tests locally, and adding new tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci: temporarily trigger on feature branch push (remove before merge)

* ci: fix job condition to allow push trigger

* ci: remove temporary push trigger after successful test

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io

Claude Opus 4.5 and committed by
GitHub
ec4cfb4e af81e761

+824 -3
+75
.github/workflows/integration-tests.yml
··· 1 + name: integration tests 2 + 3 + on: 4 + # run after staging deployment completes 5 + workflow_run: 6 + workflows: ["deploy staging"] 7 + types: 8 + - completed 9 + branches: 10 + - main 11 + 12 + # allow manual trigger 13 + workflow_dispatch: 14 + inputs: 15 + skip_if_no_tokens: 16 + description: "Skip tests if tokens not configured (vs fail)" 17 + type: boolean 18 + default: true 19 + 20 + permissions: 21 + contents: read 22 + 23 + jobs: 24 + integration: 25 + name: run integration tests 26 + runs-on: ubuntu-latest 27 + # only run if staging deploy succeeded (or manual trigger) 28 + if: > 29 + github.event_name == 'workflow_dispatch' || 30 + github.event.workflow_run.conclusion == 'success' 31 + timeout-minutes: 15 32 + steps: 33 + - uses: actions/checkout@v4 34 + 35 + - name: check token configuration 36 + id: check-tokens 37 + run: | 38 + if [ -z "${{ secrets.PLYR_TEST_TOKEN_1 }}" ]; then 39 + echo "has_tokens=false" >> $GITHUB_OUTPUT 40 + echo "::warning::PLYR_TEST_TOKEN_1 not configured - integration tests will be skipped" 41 + else 42 + echo "has_tokens=true" >> $GITHUB_OUTPUT 43 + fi 44 + 45 + - name: set up python 46 + if: steps.check-tokens.outputs.has_tokens == 'true' 47 + uses: actions/setup-python@v5 48 + with: 49 + python-version: "3.12" 50 + 51 + - name: install uv 52 + if: steps.check-tokens.outputs.has_tokens == 'true' 53 + uses: astral-sh/setup-uv@v7 54 + with: 55 + enable-cache: true 56 + cache-dependency-glob: "backend/uv.lock" 57 + 58 + - name: install dependencies 59 + if: steps.check-tokens.outputs.has_tokens == 'true' 60 + run: cd backend && uv sync --locked 61 + 62 + - name: run integration tests 63 + if: steps.check-tokens.outputs.has_tokens == 'true' 64 + env: 65 + PLYR_API_URL: https://api-stg.plyr.fm 66 + PLYR_TEST_TOKEN_1: ${{ secrets.PLYR_TEST_TOKEN_1 }} 67 + PLYR_TEST_TOKEN_2: ${{ secrets.PLYR_TEST_TOKEN_2 }} 68 + PLYR_TEST_TOKEN_3: ${{ secrets.PLYR_TEST_TOKEN_3 }} 69 + run: | 70 + cd backend 71 + uv run pytest tests/integration -m integration -v --tb=short 72 + 73 + - name: prune uv cache 74 + if: always() 75 + run: uv cache prune --ci
+1
.gitignore
··· 23 23 .env 24 24 .env.local 25 25 test-audio/ 26 + plyr_test_tokens.env 26 27 27 28 # Development/sandbox 28 29 sandbox/
+1
backend/pyproject.toml
··· 43 43 "dirty-equals>=0.9.0", 44 44 "ipython>=8.12.3", 45 45 "pdbpp>=0.10.3", 46 + "plyrfm @ git+https://github.com/zzstoatzz/plyr-python-client#subdirectory=packages/plyrfm", 46 47 "prek>=0.2.13", 47 48 "pytest>=8.3.3", 48 49 "pytest-asyncio>=1.0.0",
+8
backend/tests/integration/__init__.py
··· 1 + """integration tests for plyr.fm API. 2 + 3 + these tests run against a real staging environment and require: 4 + - PLYR_TEST_TOKEN_1, PLYR_TEST_TOKEN_2, PLYR_TEST_TOKEN_3 env vars 5 + - staging API at https://api-stg.plyr.fm (or PLYR_API_URL override) 6 + 7 + run with: uv run pytest tests/integration -m integration -v 8 + """
+147
backend/tests/integration/conftest.py
··· 1 + """fixtures for integration tests. 2 + 3 + these tests run against a real staging environment and require: 4 + - PLYR_TEST_TOKEN_1: dev token for primary test user 5 + - PLYR_TEST_TOKEN_2: dev token for secondary test user (optional) 6 + - PLYR_TEST_TOKEN_3: dev token for tertiary test user (optional) 7 + - PLYR_API_URL: API base URL (defaults to https://api-stg.plyr.fm) 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import os 13 + from collections.abc import AsyncGenerator, Generator 14 + from dataclasses import dataclass 15 + from pathlib import Path 16 + from typing import TYPE_CHECKING 17 + 18 + import pytest 19 + 20 + from .utils.audio import save_drone 21 + 22 + if TYPE_CHECKING: 23 + from plyrfm import AsyncPlyrClient 24 + 25 + 26 + @dataclass 27 + class IntegrationSettings: 28 + """settings for integration tests.""" 29 + 30 + api_url: str 31 + token_1: str | None 32 + token_2: str | None 33 + token_3: str | None 34 + 35 + @property 36 + def has_primary_token(self) -> bool: 37 + """check if primary token is configured.""" 38 + return bool(self.token_1) 39 + 40 + @property 41 + def has_multi_user(self) -> bool: 42 + """check if multiple users are configured.""" 43 + return bool(self.token_1 and self.token_2) 44 + 45 + @property 46 + def has_three_users(self) -> bool: 47 + """check if all three users are configured.""" 48 + return bool(self.token_1 and self.token_2 and self.token_3) 49 + 50 + 51 + @pytest.fixture(scope="session") 52 + def integration_settings() -> IntegrationSettings: 53 + """load integration test settings from environment.""" 54 + return IntegrationSettings( 55 + api_url=os.getenv("PLYR_API_URL", "https://api-stg.plyr.fm"), 56 + token_1=os.getenv("PLYR_TEST_TOKEN_1"), 57 + token_2=os.getenv("PLYR_TEST_TOKEN_2"), 58 + token_3=os.getenv("PLYR_TEST_TOKEN_3"), 59 + ) 60 + 61 + 62 + def _skip_if_no_token(settings: IntegrationSettings) -> None: 63 + """skip test if no primary token is configured.""" 64 + if not settings.has_primary_token: 65 + pytest.skip("PLYR_TEST_TOKEN_1 not set") 66 + 67 + 68 + def _skip_if_no_multi_user(settings: IntegrationSettings) -> None: 69 + """skip test if multi-user tokens are not configured.""" 70 + if not settings.has_multi_user: 71 + pytest.skip("PLYR_TEST_TOKEN_1 and PLYR_TEST_TOKEN_2 required") 72 + 73 + 74 + @pytest.fixture 75 + async def user1_client( 76 + integration_settings: IntegrationSettings, 77 + ) -> AsyncGenerator[AsyncPlyrClient, None]: 78 + """async client authenticated as test user 1.""" 79 + _skip_if_no_token(integration_settings) 80 + 81 + from plyrfm import AsyncPlyrClient 82 + 83 + async with AsyncPlyrClient( 84 + token=integration_settings.token_1, 85 + api_url=integration_settings.api_url, 86 + timeout=120.0, 87 + ) as client: 88 + yield client 89 + 90 + 91 + @pytest.fixture 92 + async def user2_client( 93 + integration_settings: IntegrationSettings, 94 + ) -> AsyncGenerator[AsyncPlyrClient, None]: 95 + """async client authenticated as test user 2.""" 96 + _skip_if_no_multi_user(integration_settings) 97 + 98 + from plyrfm import AsyncPlyrClient 99 + 100 + async with AsyncPlyrClient( 101 + token=integration_settings.token_2, 102 + api_url=integration_settings.api_url, 103 + timeout=120.0, 104 + ) as client: 105 + yield client 106 + 107 + 108 + @pytest.fixture 109 + async def user3_client( 110 + integration_settings: IntegrationSettings, 111 + ) -> AsyncGenerator[AsyncPlyrClient, None]: 112 + """async client authenticated as test user 3.""" 113 + if not integration_settings.has_three_users: 114 + pytest.skip("PLYR_TEST_TOKEN_3 required") 115 + 116 + from plyrfm import AsyncPlyrClient 117 + 118 + async with AsyncPlyrClient( 119 + token=integration_settings.token_3, 120 + api_url=integration_settings.api_url, 121 + timeout=120.0, 122 + ) as client: 123 + yield client 124 + 125 + 126 + @pytest.fixture 127 + def drone_a4(tmp_path: Path) -> Generator[Path, None, None]: 128 + """generate a 2-second A4 drone (440Hz).""" 129 + path = tmp_path / "drone_a4.wav" 130 + save_drone(path, "A4", duration_sec=2.0) 131 + yield path 132 + 133 + 134 + @pytest.fixture 135 + def drone_e4(tmp_path: Path) -> Generator[Path, None, None]: 136 + """generate a 2-second E4 drone (330Hz).""" 137 + path = tmp_path / "drone_e4.wav" 138 + save_drone(path, "E4", duration_sec=2.0) 139 + yield path 140 + 141 + 142 + @pytest.fixture 143 + def drone_c4(tmp_path: Path) -> Generator[Path, None, None]: 144 + """generate a 2-second C4 drone (262Hz).""" 145 + path = tmp_path / "drone_c4.wav" 146 + save_drone(path, "C4", duration_sec=2.0) 147 + yield path
+171
backend/tests/integration/test_interactions.py
··· 1 + """integration tests for cross-user interactions. 2 + 3 + tests that require multiple authenticated users: 4 + - likes between users 5 + - permission boundaries (can't edit others' tracks) 6 + """ 7 + 8 + from __future__ import annotations 9 + 10 + from pathlib import Path 11 + from typing import TYPE_CHECKING 12 + 13 + import pytest 14 + 15 + if TYPE_CHECKING: 16 + from plyrfm import AsyncPlyrClient 17 + 18 + pytestmark = [pytest.mark.integration, pytest.mark.timeout(120)] 19 + 20 + 21 + async def test_cross_user_like( 22 + user1_client: AsyncPlyrClient, 23 + user2_client: AsyncPlyrClient, 24 + drone_a4: Path, 25 + ): 26 + """user2 can like and unlike user1's track.""" 27 + client1 = user1_client 28 + client2 = user2_client 29 + 30 + # user1 uploads a track 31 + result = await client1.upload( 32 + drone_a4, 33 + "Test Drone - Cross User Like", 34 + tags={"integration-test"}, 35 + ) 36 + track_id = result.track_id 37 + 38 + try: 39 + # get initial like count 40 + track = await client1.get_track(track_id) 41 + initial_likes = track.like_count 42 + 43 + # user2 likes the track 44 + await client2.like(track_id) 45 + 46 + # verify like count increased 47 + track = await client1.get_track(track_id) 48 + assert track.like_count == initial_likes + 1 49 + 50 + # verify track appears in user2's liked tracks 51 + liked = await client2.liked_tracks(limit=100) 52 + liked_ids = [t.id for t in liked] 53 + assert track_id in liked_ids 54 + 55 + # user2 unlikes the track 56 + await client2.unlike(track_id) 57 + 58 + # verify like count decreased 59 + track = await client1.get_track(track_id) 60 + assert track.like_count == initial_likes 61 + 62 + # verify track no longer in user2's liked tracks 63 + liked = await client2.liked_tracks(limit=100) 64 + liked_ids = [t.id for t in liked] 65 + assert track_id not in liked_ids 66 + 67 + finally: 68 + await client1.delete(track_id) 69 + 70 + 71 + async def test_cannot_delete_others_track( 72 + user1_client: AsyncPlyrClient, 73 + user2_client: AsyncPlyrClient, 74 + drone_e4: Path, 75 + ): 76 + """user2 cannot delete user1's track.""" 77 + client1 = user1_client 78 + client2 = user2_client 79 + 80 + # user1 uploads a track 81 + result = await client1.upload( 82 + drone_e4, 83 + "Test Drone - Cannot Delete", 84 + tags={"integration-test"}, 85 + ) 86 + track_id = result.track_id 87 + 88 + try: 89 + # user2 tries to delete - should fail 90 + with pytest.raises(Exception) as exc_info: 91 + await client2.delete(track_id) 92 + 93 + # verify it's a permission error (403 or similar) 94 + assert ( 95 + "403" in str(exc_info.value) or "forbidden" in str(exc_info.value).lower() 96 + ) 97 + 98 + # verify track still exists 99 + track = await client1.get_track(track_id) 100 + assert track.id == track_id 101 + 102 + finally: 103 + # user1 cleans up 104 + await client1.delete(track_id) 105 + 106 + 107 + async def test_cannot_edit_others_track( 108 + user1_client: AsyncPlyrClient, 109 + user2_client: AsyncPlyrClient, 110 + drone_c4: Path, 111 + ): 112 + """user2 cannot edit user1's track.""" 113 + from plyrfm._internal.types import TrackPatch 114 + 115 + client1 = user1_client 116 + client2 = user2_client 117 + 118 + # user1 uploads a track 119 + result = await client1.upload( 120 + drone_c4, 121 + "Test Drone - Cannot Edit", 122 + tags={"integration-test"}, 123 + ) 124 + track_id = result.track_id 125 + 126 + try: 127 + # user2 tries to edit - should fail 128 + with pytest.raises(Exception) as exc_info: 129 + await client2.update_track( 130 + track_id, 131 + TrackPatch(title="Malicious Edit"), 132 + ) 133 + 134 + # verify it's a permission error 135 + assert ( 136 + "403" in str(exc_info.value) or "forbidden" in str(exc_info.value).lower() 137 + ) 138 + 139 + # verify title unchanged 140 + track = await client1.get_track(track_id) 141 + assert track.title == "Test Drone - Cannot Edit" 142 + 143 + finally: 144 + await client1.delete(track_id) 145 + 146 + 147 + async def test_public_track_visibility( 148 + user1_client: AsyncPlyrClient, 149 + user2_client: AsyncPlyrClient, 150 + drone_a4: Path, 151 + ): 152 + """tracks uploaded by user1 are visible to user2.""" 153 + client1 = user1_client 154 + client2 = user2_client 155 + 156 + # user1 uploads a track 157 + result = await client1.upload( 158 + drone_a4, 159 + "Test Drone - Public Visibility", 160 + tags={"integration-test"}, 161 + ) 162 + track_id = result.track_id 163 + 164 + try: 165 + # user2 can see the track 166 + track = await client2.get_track(track_id) 167 + assert track.id == track_id 168 + assert track.title == "Test Drone - Public Visibility" 169 + 170 + finally: 171 + await client1.delete(track_id)
+173
backend/tests/integration/test_track_lifecycle.py
··· 1 + """integration tests for track lifecycle: upload, edit, delete. 2 + 3 + these tests exercise the full track CRUD workflow using the plyrfm SDK. 4 + each test is self-cleaning: it creates data, verifies it, then deletes it. 5 + """ 6 + 7 + from __future__ import annotations 8 + 9 + from pathlib import Path 10 + from typing import TYPE_CHECKING 11 + 12 + import pytest 13 + 14 + if TYPE_CHECKING: 15 + from plyrfm import AsyncPlyrClient 16 + 17 + pytestmark = [pytest.mark.integration, pytest.mark.timeout(120)] 18 + 19 + 20 + async def test_upload_verify_delete(user1_client: AsyncPlyrClient, drone_a4: Path): 21 + """basic lifecycle: upload a track, verify it exists, then delete it.""" 22 + client = user1_client 23 + 24 + # upload 25 + result = await client.upload( 26 + drone_a4, 27 + "Test Drone A4", 28 + tags={"integration-test", "drone"}, 29 + ) 30 + track_id = result.track_id 31 + assert track_id is not None 32 + 33 + try: 34 + # verify upload succeeded 35 + track = await client.get_track(track_id) 36 + assert track.title == "Test Drone A4" 37 + assert "integration-test" in track.tags 38 + assert "drone" in track.tags 39 + assert track.file_type == "wav" 40 + 41 + finally: 42 + # always cleanup 43 + await client.delete(track_id) 44 + 45 + # verify deletion 46 + with pytest.raises(Exception): # noqa: B017 47 + await client.get_track(track_id) 48 + 49 + 50 + async def test_upload_edit_title(user1_client: AsyncPlyrClient, drone_e4: Path): 51 + """upload a track and edit its title.""" 52 + from plyrfm._internal.types import TrackPatch 53 + 54 + client = user1_client 55 + 56 + # upload 57 + result = await client.upload( 58 + drone_e4, 59 + "Test Drone E4 - Original", 60 + tags={"integration-test"}, 61 + ) 62 + track_id = result.track_id 63 + 64 + try: 65 + # verify original title 66 + track = await client.get_track(track_id) 67 + assert track.title == "Test Drone E4 - Original" 68 + 69 + # edit title 70 + updated = await client.update_track( 71 + track_id, 72 + TrackPatch(title="Test Drone E4 - Edited"), 73 + ) 74 + assert updated.title == "Test Drone E4 - Edited" 75 + 76 + # verify edit persisted 77 + track = await client.get_track(track_id) 78 + assert track.title == "Test Drone E4 - Edited" 79 + 80 + finally: 81 + await client.delete(track_id) 82 + 83 + 84 + async def test_upload_edit_tags(user1_client: AsyncPlyrClient, drone_c4: Path): 85 + """upload a track and edit its tags.""" 86 + from plyrfm._internal.types import TrackPatch 87 + 88 + client = user1_client 89 + 90 + # upload with initial tags 91 + result = await client.upload( 92 + drone_c4, 93 + "Test Drone C4", 94 + tags={"integration-test", "original-tag"}, 95 + ) 96 + track_id = result.track_id 97 + 98 + try: 99 + # verify initial tags 100 + track = await client.get_track(track_id) 101 + assert "integration-test" in track.tags 102 + assert "original-tag" in track.tags 103 + 104 + # edit tags 105 + updated = await client.update_track( 106 + track_id, 107 + TrackPatch(tags=["integration-test", "new-tag", "another-tag"]), 108 + ) 109 + assert "new-tag" in updated.tags 110 + assert "another-tag" in updated.tags 111 + 112 + finally: 113 + await client.delete(track_id) 114 + 115 + 116 + async def test_upload_appears_in_my_tracks( 117 + user1_client: AsyncPlyrClient, 118 + drone_a4: Path, 119 + ): 120 + """uploaded track appears in user's track list.""" 121 + client = user1_client 122 + 123 + result = await client.upload( 124 + drone_a4, 125 + "Test Drone - My Tracks", 126 + tags={"integration-test"}, 127 + ) 128 + track_id = result.track_id 129 + 130 + try: 131 + # verify track appears in my_tracks 132 + my_tracks = await client.my_tracks(limit=100) 133 + track_ids = [t.id for t in my_tracks] 134 + assert track_id in track_ids 135 + 136 + finally: 137 + await client.delete(track_id) 138 + 139 + 140 + async def test_upload_searchable(user1_client: AsyncPlyrClient, drone_a4: Path): 141 + """uploaded track is searchable after upload.""" 142 + import asyncio 143 + 144 + client = user1_client 145 + 146 + # use unique title for search 147 + unique_title = "TestDroneSearchable12345" 148 + 149 + result = await client.upload( 150 + drone_a4, 151 + unique_title, 152 + tags={"integration-test"}, 153 + ) 154 + track_id = result.track_id 155 + 156 + try: 157 + # search may take a moment to index - retry a few times 158 + found = False 159 + for _ in range(5): 160 + search_result = await client.search(unique_title, type="tracks") 161 + # search results are in search_result.results, filter for tracks 162 + for item in search_result.results: 163 + if item.type == "track" and item.id == track_id: 164 + found = True 165 + break 166 + if found: 167 + break 168 + await asyncio.sleep(1) 169 + 170 + assert found, f"track {track_id} not found in search results" 171 + 172 + finally: 173 + await client.delete(track_id)
+5
backend/tests/integration/utils/__init__.py
··· 1 + """utility modules for integration tests.""" 2 + 3 + from .audio import generate_drone, note_to_freq 4 + 5 + __all__ = ["generate_drone", "note_to_freq"]
+147
backend/tests/integration/utils/audio.py
··· 1 + """pure python audio generation for integration tests. 2 + 3 + generates simple drone sounds (sine waves) without external dependencies. 4 + these are useful for testing upload/streaming without needing FFmpeg. 5 + """ 6 + 7 + import math 8 + import struct 9 + from io import BytesIO 10 + from pathlib import Path 11 + 12 + # standard musical note frequencies (A440 tuning) 13 + NOTE_FREQUENCIES: dict[str, float] = { 14 + "C3": 130.81, 15 + "D3": 146.83, 16 + "E3": 164.81, 17 + "F3": 174.61, 18 + "G3": 196.00, 19 + "A3": 220.00, 20 + "B3": 246.94, 21 + "C4": 261.63, 22 + "D4": 293.66, 23 + "E4": 329.63, 24 + "F4": 349.23, 25 + "G4": 392.00, 26 + "A4": 440.00, 27 + "B4": 493.88, 28 + "C5": 523.25, 29 + "A5": 880.00, 30 + } 31 + 32 + 33 + def note_to_freq(note: str) -> float: 34 + """convert note name to frequency. 35 + 36 + args: 37 + note: note name like 'A4', 'C3', etc. 38 + 39 + returns: 40 + frequency in Hz 41 + 42 + raises: 43 + ValueError: if note is not recognized 44 + """ 45 + if note not in NOTE_FREQUENCIES: 46 + valid = ", ".join(sorted(NOTE_FREQUENCIES.keys())) 47 + msg = f"unknown note: {note}. valid notes: {valid}" 48 + raise ValueError(msg) 49 + return NOTE_FREQUENCIES[note] 50 + 51 + 52 + def generate_drone( 53 + note: str = "A4", 54 + duration_sec: float = 2.0, 55 + sample_rate: int = 22050, 56 + amplitude: float = 0.3, 57 + ) -> BytesIO: 58 + """generate a pure sine wave drone as WAV audio. 59 + 60 + creates a simple tone at the specified musical note frequency. 61 + includes fade in/out to avoid clicks at start/end. 62 + 63 + args: 64 + note: musical note name (e.g., 'A4' for 440Hz). see NOTE_FREQUENCIES. 65 + duration_sec: duration in seconds 66 + sample_rate: samples per second (lower = smaller file) 67 + amplitude: volume from 0.0 to 1.0 68 + 69 + returns: 70 + BytesIO containing valid WAV file data 71 + 72 + example: 73 + >>> wav = generate_drone("A4", duration_sec=2.0) 74 + >>> Path("/tmp/drone.wav").write_bytes(wav.read()) 75 + """ 76 + freq = note_to_freq(note) 77 + num_samples = int(sample_rate * duration_sec) 78 + 79 + # generate sine wave with fade envelope 80 + samples = [] 81 + for i in range(num_samples): 82 + t = i / sample_rate 83 + # fade in first 0.1s, fade out last 0.1s to avoid clicks 84 + fade_in = min(t / 0.1, 1.0) 85 + fade_out = min((duration_sec - t) / 0.1, 1.0) 86 + envelope = fade_in * fade_out 87 + 88 + # generate sample 89 + sample_value = amplitude * envelope * math.sin(2 * math.pi * freq * t) 90 + # convert to 16-bit signed integer 91 + sample_int = int(32767 * max(-1.0, min(1.0, sample_value))) 92 + samples.append(struct.pack("<h", sample_int)) 93 + 94 + audio_data = b"".join(samples) 95 + 96 + # build WAV file 97 + wav = BytesIO() 98 + 99 + # RIFF header 100 + wav.write(b"RIFF") 101 + wav.write(struct.pack("<I", 36 + len(audio_data))) # file size - 8 102 + wav.write(b"WAVE") 103 + 104 + # fmt chunk 105 + wav.write(b"fmt ") 106 + wav.write( 107 + struct.pack( 108 + "<IHHIIHH", 109 + 16, # chunk size 110 + 1, # audio format (PCM) 111 + 1, # num channels (mono) 112 + sample_rate, 113 + sample_rate * 2, # byte rate (sample_rate * channels * bytes_per_sample) 114 + 2, # block align (channels * bytes_per_sample) 115 + 16, # bits per sample 116 + ) 117 + ) 118 + 119 + # data chunk 120 + wav.write(b"data") 121 + wav.write(struct.pack("<I", len(audio_data))) 122 + wav.write(audio_data) 123 + 124 + wav.seek(0) 125 + return wav 126 + 127 + 128 + def save_drone( 129 + path: Path, 130 + note: str = "A4", 131 + duration_sec: float = 2.0, 132 + ) -> Path: 133 + """generate and save a drone to a file. 134 + 135 + convenience wrapper around generate_drone. 136 + 137 + args: 138 + path: where to save the WAV file 139 + note: musical note name 140 + duration_sec: duration in seconds 141 + 142 + returns: 143 + the path where the file was saved 144 + """ 145 + wav = generate_drone(note, duration_sec) 146 + path.write_bytes(wav.read()) 147 + return path
+16 -3
backend/uv.lock
··· 347 347 { name = "httpx" }, 348 348 { name = "ipython" }, 349 349 { name = "pdbpp" }, 350 + { name = "plyrfm" }, 350 351 { name = "prek" }, 351 352 { name = "pytest" }, 352 353 { name = "pytest-asyncio" }, ··· 394 395 { name = "httpx", specifier = ">=0.28.0" }, 395 396 { name = "ipython", specifier = ">=8.12.3" }, 396 397 { name = "pdbpp", specifier = ">=0.10.3" }, 398 + { name = "plyrfm", git = "https://github.com/zzstoatzz/plyr-python-client?subdirectory=packages%2Fplyrfm" }, 397 399 { name = "prek", specifier = ">=0.2.13" }, 398 400 { name = "pytest", specifier = ">=8.3.3" }, 399 401 { name = "pytest-asyncio", specifier = ">=1.0.0" }, ··· 1995 1997 ] 1996 1998 1997 1999 [[package]] 2000 + name = "plyrfm" 2001 + version = "0.0.1a16.dev1+c291037" 2002 + source = { git = "https://github.com/zzstoatzz/plyr-python-client?subdirectory=packages%2Fplyrfm#c2910373083b2e1c6238b40c7f9be22abbf7252d" } 2003 + dependencies = [ 2004 + { name = "httpx" }, 2005 + { name = "pydantic" }, 2006 + { name = "pydantic-settings" }, 2007 + { name = "rich" }, 2008 + ] 2009 + 2010 + [[package]] 1998 2011 name = "prek" 1999 2012 version = "0.2.13" 2000 2013 source = { registry = "https://pypi.org/simple" } ··· 2288 2301 2289 2302 [[package]] 2290 2303 name = "pydantic" 2291 - version = "2.12.4" 2304 + version = "2.12.5" 2292 2305 source = { registry = "https://pypi.org/simple" } 2293 2306 dependencies = [ 2294 2307 { name = "annotated-types" }, ··· 2296 2309 { name = "typing-extensions" }, 2297 2310 { name = "typing-inspection" }, 2298 2311 ] 2299 - sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } 2312 + sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } 2300 2313 wheels = [ 2301 - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, 2314 + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, 2302 2315 ] 2303 2316 2304 2317 [[package]]
+80
docs/testing/integration-tests.md
··· 1 + # integration tests 2 + 3 + integration tests run against the staging environment (`api-stg.plyr.fm`) using real API tokens. 4 + 5 + ## running locally 6 + 7 + ```bash 8 + # set tokens 9 + export PLYR_TEST_TOKEN_1=... 10 + export PLYR_TEST_TOKEN_2=... 11 + export PLYR_TEST_TOKEN_3=... 12 + 13 + # run tests 14 + cd backend 15 + uv run pytest tests/integration -m integration -v 16 + ``` 17 + 18 + ## test accounts 19 + 20 + | secret | handle | purpose | 21 + |--------|--------|---------| 22 + | `PLYR_TEST_TOKEN_1` | zzstoatzz.io | primary test user - all single-user tests | 23 + | `PLYR_TEST_TOKEN_2` | plyr.fm | secondary user - cross-user interaction tests | 24 + | `PLYR_TEST_TOKEN_3` | zzstoatzzdevlog.bsky.social | tertiary user - reserved for future tests | 25 + 26 + tokens are developer tokens created at [plyr.fm/portal](https://plyr.fm/portal) → "developer tokens". 27 + 28 + ## github actions 29 + 30 + the `integration-tests.yml` workflow runs automatically after staging deployment succeeds. it can also be triggered manually via workflow dispatch. 31 + 32 + secrets are stored in the repository settings under Settings → Secrets and variables → Actions. 33 + 34 + ## test structure 35 + 36 + ``` 37 + backend/tests/integration/ 38 + ├── conftest.py # fixtures, multi-user auth 39 + ├── test_track_lifecycle.py # upload, edit, delete (5 tests) 40 + ├── test_interactions.py # cross-user likes, permissions (4 tests) 41 + └── utils/ 42 + └── audio.py # pure python drone generation 43 + ``` 44 + 45 + ## adding new tests 46 + 47 + 1. use the `user1_client`, `user2_client`, or `user3_client` fixtures 48 + 2. tag all test content with `integration-test` for identification 49 + 3. always clean up in a `finally` block 50 + 4. use `pytest.mark.integration` and `pytest.mark.timeout(120)` 51 + 52 + example: 53 + 54 + ```python 55 + async def test_something(user1_client: AsyncPlyrClient, drone_a4: Path): 56 + result = await user1_client.upload(drone_a4, "Test", tags={"integration-test"}) 57 + track_id = result.track_id 58 + 59 + try: 60 + # test logic here 61 + pass 62 + finally: 63 + await user1_client.delete(track_id) 64 + ``` 65 + 66 + ## audio generation 67 + 68 + tests use pure python WAV generation (no FFmpeg required): 69 + 70 + ```python 71 + from tests.integration.utils.audio import generate_drone, save_drone 72 + 73 + # generate in memory 74 + wav = generate_drone("A4", duration_sec=2.0) # 440Hz, ~22KB 75 + 76 + # save to file 77 + save_drone(Path("/tmp/drone.wav"), "E4", duration_sec=1.0) 78 + ``` 79 + 80 + available notes: C3-B4, A5 (standard A440 tuning).