Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [main]
branches: [main, dev]
pull_request:
branches: [main]
branches: [main, dev]

jobs:
backend-lint:
Expand All @@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.9'
python-version: '3.12'
- run: pip install ruff==0.1.9
- run: ruff check .

Expand All @@ -25,11 +25,14 @@ jobs:
defaults:
run:
working-directory: backend
env:
DEBUG: 'True'
SECRET_KEY: 'ci-test-secret-key'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.9'
python-version: '3.12'
- run: pip install -r requirements.txt
- run: mypy apps/ --ignore-missing-imports

Expand Down Expand Up @@ -64,7 +67,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.9'
python-version: '3.12'
- run: pip install -r requirements.txt
- run: python manage.py migrate
- run: pytest --cov=apps --cov-report=term-missing --cov-fail-under=60 -q
Expand Down Expand Up @@ -115,6 +118,9 @@ jobs:
run: docker compose up -d
env:
OPENAI_API_KEY: 'test-key-not-real'
# One runner address drives the whole suite; the production default
# (60/minute) throttles legitimate test traffic. CI-only value.
THROTTLE_ANON_RATE: '1000/minute'
- name: Wait for backend health
run: |
timeout 90 bash -c 'until curl -sf http://localhost:8011/api/health/ > /dev/null 2>&1; do sleep 3; done'
Expand Down
18 changes: 15 additions & 3 deletions backend/apps/voice_notes/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,25 @@ def get_queryset(self):


class FolderSerializer(serializers.ModelSerializer):
parent = UserScopedFolderField(allow_null=True, required=False)

class Meta:
model = Folder
fields = ('id', 'name', 'color', 'parent', 'created_at')
read_only_fields = ('id', 'created_at')

def get_fields(self):
"""Replace the auto-built ``parent`` field with the user-scoped one.

``parent`` cannot be declared as a class attribute the way the other
scoped fields are: DRF's ``Field.parent`` already owns that name on the
base class, so a class-level assignment reads as an override of an
unrelated attribute even though the serializer metaclass moves declared
fields out of the class namespace. Installing the field here yields the
identical field map without the name collision.
"""
fields = super().get_fields()
fields['parent'] = UserScopedFolderField(allow_null=True, required=False)
return fields

def validate_parent(self, value):
# Enforce a single level of nesting: a parent cannot itself be nested.
if value is not None and value.parent_id is not None:
Expand Down Expand Up @@ -87,7 +99,7 @@ class VoiceNoteListSerializer(serializers.ModelSerializer):
audio_url = serializers.SerializerMethodField()
transcription_text = serializers.CharField(source='transcription', read_only=True)
transcription_confidence = serializers.FloatField(source='confidence_score', read_only=True)
folder = serializers.PrimaryKeyRelatedField(read_only=True)
folder: 'serializers.PrimaryKeyRelatedField[Folder]' = serializers.PrimaryKeyRelatedField(read_only=True)
folder_name = serializers.CharField(source='folder.name', read_only=True, default=None)

class Meta:
Expand Down
6 changes: 5 additions & 1 deletion backend/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
SpectacularRedocView,
SpectacularSwaggerView,
)
from rest_framework.decorators import api_view, permission_classes
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response

Expand All @@ -16,7 +16,11 @@

@api_view(['GET'])
@permission_classes([AllowAny])
@throttle_classes([])
def health_check(request):
"""Liveness probe. Exempt from the anonymous throttle: uptime checks and the
e2e suite hit it alongside unauthenticated traffic, and a 429 here reads as an
outage."""
return Response({'status': 'ok'})


Expand Down
2 changes: 1 addition & 1 deletion backend/mypy.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[mypy]
python_version = 3.9
python_version = 3.12
plugins =
mypy_django_plugin.main,
mypy_drf_plugin.main
Expand Down
9 changes: 9 additions & 0 deletions backend/tests/test_folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ def test_cannot_parent_to_another_users_folder(self, api_client, user, user_b):
resp = api_client.post('/api/folders/', {'name': 'Mine', 'parent': foreign.id})
assert resp.status_code == status.HTTP_400_BAD_REQUEST

