Files
call-center/tests/test_recording_service.py
T

401 lines
14 KiB
Python

from pathlib import Path
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
import services.recording_service.app as recording_module
from services.recording_service.app import app as recording_app
from services.shared.db import get_session
from services.shared.security import issue_app_token
from services.shared.sql_models import CallRecordingRow
from services.voice_adapter_service.app import app as voice_app
def _write_audio_fixture(path: Path, size: int = 32) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"RIFF" + b"\x00" * size)
return path
def _supervisor_headers() -> dict[str, str]:
return {"X-User": "supervisor", "X-Role": "supervisor"}
def _admin_headers() -> dict[str, str]:
return {"X-User": "admin", "X-Role": "admin"}
def _operator_headers() -> dict[str, str]:
return {"X-User": "operator", "X-Role": "operator"}
def test_import_from_voice_event_creates_recording_and_is_idempotent(tmp_path, monkeypatch):
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "recordings"))
voice_client = TestClient(voice_app)
recording_client = TestClient(recording_app)
source_path = _write_audio_fixture(tmp_path / "fixtures" / "call.wav")
event = voice_client.post(
"/integrations/voice/events",
headers=_admin_headers(),
json={
"event_type": "recording.ready",
"call_id": f"call_{tmp_path.name}",
"interaction_id": f"int_{tmp_path.name}",
"payload": {
"source_path": str(source_path),
"file_name": "call.wav",
"mime_type": "audio/wav",
"duration_seconds": 1,
},
},
)
assert event.status_code == 200
event_id = event.json()["event_id"]
created = recording_client.post(
f"/recordings/import-from-voice-event/{event_id}",
headers=_supervisor_headers(),
)
assert created.status_code == 200
payload = created.json()
assert payload["recording_id"].startswith("rec_")
assert payload["status"] == "ready"
assert payload["mime_type"] == "audio/wav"
duplicated = recording_client.post(
f"/recordings/import-from-voice-event/{event_id}",
headers=_supervisor_headers(),
)
assert duplicated.status_code == 200
assert duplicated.json()["recording_id"] == payload["recording_id"]
def test_register_recording_lists_filters_and_archives(tmp_path, monkeypatch):
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
recording_client = TestClient(recording_app)
source_path = _write_audio_fixture(tmp_path / "fixtures" / "register.wav")
call_id = f"call_{tmp_path.name}"
interaction_id = f"int_{tmp_path.name}"
created = recording_client.post(
"/recordings/register",
headers=_supervisor_headers(),
json={
"call_id": call_id,
"interaction_id": interaction_id,
"source_path": str(source_path),
"file_name": "register.wav",
"mime_type": "audio/wav",
"duration_seconds": 2,
},
)
assert created.status_code == 200
recording_id = created.json()["recording_id"]
listed = recording_client.get(f"/recordings?call_id={call_id}", headers=_supervisor_headers())
assert listed.status_code == 200
assert any(item["recording_id"] == recording_id for item in listed.json())
filtered = recording_client.get(
f"/recordings?interaction_id={interaction_id}",
headers=_supervisor_headers(),
)
assert filtered.status_code == 200
assert any(item["recording_id"] == recording_id for item in filtered.json())
detail = recording_client.get(f"/recordings/{recording_id}", headers=_supervisor_headers())
assert detail.status_code == 200
assert detail.json()["call_id"] == call_id
archived = recording_client.post(
f"/recordings/{recording_id}/archive",
headers=_supervisor_headers(),
)
assert archived.status_code == 200
assert archived.json()["status"] == "archived"
def test_recording_content_supports_inline_and_attachment(tmp_path, monkeypatch):
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
recording_client = TestClient(recording_app)
source_path = _write_audio_fixture(tmp_path / "fixtures" / "preview.wav")
created = recording_client.post(
"/recordings/register",
headers=_supervisor_headers(),
json={
"call_id": f"call_{tmp_path.name}",
"source_path": str(source_path),
"file_name": "preview.wav",
},
)
assert created.status_code == 200
recording_id = created.json()["recording_id"]
inline = recording_client.get(
f"/recordings/{recording_id}/content?disposition=inline",
headers=_supervisor_headers(),
)
assert inline.status_code == 200
assert inline.content.startswith(b"RIFF")
assert inline.headers["content-type"].startswith("audio/wav")
attachment = recording_client.get(
f"/recordings/{recording_id}/content?disposition=attachment",
headers=_supervisor_headers(),
)
assert attachment.status_code == 200
assert "attachment" in attachment.headers["content-disposition"]
def test_missing_file_marks_recording_missing(tmp_path, monkeypatch):
storage_root = tmp_path / "managed"
monkeypatch.setenv("CC_RECORDINGS_DIR", str(storage_root))
recording_client = TestClient(recording_app)
source_path = _write_audio_fixture(tmp_path / "fixtures" / "missing.wav")
created = recording_client.post(
"/recordings/register",
headers=_supervisor_headers(),
json={
"call_id": f"call_{tmp_path.name}",
"source_path": str(source_path),
"file_name": "missing.wav",
},
)
assert created.status_code == 200
recording_id = created.json()["recording_id"]
stored_files = list(storage_root.rglob("*.wav"))
assert stored_files
stored_files[0].unlink()
missing = recording_client.get(
f"/recordings/{recording_id}/content",
headers=_supervisor_headers(),
)
assert missing.status_code == 410
detail = recording_client.get(f"/recordings/{recording_id}", headers=_supervisor_headers())
assert detail.status_code == 200
assert detail.json()["status"] == "missing"
def test_recording_import_upload_accepts_multipart(tmp_path, monkeypatch):
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
recording_client = TestClient(recording_app)
fixture = _write_audio_fixture(tmp_path / "fixtures" / "upload.wav")
with fixture.open("rb") as handle:
created = recording_client.post(
"/recordings/import-upload",
headers=_admin_headers(),
data={
"call_id": f"call_upload_{tmp_path.name}",
"interaction_id": f"int_upload_{tmp_path.name}",
"file_name": "upload.wav",
"mime_type": "audio/wav",
"duration_seconds": "4",
},
files={"file": ("upload.wav", handle, "audio/wav")},
)
assert created.status_code == 200
payload = created.json()
assert payload["recording_id"].startswith("rec_")
assert payload["call_id"].startswith("call_upload_")
assert payload["status"] == "ready"
assert payload["mime_type"] == "audio/wav"
def test_recording_import_upload_is_idempotent_by_source_event_id(tmp_path, monkeypatch):
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
recording_client = TestClient(recording_app)
fixture = _write_audio_fixture(tmp_path / "fixtures" / "upload-idempotent.wav")
source_event_id = f"bridge_upload_{tmp_path.name}"
form_data = {
"call_id": f"call_upload_idempotent_{tmp_path.name}",
"interaction_id": f"int_upload_idempotent_{tmp_path.name}",
"source_event_id": source_event_id,
"file_name": "upload-idempotent.wav",
"mime_type": "audio/wav",
"duration_seconds": "4",
}
with fixture.open("rb") as handle:
first = recording_client.post(
"/recordings/import-upload",
headers=_admin_headers(),
data=form_data,
files={"file": ("upload-idempotent.wav", handle, "audio/wav")},
)
with fixture.open("rb") as handle:
second = recording_client.post(
"/recordings/import-upload",
headers=_admin_headers(),
data=form_data,
files={"file": ("upload-idempotent.wav", handle, "audio/wav")},
)
assert first.status_code == 200
assert second.status_code == 200
assert first.json()["recording_id"] == second.json()["recording_id"]
assert first.json()["source_event_id"] == source_event_id
session = get_session()
try:
rows = session.execute(
select(CallRecordingRow).where(CallRecordingRow.source_event_id == source_event_id)
).scalars().all()
assert len(rows) == 1
finally:
session.close()
def test_register_recording_recovers_from_source_event_id_conflict(tmp_path, monkeypatch):
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
source_path = _write_audio_fixture(tmp_path / "fixtures" / "race.wav")
source_event_id = f"src_race_{tmp_path.name}"
existing_call_id = f"call_race_{tmp_path.name}"
session = get_session()
original_commit = session.commit
commit_attempts = {"count": 0}
def _conflicting_commit():
if commit_attempts["count"] == 0:
commit_attempts["count"] += 1
other = get_session()
try:
existing = recording_module._register_recording(
other,
call_id=existing_call_id,
interaction_id=f"int_race_{tmp_path.name}",
source_path=source_path.resolve(),
file_name="race.wav",
mime_type="audio/wav",
duration_seconds=3,
recorded_at=None,
source_event_id=source_event_id,
)
assert existing.source_event_id == source_event_id
finally:
other.close()
raise IntegrityError("duplicate source_event_id", params=None, orig=Exception("duplicate"))
return original_commit()
try:
monkeypatch.setattr(session, "commit", _conflicting_commit)
row = recording_module._register_recording(
session,
call_id=existing_call_id,
interaction_id=f"int_race_{tmp_path.name}",
source_path=source_path.resolve(),
file_name="race.wav",
mime_type="audio/wav",
duration_seconds=3,
recorded_at=None,
source_event_id=source_event_id,
)
assert row.source_event_id == source_event_id
verify = get_session()
try:
rows = verify.execute(
select(CallRecordingRow).where(CallRecordingRow.source_event_id == source_event_id)
).scalars().all()
assert len(rows) == 1
assert rows[0].recording_id == row.recording_id
finally:
verify.close()
finally:
session.close()
def test_recording_rejects_unsupported_or_oversized_files_and_operator_access(tmp_path, monkeypatch):
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
monkeypatch.setenv("RECORDING_MAX_BYTES", "1024")
recording_client = TestClient(recording_app)
unsupported_path = (tmp_path / "fixtures" / "bad.txt")
unsupported_path.parent.mkdir(parents=True, exist_ok=True)
unsupported_path.write_text("not audio", encoding="utf-8")
unsupported = recording_client.post(
"/recordings/register",
headers=_supervisor_headers(),
json={
"call_id": f"call_bad_{tmp_path.name}",
"source_path": str(unsupported_path),
"file_name": "bad.txt",
},
)
assert unsupported.status_code == 415
oversized_path = _write_audio_fixture(tmp_path / "fixtures" / "big.wav", size=4096)
oversized = recording_client.post(
"/recordings/register",
headers=_supervisor_headers(),
json={
"call_id": f"call_big_{tmp_path.name}",
"source_path": str(oversized_path),
"file_name": "big.wav",
},
)
assert oversized.status_code == 413
denied = recording_client.get("/recordings", headers=_operator_headers())
assert denied.status_code == 403
def test_recording_import_upload_requires_trusted_service_when_admin_fallback_disabled(tmp_path, monkeypatch):
monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed"))
monkeypatch.setenv("RECORDING_IMPORT_ALLOW_ADMIN", "0")
monkeypatch.setenv("RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge")
monkeypatch.setenv("ALLOW_LEGACY_HEADER_AUTH", "0")
monkeypatch.setenv("APP_TOKEN_SECRET", "track9-service-token-test")
recording_client = TestClient(recording_app)
fixture = _write_audio_fixture(tmp_path / "fixtures" / "svc-upload.wav")
with fixture.open("rb") as handle:
denied = recording_client.post(
"/recordings/import-upload",
headers=_admin_headers(),
data={
"call_id": f"call_upload_denied_{tmp_path.name}",
"interaction_id": f"int_upload_denied_{tmp_path.name}",
"file_name": "svc-upload.wav",
"mime_type": "audio/wav",
"duration_seconds": "4",
},
files={"file": ("svc-upload.wav", handle, "audio/wav")},
)
assert denied.status_code == 403
token = issue_app_token(
subject="svc:asterisk-bridge",
username="asterisk-bridge",
role="admin",
auth_source="service",
provider="track9-test",
ttl_seconds=300,
)
with fixture.open("rb") as handle:
allowed = recording_client.post(
"/recordings/import-upload",
headers={"Authorization": f"Bearer {token}"},
data={
"call_id": f"call_upload_allowed_{tmp_path.name}",
"interaction_id": f"int_upload_allowed_{tmp_path.name}",
"file_name": "svc-upload.wav",
"mime_type": "audio/wav",
"duration_seconds": "4",
},
files={"file": ("svc-upload.wav", handle, "audio/wav")},
)
assert allowed.status_code == 200
assert allowed.json()["recording_id"].startswith("rec_")