Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Reference in a new issue