def test_cannot_reparent_to_another_users_folder_on_update(self, api_client, user, user_b):
mine = Folder.objects.create(user=user, name='Mine')
foreign = Folder.objects.create(user=user_b, name='Foreign')
_auth(api_client, user)
resp = api_client.patch(f'/api/folders/{mine.id}/', {'parent': foreign.id})
assert resp.status_code == status.HTTP_400_BAD_REQUEST
mine.refresh_from_db()
assert mine.parent is None


@pytest.mark.django_db
class TestNoteFolderAssignment:
Expand Down
29 changes: 29 additions & 0 deletions backend/tests/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""The liveness probe must answer 200 regardless of the anonymous throttle.

conftest.py switches throttling off for the whole suite, so this test puts the
production default back on the base view class and tightens the anon rate to
one request per hour. A control request proves the throttle is really biting;
the health probe must still answer 200 every time.
"""
import pytest
from django.core.cache import cache
from rest_framework.test import APIClient
from rest_framework.throttling import AnonRateThrottle
from rest_framework.views import APIView


@pytest.mark.django_db
def test_health_is_not_throttled(monkeypatch):
monkeypatch.setattr(APIView, 'throttle_classes', [AnonRateThrottle])
monkeypatch.setattr(AnonRateThrottle, 'THROTTLE_RATES', {'anon': '1/hour'})
cache.clear()
client = APIClient()

# Control: an ordinary anonymous endpoint is throttled on the second call.
client.post('/api/auth/register/', {})
assert client.post('/api/auth/register/', {}).status_code == 429

for _ in range(3):
response = client.get('/api/health/')
assert response.status_code == 200
assert response.json() == {'status': 'ok'}
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ services:
- DEEPGRAM_MODEL=${DEEPGRAM_MODEL:-nova-3}
- ALLOWED_HOSTS=localhost,127.0.0.1,backend
- REDIS_URL=redis://redis:6379/0
# Anonymous throttle (settings.py default 60/minute). CI raises it: the
# e2e suite sends all its anonymous traffic from one address in one minute.
- THROTTLE_ANON_RATE=${THROTTLE_ANON_RATE:-60/minute}
- PYTHONDONTWRITEBYTECODE=1
volumes:
- ./backend:/app
Expand Down Expand Up @@ -118,6 +121,11 @@ services:
condition: service_healthy
redis:
condition: service_healthy
# Start after the backend so the two services do not race to populate the
# shared media_files volume (Docker copies /app/media from whichever image
# mounts the empty volume first; two at once fail with "file exists").
backend:
condition: service_healthy

volumes:
postgres_data:
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/tests/comprehensive-harness.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ test.describe('Authenticated', () => {
});

test('page loads with header and recorder', async ({ page }) => {
await expect(page.locator('h1:has-text("Record Voice Note")')).toBeVisible();
await expect(page.locator('h1:has-text("New Voice Note")')).toBeVisible();
await expect(page.locator('text=Ready to record')).toBeVisible();
});

Expand Down
7 changes: 7 additions & 0 deletions frontend/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
testDir: './e2e/tests',
// production-sweep.spec.js drives the live production deployment
// (https://clio.chadacus.dev) and registers real accounts on it. testIgnore
// removes it at discovery, so this config cannot run it at all, not even when
// the file is named on the command line. That is deliberate: no env var can
// turn it back on in CI. To run it by hand, point Playwright at a separate
// config (`npx playwright test -c <config>`) that does not ignore it.
testIgnore: /production-sweep\.spec\.js/,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
Expand Down
24 changes: 14 additions & 10 deletions frontend/src/components/AudioRecorder/MicrophonePermission.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
MicrophoneIcon,
NoSymbolIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
InformationCircleIcon
} from '@heroicons/react/24/outline';
Expand All @@ -24,11 +23,12 @@ const MicrophonePermission: React.FC<MicrophonePermissionProps> = ({
const [isRequesting, setIsRequesting] = useState(false);
const [showHelp, setShowHelp] = useState(false);

const onPermissionChangeRef = useRef(onPermissionChange);
useEffect(() => {
checkPermissionStatus();
}, []);
onPermissionChangeRef.current = onPermissionChange;
}, [onPermissionChange]);

const checkPermissionStatus = async () => {
const checkPermissionStatus = useCallback(async () => {
setPermissionState('checking');

try {
Expand All @@ -40,24 +40,28 @@ const MicrophonePermission: React.FC<MicrophonePermissionProps> = ({

const state = permissionStatus.state as PermissionState;
setPermissionState(state);
onPermissionChange(state === 'granted');
onPermissionChangeRef.current(state === 'granted');

// Listen for permission changes
permissionStatus.onchange = () => {
const newState = permissionStatus.state as PermissionState;
setPermissionState(newState);
onPermissionChange(newState === 'granted');
onPermissionChangeRef.current(newState === 'granted');
};
} else {
// Fallback: assume we need to prompt
setPermissionState('prompt');
onPermissionChange(false);
onPermissionChangeRef.current(false);
}
} catch (_error) {
setPermissionState('prompt');
onPermissionChange(false);
onPermissionChangeRef.current(false);
}
};
}, []);

useEffect(() => {
checkPermissionStatus();
}, [checkPermissionStatus]);

const requestPermission = async () => {
setIsRequesting(true);
Expand Down
9 changes: 4 additions & 5 deletions frontend/src/components/AudioRecorder/RecorderControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from '@heroicons/react/24/outline';
import { useAudioRecorder } from '../../hooks/useAudioRecorder';
import WaveformDisplay from './WaveformDisplay';
import PerformanceIndicator from '../Performance/PerformanceIndicator';
import toast from 'react-hot-toast';

interface RecorderControlsProps {
Expand All @@ -17,7 +16,6 @@ interface RecorderControlsProps {
onRecordingStop?: () => void;
disabled?: boolean;
className?: string;
showPerformanceIndicator?: boolean;
}

const RecorderControls: React.FC<RecorderControlsProps> = ({
Expand All @@ -26,7 +24,6 @@ const RecorderControls: React.FC<RecorderControlsProps> = ({
onRecordingStop,
disabled = false,
className = '',
showPerformanceIndicator = true,
}) => {
const [hasPermission, setHasPermission] = useState<boolean | null>(null);

Expand All @@ -43,11 +40,13 @@ const RecorderControls: React.FC<RecorderControlsProps> = ({
enablePerformanceManagement: true,
});

const { checkMicrophonePermission } = recorder;

// Check microphone permission on component mount
useEffect(() => {
const checkInitialPermission = async () => {
try {
const permission = await recorder.checkMicrophonePermission();
const permission = await checkMicrophonePermission();
setHasPermission(permission);
} catch (error) {
console.error('Error checking microphone permission:', error);
Expand All @@ -56,7 +55,7 @@ const RecorderControls: React.FC<RecorderControlsProps> = ({
};

checkInitialPermission();
}, [recorder.checkMicrophonePermission]);
}, [checkMicrophonePermission]);

const handleStartRecording = async () => {
try {
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
HomeIcon,
MicrophoneIcon,
DocumentTextIcon,
UserIcon,
Bars3Icon,
XMarkIcon,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ const PerformanceIndicator: React.FC<PerformanceIndicatorProps> = ({
qualitySettings,
performanceStatus,
getQualityDescription,
getPerformanceStatusColor,
getPerformanceStatusText,
getRecommendedSettings
} = performanceManager;
Expand Down
Loading
Loading