Update all files

This commit is contained in:
Mistral Vibe
2026-03-29 20:44:23 +02:00
parent 19a119ace2
commit f7a07ba05e
9 changed files with 2084 additions and 24 deletions

View File

@@ -1,8 +1,11 @@
.PHONY: up down build logs migrate seed test test-api test-worker test-watcher lint check format .PHONY: up down build logs migrate seed test test-api test-worker test-watcher lint check format
up: up: validate-env
docker compose up -d docker compose up -d
validate-env:
bash scripts/validate-env.sh
down: down:
docker compose down docker compose down
@@ -22,7 +25,7 @@ migrate-auto:
# ── Setup ───────────────────────────────────────────────────────────────────── # ── Setup ─────────────────────────────────────────────────────────────────────
setup: up setup: validate-env up
@echo "Waiting for Nextcloud to initialize (this can take ~60s)..." @echo "Waiting for Nextcloud to initialize (this can take ~60s)..."
@sleep 60 @sleep 60
bash scripts/nc-setup.sh bash scripts/nc-setup.sh

View File

@@ -61,3 +61,10 @@ ignore_missing_imports = true
source = ["src/rehearsalhub"] source = ["src/rehearsalhub"]
omit = ["src/rehearsalhub/db/models.py"] omit = ["src/rehearsalhub/db/models.py"]
[dependency-groups]
dev = [
"httpx>=0.28.1",
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
]

View File

