Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -1,7 +1,8 @@
"""WAV-запись вызова: формат, микширование и защищённая выдача."""
import os
import wave
from datetime import datetime, timezone
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import uuid4
@ -25,6 +26,9 @@ def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
assert recorder.finalize() == path
assert recorder.finalize() == path
assert not path.with_suffix(".wav.tmp").exists()
assert not path.with_suffix(".wav.journal").exists()
if os.name == "posix": # Windows exposes a different permission model.
assert os.stat(path).st_mode & 0o777 == 0o600
with wave.open(str(path), "rb") as source:
assert source.getnchannels() == 1
assert source.getsampwidth() == 2
@ -34,6 +38,34 @@ def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
assert samples.max() >= 2000
def test_recorder_recovers_audio_journal_after_process_restart(tmp_path):
path = tmp_path / "interrupted.wav"
clock_value = [10.0]
clock = lambda: clock_value[0]
first_process = CallRecorder(path, clock=clock)
first_process.add_pcm((1000).to_bytes(2, "little", signed=True) * 320, sample_rate=16_000)
journal = path.with_suffix(".wav.journal")
# Simulate power loss halfway through a journal record. The next process
# must keep all complete audio and discard only the torn tail.
first_process._journal.close()
with journal.open("ab") as partial:
partial.write(b"\x40\x01\x00\x00\x00\x00\x00\x00\x40\x01\x00\x00\x02\x00")
clock_value[0] = 50.0 # monotonic origin changed across host restart
recovered = CallRecorder(path, clock=clock)
recovered.add_pcm((2000).to_bytes(2, "little", signed=True) * 320, sample_rate=16_000)
recovered.finalize()
with wave.open(str(path), "rb") as source:
samples = np.frombuffer(source.readframes(source.getnframes()), dtype="<i2")
assert source.getframerate() == 16_000
assert samples.size == 640
assert np.all(samples[:320] == 1000)
assert np.all(samples[320:] == 2000)
assert not journal.exists()
def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypatch):
session_id = uuid4()
path = tmp_path / f"{session_id}.wav"
@ -46,11 +78,17 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
async def fake_session(db, requested):
assert requested == session_id
return SimpleNamespace(
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(timezone.utc),
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(UTC),
)
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
audit_events = []
async def record_access(actor, role, action, object_id=None, detail=""):
audit_events.append((actor, role, action, object_id, detail))
monkeypatch.setattr(sessions, "audit_required", record_access)
with TestClient(app) as client:
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 401
client.post("/api/auth/dev-token")
@ -58,6 +96,9 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
assert response.status_code == 200
assert response.headers["content-type"] == "audio/wav"
assert response.content.startswith(b"RIFF")
assert audit_events == [
("dev", "instructor", "recording.read", str(session_id), "")
]
monkeypatch.setattr(
sessions,
@ -67,3 +108,29 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
),
)
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 403
assert len(audit_events) == 1
def test_recording_is_not_returned_when_access_audit_is_unavailable(tmp_path, monkeypatch):
from fastapi import HTTPException
session_id = uuid4()
path = tmp_path / f"{session_id}.wav"
path.write_bytes(b"not returned")
async def fake_session(db, requested):
return SimpleNamespace(
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(UTC),
)
async def audit_unavailable(*_args, **_kwargs):
raise HTTPException(status_code=503, detail="audit_unavailable")
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
monkeypatch.setattr(sessions, "audit_required", audit_unavailable)
with TestClient(app) as client:
client.post("/api/auth/dev-token")
response = client.get(f"/api/sessions/{session_id}/recording.wav")
assert response.status_code == 503
assert response.json() == {"detail": "audit_unavailable"}