@@ -0,0 +1,171 @@
"""Integration tests for version streaming endpoints."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from rehearsalhub.routers.versions import stream_version, get_waveform
from rehearsalhub.db.models import Member, AudioVersion
from rehearsalhub.schemas.audio_version import AudioVersionRead
@pytest.mark.asyncio
@pytest.mark.integration
async def test_stream_version_connection_error():
"""Test stream_version endpoint handles connection errors gracefully."""
# Mock dependencies
mock_session = MagicMock()
mock_member = Member(id=1, name="Test User")
# Mock version with nc_file_path
mock_version = AudioVersion(
id="test-version-id",
nc_file_path="test/path/file.mp3",
waveform_url="test/path/waveform.json"
)
# Mock the storage client to raise connection error
with patch("rehearsalhub.routers.versions.NextcloudClient") as mock_client_class:
mock_client = MagicMock()
mock_client.download = AsyncMock(side_effect=httpx.ConnectError("Connection failed"))
mock_client_class.return_value = mock_client
# Mock the membership check
with patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(mock_version, None)):
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
await stream_version(
version_id="test-version-id",
session=mock_session,
current_member=mock_member
)
# Should return 503 Service Unavailable
assert exc_info.value.status_code == 503
assert "Failed to connect to storage" in str(exc_info.value.detail)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_stream_version_file_not_found():
"""Test stream_version endpoint handles 404 errors gracefully."""
# Mock dependencies
mock_session = MagicMock()
mock_member = Member(id=1, name="Test User")
# Mock version with nc_file_path
mock_version = AudioVersion(
id="test-version-id",
nc_file_path="test/path/file.mp3",
waveform_url="test/path/waveform.json"
)
# Mock the storage client to raise 404 error
with patch("rehearsalhub.routers.versions.NextcloudClient") as mock_client_class:
mock_client = MagicMock()
# Create mock response with 404 status
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.text = "Not Found"
mock_client.download = AsyncMock(
side_effect=httpx.HTTPStatusError("Not found", request=MagicMock(), response=mock_response)
)
mock_client_class.return_value = mock_client
# Mock the membership check
with patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(mock_version, None)):
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
await stream_version(
version_id="test-version-id",
session=mock_session,
current_member=mock_member
)
# Should return 404 Not Found
assert exc_info.value.status_code == 404
assert "File not found in storage" in str(exc_info.value.detail)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_waveform_connection_error():
"""Test get_waveform endpoint handles connection errors gracefully."""
# Mock dependencies
mock_session = MagicMock()
mock_member = Member(id=1, name="Test User")
# Mock version with waveform_url
mock_version = AudioVersion(
id="test-version-id",
nc_file_path="test/path/file.mp3",
waveform_url="test/path/waveform.json"
)
# Mock the storage client to raise connection error
with patch("rehearsalhub.routers.versions.NextcloudClient") as mock_client_class:
mock_client = MagicMock()
mock_client.download = AsyncMock(side_effect=httpx.ConnectError("Connection failed"))
mock_client_class.return_value = mock_client
# Mock the membership check
with patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(mock_version, None)):
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
await get_waveform(
version_id="test-version-id",
session=mock_session,
current_member=mock_member
)
# Should return 503 Service Unavailable
assert exc_info.value.status_code == 503
assert "Failed to connect to storage" in str(exc_info.value.detail)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_stream_version_success():
"""Test successful streaming when connection works."""
# Mock dependencies
mock_session = MagicMock()
mock_member = Member(id=1, name="Test User")
# Mock version with nc_file_path
mock_version = AudioVersion(
id="test-version-id",
nc_file_path="test/path/file.mp3",
waveform_url="test/path/waveform.json"
)
# Mock the storage client to return success
with patch("rehearsalhub.routers.versions.NextcloudClient") as mock_client_class:
mock_client = MagicMock()
mock_client.download = AsyncMock(return_value=b"audio_data")
mock_client_class.return_value = mock_client
# Mock the membership check
with patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(mock_version, None)):
result = await stream_version(
version_id="test-version-id",
session=mock_session,
current_member=mock_member
)
# Should return Response with audio data
assert result.status_code == 200
assert result.body == b"audio_data"
assert result.media_type == "audio/mpeg"

View File

@@ -0,0 +1,85 @@
"""Test error handling in versions router."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from rehearsalhub.routers.versions import _download_with_retry
from rehearsalhub.storage.nextcloud import NextcloudClient
@pytest.mark.asyncio
async def test_download_with_retry_success():
"""Test successful download on first attempt."""
mock_storage = MagicMock()
mock_storage.download = AsyncMock(return_value=b"test_data")
result = await _download_with_retry(mock_storage, "test_path")
assert result == b"test_data"
mock_storage.download.assert_awaited_once_with("test_path")
@pytest.mark.asyncio
async def test_download_with_retry_connection_error():
"""Test retry logic on connection errors."""
mock_storage = MagicMock()
mock_storage.download = AsyncMock(side_effect=httpx.ConnectError("Connection failed"))
with pytest.raises(httpx.ConnectError):
await _download_with_retry(mock_storage, "test_path", max_retries=2)
# Should retry max_retries times
assert mock_storage.download.await_count == 2
@pytest.mark.asyncio
async def test_download_with_retry_server_error():
"""Test retry logic on 500 server errors."""
mock_storage = MagicMock()
# Create a mock response with 500 status
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
mock_storage.download = AsyncMock(side_effect=httpx.HTTPStatusError("Server error", request=MagicMock(), response=mock_response))
with pytest.raises(httpx.HTTPStatusError):
await _download_with_retry(mock_storage, "test_path", max_retries=2)
# Should retry max_retries times for 5xx errors
assert mock_storage.download.await_count == 2
@pytest.mark.asyncio
async def test_download_with_retry_client_error():
"""Test no retry on 4xx client errors."""
mock_storage = MagicMock()
# Create a mock response with 404 status
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.text = "Not Found"
mock_storage.download = AsyncMock(side_effect=httpx.HTTPStatusError("Not found", request=MagicMock(), response=mock_response))
with pytest.raises(httpx.HTTPStatusError):
await _download_with_retry(mock_storage, "test_path")
# Should not retry on 4xx errors
assert mock_storage.download.await_count == 1
@pytest.mark.asyncio
async def test_download_with_retry_fallback_error():
"""Test fallback HTTPException when no specific error is caught."""
mock_storage = MagicMock()
mock_storage.download = AsyncMock(side_effect=Exception("Unknown error"))
from fastapi import HTTPException
with pytest.raises(Exception) as exc_info:
await _download_with_retry(mock_storage, "test_path")
# Should raise the original exception for unknown errors
assert str(exc_info.value) == "Unknown error"

1724
api/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,19 @@ services:
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-rehearsalhub} POSTGRES_DB: ${POSTGRES_DB:-rehearsalhub}
POSTGRES_USER: ${POSTGRES_USER:-rh_user} POSTGRES_USER: ${POSTGRES_USER:-rh_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-default_secure_password}
volumes: volumes:
- pg_data:/var/lib/postgresql/data - pg_data:/var/lib/postgresql/data
networks: networks:
- rh_net - rh_net
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rh_user} -d ${POSTGRES_DB:-rehearsalhub}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rh_user} -d ${POSTGRES_DB:-rehearsalhub} || exit 1"]
interval: 5s interval: 15s
timeout: 5s timeout: 10s
retries: 10 retries: 30
start_period: 45s
restart: unless-stopped restart: unless-stopped
command: ["postgres", "-c", "max_connections=200", "-c", "shared_buffers=256MB"]
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -24,11 +26,16 @@ services:
networks: networks:
- rh_net - rh_net
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD-SHELL", "redis-cli ping || exit 1"]
interval: 5s interval: 10s
timeout: 3s timeout: 5s
retries: 5 retries: 15
start_period: 25s
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
api: api:
build: build:
@@ -36,12 +43,12 @@ services:
target: production target: production
image: rehearsalhub/api:latest image: rehearsalhub/api:latest
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-rehearsalhub} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
NEXTCLOUD_URL: ${NEXTCLOUD_URL} NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER} NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS} NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default}
DOMAIN: ${DOMAIN:-localhost} DOMAIN: ${DOMAIN:-localhost}
networks: networks:
- rh_net - rh_net
@@ -50,7 +57,17 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
interval: 20s
timeout: 10s
retries: 5
start_period: 60s
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
audio-worker: audio-worker:
build: build:
@@ -58,11 +75,11 @@ services:
target: production target: production
image: rehearsalhub/audio-worker:latest image: rehearsalhub/audio-worker:latest
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-rehearsalhub} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
NEXTCLOUD_URL: ${NEXTCLOUD_URL} NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER} NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS} NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
ANALYSIS_VERSION: "1.0.0" ANALYSIS_VERSION: "1.0.0"
volumes: volumes:
- audio_tmp:/tmp/audio - audio_tmp:/tmp/audio
@@ -73,6 +90,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
api:
condition: service_started
restart: unless-stopped restart: unless-stopped
nc-watcher: nc-watcher:
@@ -81,9 +100,9 @@ services:
target: production target: production
image: rehearsalhub/nc-watcher:latest image: rehearsalhub/nc-watcher:latest
environment: environment:
NEXTCLOUD_URL: ${NEXTCLOUD_URL} NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER} NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS} NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
API_URL: http://api:8000 API_URL: http://api:8000
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
POLL_INTERVAL: "30" POLL_INTERVAL: "30"
@@ -94,6 +113,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
api:
condition: service_started
restart: unless-stopped restart: unless-stopped
web: web:

View File

@@ -1,6 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
echo "→ Checking for Nextcloud service..."
# Check if nextcloud service exists
if ! docker compose ps | grep -q nextcloud; then
echo " Nextcloud service not found in compose setup"
echo " Skipping Nextcloud configuration (external setup required)"
exit 0
fi
echo "→ Configuring Nextcloud via occ..." echo "→ Configuring Nextcloud via occ..."
NC="docker compose exec -T nextcloud php occ" NC="docker compose exec -T nextcloud php occ"

40
test_websocket.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Test</title>
</head>
<body>
<h1>WebSocket Test</h1>
<p id="status">Connecting...</p>
<script>
const versionId = "625b4a46-bb84-44c5-bfce-13f62b2b4dcf"; // Use the same ID from the error
const ws = new WebSocket(`ws://${window.location.host}/ws/versions/${versionId}`);
ws.onopen = () => {
document.getElementById("status").textContent = "Connected!";
document.getElementById("status").style.color = "green";
// Send a ping
ws.send(JSON.stringify({ event: "ping" }));
};
ws.onmessage = (event) => {
console.log("Message received:", event.data);
const data = JSON.parse(event.data);
if (data.event === "pong") {
document.getElementById("status").textContent += " Pong received!";
}
};
ws.onerror = (error) => {
document.getElementById("status").textContent = "Error: " + error.message;
document.getElementById("status").style.color = "red";
console.error("WebSocket error:", error);
};
ws.onclose = () => {
document.getElementById("status").textContent += " Connection closed";
};
</script>
</body>
</html>

View File

@@ -1,7 +1,7 @@
# Stage 1: Essentia builder # Stage 1: Essentia builder
# Essentia doesn't have wheels for Python 3.12 yet; we use the official image # Essentia doesn't have wheels for Python 3.12 yet; we use the official image
# and copy the bindings into our final stage via a bind mount. # and copy the bindings into our final stage via a bind mount.
FROM mtgupf/essentia:latest AS essentia-builder FROM docker.io/mtgupf/essentia:latest AS essentia-builder
FROM python:3.12-slim AS base FROM python:3.12-slim AS base
WORKDIR /app WORKDIR /app