first commit
This commit is contained in:
1
tests/utils/__init__.py
Normal file
1
tests/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
109
tests/utils/test_audio_backlog.py
Normal file
109
tests/utils/test_audio_backlog.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
from app.utils import audio_backlog
|
||||
|
||||
FRAME_SAMPLES = 320 # 20 ms @ 16 kHz mono
|
||||
BYTES_PER_FRAME = FRAME_SAMPLES * 2
|
||||
|
||||
|
||||
def _silent() -> bytes:
|
||||
return b"\x00" * BYTES_PER_FRAME
|
||||
|
||||
|
||||
def _voiced(amp: int = 8000) -> bytes:
|
||||
return struct.pack("<%dh" % FRAME_SAMPLES, *([amp] * FRAME_SAMPLES))
|
||||
|
||||
|
||||
def _queue(frames: list[bytes]) -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
for frame in frames:
|
||||
q.put_nowait(frame)
|
||||
return q
|
||||
|
||||
|
||||
def _drain(q: asyncio.Queue) -> list[bytes]:
|
||||
out = []
|
||||
while True:
|
||||
try:
|
||||
out.append(q.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def test_rms_threshold_from_dbfs() -> None:
|
||||
assert audio_backlog.rms_threshold_from_dbfs(0.0) == 32768
|
||||
assert audio_backlog.rms_threshold_from_dbfs(-50.0) == 103
|
||||
assert audio_backlog.rms_threshold_from_dbfs(-200.0) == 0
|
||||
|
||||
|
||||
def test_frames_from_ms_rounds_up_with_minimum() -> None:
|
||||
assert audio_backlog.frames_from_ms(100, 20) == 5
|
||||
assert audio_backlog.frames_from_ms(90, 20) == 5 # arredonda pra cima
|
||||
assert audio_backlog.frames_from_ms(0, 20) == 1 # minimo
|
||||
assert audio_backlog.frames_from_ms(40, 20) == 2
|
||||
|
||||
|
||||
def test_is_silent_frame() -> None:
|
||||
thr = audio_backlog.rms_threshold_from_dbfs(-50.0)
|
||||
assert audio_backlog.is_silent_frame(_silent(), thr) is True
|
||||
assert audio_backlog.is_silent_frame(_voiced(), thr) is False
|
||||
|
||||
|
||||
def test_shed_below_keep_is_noop() -> None:
|
||||
q = _queue([_voiced()] * 2)
|
||||
result = audio_backlog.shed_queue_backlog(q, keep_frames=5, rms_threshold=103, blind=False)
|
||||
assert result.dropped == 0
|
||||
assert result.mode == "none"
|
||||
assert q.qsize() == 2
|
||||
|
||||
|
||||
def test_shed_blind_drops_oldest_and_keeps_recent_in_order() -> None:
|
||||
frames = [_voiced(1000 + i) for i in range(8)]
|
||||
q = _queue(frames)
|
||||
result = audio_backlog.shed_queue_backlog(q, keep_frames=2, rms_threshold=103, blind=True)
|
||||
assert result.dropped == 6
|
||||
assert result.dropped_voiced == 6
|
||||
assert result.mode == "blind"
|
||||
assert _drain(q) == frames[-2:]
|
||||
|
||||
|
||||
def test_shed_energy_drops_only_silence_preserving_speech_order() -> None:
|
||||
voiced = [_voiced(2000 + i * 100) for i in range(4)]
|
||||
# intercala voz e silencio: [V0, S, V1, S, V2, S, V3, S]
|
||||
frames = [voiced[0], _silent(), voiced[1], _silent(), voiced[2], _silent(), voiced[3], _silent()]
|
||||
q = _queue(frames)
|
||||
result = audio_backlog.shed_queue_backlog(q, keep_frames=2, rms_threshold=103, blind=False)
|
||||
# need = 8 - 2 = 6, mas so ha 4 frames silenciosos -> descarta os 4 silencios,
|
||||
# preserva os 4 de voz na ordem (nao corta fala).
|
||||
assert result.mode == "energy"
|
||||
assert result.dropped == 4
|
||||
assert result.dropped_silent == 4
|
||||
assert result.dropped_voiced == 0
|
||||
assert _drain(q) == voiced
|
||||
|
||||
|
||||
def test_shed_energy_stops_at_need_even_with_more_silence() -> None:
|
||||
voiced = [_voiced(3000 + i) for i in range(2)]
|
||||
# 2 de voz + 6 de silencio, keep=4 -> need=4 -> descarta 4 silencios, sobra 4
|
||||
frames = [voiced[0], _silent(), _silent(), _silent(), voiced[1], _silent(), _silent(), _silent()]
|
||||
q = _queue(frames)
|
||||
result = audio_backlog.shed_queue_backlog(q, keep_frames=4, rms_threshold=103, blind=False)
|
||||
assert result.dropped == 4
|
||||
assert result.dropped_silent == 4
|
||||
remaining = _drain(q)
|
||||
assert q.qsize() == 0
|
||||
# sobra os 2 de voz (na ordem) + 2 silencios mais recentes
|
||||
assert remaining[0] == voiced[0]
|
||||
assert voiced[1] in remaining
|
||||
assert len(remaining) == 4
|
||||
|
||||
|
||||
def test_shed_disabled_via_empty_queue() -> None:
|
||||
q = _queue([])
|
||||
result = audio_backlog.shed_queue_backlog(q, keep_frames=2, rms_threshold=103, blind=True)
|
||||
assert result.dropped == 0
|
||||
assert result.mode == "none"
|
||||
178
tests/utils/test_audio_output_tracker.py
Normal file
178
tests/utils/test_audio_output_tracker.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.utils import background as background_module
|
||||
from app.utils.background import (
|
||||
AudioOutputBacklogConfig,
|
||||
AudioOutputLatencyTracker,
|
||||
audio_output_backlog_config_from_env,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger("test.audio_out")
|
||||
|
||||
|
||||
def _capture(monkeypatch) -> list:
|
||||
events: list = []
|
||||
monkeypatch.setattr(
|
||||
background_module,
|
||||
"log_flow_event",
|
||||
lambda logger, step, **payload: events.append((step, payload)),
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def test_output_config_from_env_defaults(monkeypatch) -> None:
|
||||
for name in (
|
||||
"AUDIO_OUT_LATENCY_METRICS_ENABLED",
|
||||
"AUDIO_OUT_LATENCY_ALERT_MS",
|
||||
"AUDIO_OUT_LATENCY_LOG_INTERVAL_S",
|
||||
"AUDIO_OUT_PACING_DEBT_THRESHOLD_MS",
|
||||
"AUDIO_OUT_BACKLOG_SHED_ENABLED",
|
||||
"AUDIO_OUT_BACKLOG_SHED_THRESHOLD_MS",
|
||||
"AUDIO_OUT_BACKLOG_SHED_KEEP_MS",
|
||||
"AUDIO_OUT_BACKLOG_SHED_CHECK_INTERVAL_FRAMES",
|
||||
"AUDIO_OUT_BACKLOG_SILENCE_DBFS",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
config = audio_output_backlog_config_from_env()
|
||||
|
||||
assert config.metrics_enabled is True
|
||||
assert config.shed_enabled is True
|
||||
assert config.shed_threshold_ms == 400
|
||||
assert config.shed_keep_ms == 200
|
||||
assert config.pacing_debt_threshold_ms == 200
|
||||
assert config.silence_dbfs == -60.0
|
||||
# frames dependem do frame_ms passado no uso
|
||||
assert config.shed_threshold_frames(20) == 20
|
||||
assert config.shed_keep_frames(20) == 10
|
||||
assert config.silence_rms_threshold == 32
|
||||
|
||||
|
||||
def test_note_pacing_debt_accumulates_and_logs(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
tracker = AudioOutputLatencyTracker(
|
||||
AudioOutputBacklogConfig(), frame_ms=20, flow_logger=_LOGGER
|
||||
)
|
||||
|
||||
tracker.note_pacing_debt(250, now=1.0)
|
||||
tracker.note_pacing_debt(300, now=2.0)
|
||||
tracker.note_pacing_debt(0, now=3.0) # ignora nao-positivo
|
||||
|
||||
assert tracker.pacing_debt_events == 2
|
||||
assert tracker.pacing_debt_dropped_ms == 550
|
||||
debt_events = [e for e in events if e[0] == "audio_out_pacing_debt"]
|
||||
assert len(debt_events) == 2
|
||||
assert debt_events[-1][1]["pacing_debt_dropped_ms"] == 550
|
||||
assert debt_events[-1][1]["debt_ms"] == 300
|
||||
|
||||
|
||||
def test_record_shed_accumulates_and_logs(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
debug_events = []
|
||||
tracker = AudioOutputLatencyTracker(
|
||||
AudioOutputBacklogConfig(),
|
||||
frame_ms=20,
|
||||
flow_logger=_LOGGER,
|
||||
debug_event_publisher=lambda event, payload: debug_events.append(
|
||||
(event, payload)
|
||||
),
|
||||
)
|
||||
|
||||
tracker.record_shed(
|
||||
dropped_frames=3,
|
||||
dropped_silent=3,
|
||||
mode="energy",
|
||||
queue_before_frames=30,
|
||||
queue_after_frames=27,
|
||||
)
|
||||
tracker.record_shed(
|
||||
dropped_frames=0, # ignora
|
||||
dropped_silent=0,
|
||||
mode="energy",
|
||||
queue_before_frames=10,
|
||||
queue_after_frames=10,
|
||||
)
|
||||
|
||||
assert tracker.total_dropped_frames == 3
|
||||
shed_events = [e for e in events if e[0] == "audio_out_latency_shed"]
|
||||
assert len(shed_events) == 1
|
||||
payload = shed_events[0][1]
|
||||
assert payload["mode"] == "energy"
|
||||
assert payload["dropped_ms"] == 60
|
||||
assert payload["dropped_silent_frames"] == 3
|
||||
assert payload["queue_before_ms"] == 600
|
||||
assert payload["total_dropped_ms"] == 60
|
||||
assert debug_events[-1][0] == "bridge.audio_out.shed"
|
||||
assert debug_events[-1][1]["dropped_ms"] == 60
|
||||
|
||||
|
||||
def test_maybe_log_primes_first_then_logs_on_interval(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
config = AudioOutputBacklogConfig(latency_alert_ms=1000, latency_log_interval_s=15.0)
|
||||
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
|
||||
|
||||
# primeira chamada apenas inicializa o relogio (sem log)
|
||||
tracker.maybe_log(queue_frames=5, now=100.0, reason="tick")
|
||||
assert [e for e in events if e[0] == "audio_out_latency"] == []
|
||||
assert tracker.peak_queue_ms == 100 # peak atualiza mesmo sem logar
|
||||
|
||||
# antes do intervalo: ainda nao loga
|
||||
tracker.maybe_log(queue_frames=5, now=110.0, reason="tick")
|
||||
assert [e for e in events if e[0] == "audio_out_latency"] == []
|
||||
|
||||
# passado o intervalo: loga
|
||||
tracker.maybe_log(queue_frames=5, now=116.0, reason="tick")
|
||||
out_events = [e for e in events if e[0] == "audio_out_latency"]
|
||||
assert len(out_events) == 1
|
||||
assert out_events[0][1]["queue_ms"] == 100
|
||||
|
||||
|
||||
def test_maybe_log_logs_on_alert_threshold(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
config = AudioOutputBacklogConfig(latency_alert_ms=1000, latency_log_interval_s=999.0)
|
||||
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
|
||||
|
||||
tracker.maybe_log(queue_frames=5, now=1.0, reason="tick") # prime
|
||||
# backlog >= alert (60 frames * 20 = 1200 ms >= 1000) -> loga fora do intervalo
|
||||
tracker.maybe_log(queue_frames=60, now=1.5, reason="tick")
|
||||
|
||||
out_events = [e for e in events if e[0] == "audio_out_latency"]
|
||||
assert len(out_events) == 1
|
||||
assert out_events[0][1]["queue_ms"] == 1200
|
||||
assert out_events[0][1]["peak_queue_ms"] == 1200
|
||||
|
||||
|
||||
def test_metrics_disabled_suppresses_all_logs(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
config = AudioOutputBacklogConfig(metrics_enabled=False)
|
||||
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
|
||||
|
||||
tracker.note_pacing_debt(500, now=1.0)
|
||||
tracker.record_shed(
|
||||
dropped_frames=5, dropped_silent=5, mode="energy",
|
||||
queue_before_frames=40, queue_after_frames=35,
|
||||
)
|
||||
tracker.maybe_log(queue_frames=100, now=1.0, reason="tick", force=True)
|
||||
|
||||
assert events == []
|
||||
# contadores ainda acumulam (metrica desligada nao perde estado interno)
|
||||
assert tracker.pacing_debt_dropped_ms == 500
|
||||
assert tracker.total_dropped_frames == 5
|
||||
|
||||
|
||||
def test_maybe_log_alert_is_rate_limited_until_it_recovers(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
config = AudioOutputBacklogConfig(latency_alert_ms=1000, latency_log_interval_s=15.0)
|
||||
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
|
||||
|
||||
tracker.maybe_log(queue_frames=5, now=1.0, reason="tick")
|
||||
tracker.maybe_log(queue_frames=60, now=1.5, reason="alert_start")
|
||||
tracker.maybe_log(queue_frames=60, now=1.52, reason="alert_still_active")
|
||||
tracker.maybe_log(queue_frames=5, now=2.0, reason="recovered")
|
||||
tracker.maybe_log(queue_frames=60, now=2.1, reason="alert_restart")
|
||||
|
||||
out_events = [event for event in events if event[0] == "audio_out_latency"]
|
||||
assert len(out_events) == 2
|
||||
assert [event[1]["reason"] for event in out_events] == ["alert_start", "alert_restart"]
|
||||
152
tests/utils/test_background.py
Normal file
152
tests/utils/test_background.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
from fastapi import WebSocketDisconnect
|
||||
|
||||
from app.utils.background import BridgeOutputStats, ws_out_loop
|
||||
|
||||
|
||||
class _ClosingWebSocket:
|
||||
def __init__(self, exc: BaseException) -> None:
|
||||
self.exc = exc
|
||||
self.sent_frames = 0
|
||||
|
||||
async def send_bytes(self, frame: bytes) -> None:
|
||||
self.sent_frames += 1
|
||||
raise self.exc
|
||||
|
||||
|
||||
class _CollectThenDisconnectWebSocket:
|
||||
def __init__(self, disconnect_after: int) -> None:
|
||||
self.disconnect_after = disconnect_after
|
||||
self.sent_frames: list[bytes] = []
|
||||
|
||||
async def send_bytes(self, frame: bytes) -> None:
|
||||
self.sent_frames.append(frame)
|
||||
if len(self.sent_frames) >= self.disconnect_after:
|
||||
raise WebSocketDisconnect(code=1000)
|
||||
|
||||
class _CaptureThenDisconnectWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.sent_frames: list[bytes] = []
|
||||
|
||||
async def send_bytes(self, frame: bytes) -> None:
|
||||
self.sent_frames.append(frame)
|
||||
raise WebSocketDisconnect(code=1000)
|
||||
|
||||
|
||||
async def _capture_one_agent_frame(frame: bytes, **ws_out_kwargs) -> _CaptureThenDisconnectWebSocket:
|
||||
ws = _CaptureThenDisconnectWebSocket()
|
||||
agent_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
await agent_q.put(frame)
|
||||
|
||||
await asyncio.wait_for(
|
||||
ws_out_loop(
|
||||
ws,
|
||||
agent_q,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=len(frame),
|
||||
**ws_out_kwargs,
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
def test_ws_out_loop_exits_when_close_was_already_sent() -> None:
|
||||
async def _run() -> None:
|
||||
ws = _ClosingWebSocket(RuntimeError('Cannot call "send" once a close message has been sent.'))
|
||||
|
||||
await asyncio.wait_for(
|
||||
ws_out_loop(
|
||||
ws,
|
||||
asyncio.Queue(),
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
assert ws.sent_frames == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_ws_out_loop_applies_default_output_gain(monkeypatch) -> None:
|
||||
monkeypatch.delenv("WS_OUTPUT_GAIN", raising=False)
|
||||
|
||||
frame = struct.pack("<hh", 1000, -1000)
|
||||
ws = asyncio.run(_capture_one_agent_frame(frame))
|
||||
|
||||
assert struct.unpack("<hh", ws.sent_frames[0]) == (1000, -1000)
|
||||
|
||||
|
||||
def test_ws_out_loop_reads_output_gain_from_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("WS_OUTPUT_GAIN", "1.5")
|
||||
|
||||
frame = struct.pack("<hh", 1000, -1000)
|
||||
ws = asyncio.run(_capture_one_agent_frame(frame))
|
||||
|
||||
assert struct.unpack("<hh", ws.sent_frames[0]) == (1500, -1500)
|
||||
|
||||
|
||||
def test_ws_out_loop_prefers_call_config_output_gain(monkeypatch) -> None:
|
||||
monkeypatch.setenv("WS_OUTPUT_GAIN", "1.5")
|
||||
|
||||
frame = struct.pack("<hh", 1000, -1000)
|
||||
ws = asyncio.run(_capture_one_agent_frame(frame, output_gain=1.0))
|
||||
|
||||
assert struct.unpack("<hh", ws.sent_frames[0]) == (1000, -1000)
|
||||
|
||||
|
||||
def test_ws_out_loop_exits_on_websocket_disconnect() -> None:
|
||||
async def _run() -> None:
|
||||
ws = _ClosingWebSocket(WebSocketDisconnect(code=1000))
|
||||
|
||||
await asyncio.wait_for(
|
||||
ws_out_loop(
|
||||
ws,
|
||||
asyncio.Queue(),
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
assert ws.sent_frames == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_ws_out_loop_waits_for_speech_before_signaling_first_agent_audio() -> None:
|
||||
async def _run() -> None:
|
||||
silent_frame = struct.pack("<hh", 0, 0)
|
||||
voiced_frame = struct.pack("<hh", 2000, -2000)
|
||||
ws = _CollectThenDisconnectWebSocket(disconnect_after=3)
|
||||
agent_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
await agent_q.put(silent_frame)
|
||||
await agent_q.put(voiced_frame)
|
||||
first_audio = asyncio.Event()
|
||||
stats = BridgeOutputStats()
|
||||
|
||||
await asyncio.wait_for(
|
||||
ws_out_loop(
|
||||
ws,
|
||||
agent_q,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=len(silent_frame),
|
||||
first_agent_audio_sent=first_audio,
|
||||
output_stats=stats,
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
assert ws.sent_frames[:2] == [silent_frame, voiced_frame]
|
||||
assert first_audio.is_set()
|
||||
assert stats.first_agent_audio_sent is True
|
||||
assert stats.agent_audio_bursts == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
106
tests/utils/test_call_timeline.py
Normal file
106
tests/utils/test_call_timeline.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from app.utils.call_timeline import CallTimeline
|
||||
|
||||
|
||||
class CallTimelineTests(unittest.TestCase):
|
||||
def test_emit_writes_jsonl_with_relative_time(self) -> None:
|
||||
logger = logging.getLogger("test.call_timeline")
|
||||
logger.handlers = []
|
||||
logger.addHandler(logging.NullHandler())
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
old_dir = os.environ.get("CALL_TIMELINE_DIR")
|
||||
old_console = os.environ.get("CALL_TIMELINE_CONSOLE")
|
||||
old_enabled = os.environ.get("CALL_TIMELINE_ENABLED")
|
||||
|
||||
os.environ["CALL_TIMELINE_DIR"] = tmpdir
|
||||
os.environ["CALL_TIMELINE_CONSOLE"] = "0"
|
||||
os.environ["CALL_TIMELINE_ENABLED"] = "1"
|
||||
|
||||
try:
|
||||
timeline = CallTimeline(
|
||||
logger=logger,
|
||||
component="bridge",
|
||||
timeline_id="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
room="room-dev-123",
|
||||
session_id="PRT-1",
|
||||
phone_number="31999999999",
|
||||
origin_unix_ms=1000,
|
||||
)
|
||||
timeline.emit("call_start", ok=True, nested={"a": 1})
|
||||
timeline.emit("ready_sent")
|
||||
self.assertTrue(timeline.flush(timeout=5.0))
|
||||
|
||||
with timeline.path.open("r", encoding="utf-8") as handle:
|
||||
lines = [json.loads(line) for line in handle if line.strip()]
|
||||
finally:
|
||||
if old_dir is None:
|
||||
os.environ.pop("CALL_TIMELINE_DIR", None)
|
||||
else:
|
||||
os.environ["CALL_TIMELINE_DIR"] = old_dir
|
||||
|
||||
if old_console is None:
|
||||
os.environ.pop("CALL_TIMELINE_CONSOLE", None)
|
||||
else:
|
||||
os.environ["CALL_TIMELINE_CONSOLE"] = old_console
|
||||
|
||||
if old_enabled is None:
|
||||
os.environ.pop("CALL_TIMELINE_ENABLED", None)
|
||||
else:
|
||||
os.environ["CALL_TIMELINE_ENABLED"] = old_enabled
|
||||
|
||||
self.assertEqual(2, len(lines))
|
||||
self.assertEqual("bridge", lines[0]["component"])
|
||||
self.assertEqual("call_start", lines[0]["event"])
|
||||
self.assertEqual("room-dev-123", lines[0]["timeline_id"])
|
||||
self.assertEqual("PRT-1", lines[0]["protocol"])
|
||||
self.assertEqual("31999999999", lines[0]["phone_number"])
|
||||
self.assertTrue(lines[0]["ok"])
|
||||
self.assertEqual({"a": 1}, lines[0]["nested"])
|
||||
self.assertGreaterEqual(lines[0]["t_rel_ms"], 0)
|
||||
self.assertEqual("ready_sent", lines[1]["event"])
|
||||
|
||||
def test_background_writer_preserves_order_for_many_events(self) -> None:
|
||||
logger = logging.getLogger("test.call_timeline.order")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
old_dir = os.environ.get("CALL_TIMELINE_DIR")
|
||||
old_enabled = os.environ.get("CALL_TIMELINE_ENABLED")
|
||||
os.environ["CALL_TIMELINE_DIR"] = tmpdir
|
||||
os.environ["CALL_TIMELINE_ENABLED"] = "1"
|
||||
try:
|
||||
timeline = CallTimeline(
|
||||
logger=logger,
|
||||
component="bridge",
|
||||
timeline_id="room-order",
|
||||
origin_unix_ms=1000,
|
||||
)
|
||||
for index in range(200):
|
||||
timeline.emit("tick", index=index)
|
||||
self.assertTrue(timeline.flush(timeout=5.0))
|
||||
with timeline.path.open("r", encoding="utf-8") as handle:
|
||||
lines = [json.loads(line) for line in handle if line.strip()]
|
||||
finally:
|
||||
if old_dir is None:
|
||||
os.environ.pop("CALL_TIMELINE_DIR", None)
|
||||
else:
|
||||
os.environ["CALL_TIMELINE_DIR"] = old_dir
|
||||
if old_enabled is None:
|
||||
os.environ.pop("CALL_TIMELINE_ENABLED", None)
|
||||
else:
|
||||
os.environ["CALL_TIMELINE_ENABLED"] = old_enabled
|
||||
|
||||
self.assertEqual(list(range(200)), [line["index"] for line in lines])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
47
tests/utils/test_call_timeline_async_writer.py
Normal file
47
tests/utils/test_call_timeline_async_writer.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.utils.call_timeline import _TimelineWriter
|
||||
|
||||
|
||||
def test_queue_full_is_counted_and_flush_honors_timeout() -> None:
|
||||
writer = _TimelineWriter(max_queue=1, warning_interval_s=3_600)
|
||||
writer.start = lambda: True # type: ignore[method-assign]
|
||||
writer._last_drop_warning_at = time.monotonic()
|
||||
|
||||
assert writer.submit(Path("unused"), "first") is True
|
||||
assert writer.submit(Path("unused"), "second") is False
|
||||
|
||||
started = time.monotonic()
|
||||
assert writer.flush(timeout=0.01) is False
|
||||
assert time.monotonic() - started < 0.5
|
||||
assert writer.dropped == 1
|
||||
|
||||
|
||||
def test_write_error_is_counted_without_killing_writer() -> None:
|
||||
writer = _TimelineWriter(max_queue=10, warning_interval_s=3_600)
|
||||
writer._last_error_warning_at = time.monotonic()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
invalid_path = Path(tmpdir)
|
||||
assert writer.submit(invalid_path, "line") is True
|
||||
assert writer.flush(timeout=2.0) is True
|
||||
|
||||
assert writer.write_errors == 1
|
||||
assert writer.shutdown(timeout=2.0) is True
|
||||
|
||||
|
||||
def test_shutdown_drains_pending_lines_and_rejects_new_work() -> None:
|
||||
writer = _TimelineWriter(max_queue=10)
|
||||
writer._last_drop_warning_at = time.monotonic()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "timeline.jsonl"
|
||||
assert writer.submit(path, "one") is True
|
||||
assert writer.submit(path, "two") is True
|
||||
assert writer.shutdown(timeout=2.0) is True
|
||||
assert path.read_text(encoding="utf-8").splitlines() == ["one", "two"]
|
||||
assert writer.submit(path, "three") is False
|
||||
|
||||
assert writer.dropped == 1
|
||||
292
tests/utils/test_full_call_recording.py
Normal file
292
tests/utils/test_full_call_recording.py
Normal file
@@ -0,0 +1,292 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
import wave
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.utils import full_call_recording
|
||||
from app.utils.full_call_recording import (
|
||||
EntireCallRecorder,
|
||||
EntireCallUploadItem,
|
||||
build_entire_call_object_name,
|
||||
enqueue_entire_call_upload,
|
||||
)
|
||||
from app.utils.stt_audio_upload import OCIUploadConfig
|
||||
|
||||
|
||||
def test_entire_call_object_name_uses_date_folder_and_session_filename() -> None:
|
||||
object_name = build_entire_call_object_name(
|
||||
session_id="sessao muito longa/" * 30,
|
||||
now=datetime(2026, 7, 8, 10, 30, 0),
|
||||
)
|
||||
|
||||
parts = object_name.split("/")
|
||||
assert parts[0] == "2026-07-08"
|
||||
assert parts[1] == "entire_call"
|
||||
assert object_name.endswith(".wav")
|
||||
assert len(parts) == 3
|
||||
assert len(parts[2]) <= 68
|
||||
assert " " not in object_name
|
||||
|
||||
|
||||
def test_entire_call_recorder_writes_stereo_wav_without_upload(tmp_path: Path) -> None:
|
||||
async def _run() -> Path:
|
||||
recorder = EntireCallRecorder(
|
||||
session_id="session-1",
|
||||
sample_rate=1000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
tmp_dir=tmp_path,
|
||||
logger_override=logging.getLogger("test.full_call_recording.wav"),
|
||||
object_name="2026-07-08/entire_call/session-1.wav",
|
||||
)
|
||||
recorder.start()
|
||||
recorder.record_client_frame(struct.pack("<hh", 1000, -1000))
|
||||
recorder.record_output_frame(struct.pack("<hh", 2000, 500))
|
||||
result = await recorder.finalize(enqueue_upload=False)
|
||||
|
||||
assert result is not None
|
||||
assert result.frames == 1
|
||||
assert result.duration_ms == 20
|
||||
assert result.upload_enqueued is False
|
||||
return result.path
|
||||
|
||||
wav_path = asyncio.run(_run())
|
||||
with wave.open(str(wav_path), "rb") as handle:
|
||||
assert handle.getframerate() == 1000
|
||||
assert handle.getnchannels() == 2
|
||||
assert handle.getsampwidth() == 2
|
||||
# Stereo samples are interleaved as client-left, agent-right.
|
||||
assert struct.unpack("<hhhh", handle.readframes(2)) == (
|
||||
1000,
|
||||
2000,
|
||||
-1000,
|
||||
500,
|
||||
)
|
||||
|
||||
|
||||
def test_entire_call_recorder_keeps_each_side_in_its_own_channel(tmp_path: Path) -> None:
|
||||
async def _run() -> Path:
|
||||
recorder = EntireCallRecorder(
|
||||
session_id="session-separated",
|
||||
sample_rate=1000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
tmp_dir=tmp_path,
|
||||
logger_override=logging.getLogger("test.full_call_recording.separated"),
|
||||
)
|
||||
recorder.start()
|
||||
recorder.record_output_frame(struct.pack("<hh", 300, 400))
|
||||
recorder.record_client_frame(struct.pack("<hh", 100, 200))
|
||||
result = await recorder.finalize(enqueue_upload=False)
|
||||
assert result is not None
|
||||
return result.path
|
||||
|
||||
with wave.open(str(asyncio.run(_run())), "rb") as handle:
|
||||
assert handle.getnchannels() == 2
|
||||
samples = struct.unpack("<hhhhhhhh", handle.readframes(4))
|
||||
assert samples == (
|
||||
0,
|
||||
300,
|
||||
0,
|
||||
400,
|
||||
100,
|
||||
0,
|
||||
200,
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
def test_entire_call_recorder_accepts_metadata_with_reserved_names(tmp_path: Path) -> None:
|
||||
async def _run() -> None:
|
||||
recorder = EntireCallRecorder(
|
||||
session_id="session-1",
|
||||
sample_rate=1000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
tmp_dir=tmp_path,
|
||||
logger_override=logging.getLogger("test.full_call_recording.metadata"),
|
||||
metadata={"session_id": "session-1", "room": "room-1"},
|
||||
object_name="2026-07-08/entire_call/session-1.wav",
|
||||
)
|
||||
|
||||
recorder.start()
|
||||
recorder.record_output_frame(b"\x00\x00\x00\x00")
|
||||
result = await recorder.finalize(enqueue_upload=False)
|
||||
|
||||
assert result is not None
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_entire_call_recorder_queue_full_drops_without_raising(tmp_path: Path) -> None:
|
||||
recorder = EntireCallRecorder(
|
||||
session_id="session-1",
|
||||
sample_rate=1000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
tmp_dir=tmp_path,
|
||||
logger_override=logging.getLogger("test.full_call_recording.drop"),
|
||||
queue_size=1,
|
||||
)
|
||||
recorder._started = True
|
||||
recorder._queue.put_nowait(object())
|
||||
|
||||
recorder.record_client_frame(b"\x00\x00\x00\x00")
|
||||
|
||||
assert recorder.dropped_queue_frames == 1
|
||||
|
||||
|
||||
def test_upload_item_streams_file_to_oci(monkeypatch, tmp_path: Path) -> None:
|
||||
calls: dict[str, object] = {}
|
||||
wav_path = tmp_path / "call.wav"
|
||||
wav_path.write_bytes(b"fake-wav")
|
||||
config = OCIUploadConfig(
|
||||
auth_mode="oke_workload_identity",
|
||||
region="sa-saopaulo-1",
|
||||
bucket="tia-audio",
|
||||
namespace="namespace",
|
||||
)
|
||||
|
||||
class FakeClient:
|
||||
def put_object(self, **kwargs):
|
||||
calls["namespace_name"] = kwargs["namespace_name"]
|
||||
calls["bucket_name"] = kwargs["bucket_name"]
|
||||
calls["object_name"] = kwargs["object_name"]
|
||||
calls["body"] = kwargs["put_object_body"].read()
|
||||
return SimpleNamespace(status=200)
|
||||
|
||||
monkeypatch.setattr(full_call_recording, "_oci_upload_config_from_env", lambda: config)
|
||||
monkeypatch.setattr(full_call_recording, "_oci_client", lambda _config: FakeClient())
|
||||
|
||||
item = EntireCallUploadItem(
|
||||
object_name="2026-07-08/entire_call/session-1.wav",
|
||||
path=wav_path,
|
||||
session_id="session-1",
|
||||
bytes=8,
|
||||
duration_ms=20,
|
||||
dropped_queue_frames=0,
|
||||
dropped_client_buffer_frames=0,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
asyncio.run(full_call_recording._upload_item(item))
|
||||
|
||||
assert calls == {
|
||||
"namespace_name": "namespace",
|
||||
"bucket_name": "tia-audio",
|
||||
"object_name": "2026-07-08/entire_call/session-1.wav",
|
||||
"body": b"fake-wav",
|
||||
}
|
||||
|
||||
|
||||
def test_upload_item_retries_with_a_fresh_client_after_transient_failure(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
wav_path = tmp_path / "call.wav"
|
||||
wav_path.write_bytes(b"fake-wav")
|
||||
config = OCIUploadConfig(
|
||||
auth_mode="oke_workload_identity",
|
||||
region="sa-saopaulo-1",
|
||||
bucket="tia-audio",
|
||||
namespace="namespace",
|
||||
)
|
||||
attempts = 0
|
||||
resets = 0
|
||||
|
||||
class FakeClient:
|
||||
def put_object(self, **_kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
raise RuntimeError("transient connection failure")
|
||||
return SimpleNamespace(status=200)
|
||||
|
||||
def fake_reset() -> None:
|
||||
nonlocal resets
|
||||
resets += 1
|
||||
|
||||
monkeypatch.setenv("ENTIRE_CALL_RECORDING_UPLOAD_RETRY_BASE_DELAY_S", "0")
|
||||
monkeypatch.setattr(full_call_recording, "_oci_upload_config_from_env", lambda: config)
|
||||
monkeypatch.setattr(full_call_recording, "_oci_client", lambda _config: FakeClient())
|
||||
monkeypatch.setattr(full_call_recording, "_reset_oci_client", fake_reset)
|
||||
|
||||
item = EntireCallUploadItem(
|
||||
object_name="2026-07-08/entire_call/session-retry.wav",
|
||||
path=wav_path,
|
||||
session_id="session-retry",
|
||||
bytes=8,
|
||||
duration_ms=20,
|
||||
dropped_queue_frames=0,
|
||||
dropped_client_buffer_frames=0,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
asyncio.run(full_call_recording._upload_item(item))
|
||||
|
||||
assert attempts == 2
|
||||
assert resets == 1
|
||||
|
||||
|
||||
def test_upload_worker_keeps_file_after_all_attempts_fail(monkeypatch, tmp_path: Path) -> None:
|
||||
async def _run() -> None:
|
||||
wav_path = tmp_path / "failed.wav"
|
||||
wav_path.write_bytes(b"recoverable-wav")
|
||||
item = EntireCallUploadItem(
|
||||
object_name="2026-07-08/entire_call/session-failed.wav",
|
||||
path=wav_path,
|
||||
session_id="session-failed",
|
||||
bytes=15,
|
||||
duration_ms=20,
|
||||
dropped_queue_frames=0,
|
||||
dropped_client_buffer_frames=0,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
async def fail_upload(_item) -> None:
|
||||
raise RuntimeError("OCI unavailable")
|
||||
|
||||
monkeypatch.setattr(full_call_recording, "_upload_item", fail_upload)
|
||||
worker = full_call_recording._EntireCallUploadWorker(asyncio.get_running_loop())
|
||||
assert worker.enqueue(item)
|
||||
await asyncio.wait_for(worker._queue.join(), timeout=1)
|
||||
|
||||
assert wav_path.read_bytes() == b"recoverable-wav"
|
||||
worker._task.cancel()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_enqueue_entire_call_upload_is_noop_and_keeps_file_without_oci_config(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
wav_path = tmp_path / "call.wav"
|
||||
wav_path.write_bytes(b"fake-wav")
|
||||
monkeypatch.setattr(full_call_recording, "_oci_upload_config_from_env", lambda: None)
|
||||
|
||||
assert (
|
||||
enqueue_entire_call_upload(
|
||||
path=wav_path,
|
||||
object_name="2026-07-08/entire_call/session-1.wav",
|
||||
session_id="session-1",
|
||||
bytes=8,
|
||||
duration_ms=20,
|
||||
logger_override=logging.getLogger("test.full_call_recording.noop"),
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert wav_path.read_bytes() == b"fake-wav"
|
||||
117
tests/utils/test_logging_async_file.py
Normal file
117
tests/utils/test_logging_async_file.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import io
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import app.utils.logging as app_logging
|
||||
from app.utils.logging import _LogWriter, get_call_logger
|
||||
|
||||
|
||||
class _FailingHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
raise OSError("disk unavailable")
|
||||
|
||||
|
||||
def test_session_console_formatter_appends_context_without_duplicating() -> None:
|
||||
stream = io.StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(app_logging._SessionConsoleFormatter("%(message)s"))
|
||||
logger = logging.getLogger(f"test.session.console.{time.time_ns()}")
|
||||
logger.handlers = [handler]
|
||||
logger.propagate = False
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
try:
|
||||
app_logging.set_log_session_id("session-123")
|
||||
logger.info("FLOW | step=test")
|
||||
logger.info("FLOW | step=test | session_id=session-123")
|
||||
finally:
|
||||
app_logging.set_log_session_id("")
|
||||
|
||||
assert stream.getvalue().splitlines() == [
|
||||
"FLOW | step=test | session_id=session-123",
|
||||
"FLOW | step=test | session_id=session-123",
|
||||
]
|
||||
|
||||
|
||||
def test_session_console_formatter_keeps_structured_json_valid() -> None:
|
||||
formatter = app_logging._SessionConsoleFormatter("%(message)s")
|
||||
try:
|
||||
app_logging.set_log_session_id("session-456")
|
||||
record = logging.LogRecord(
|
||||
"test",
|
||||
logging.INFO,
|
||||
"",
|
||||
0,
|
||||
'{"tipo_evento":"envio msg","session_id":"session-456"}',
|
||||
(),
|
||||
None,
|
||||
)
|
||||
|
||||
assert formatter.format(record) == (
|
||||
'{"tipo_evento":"envio msg","session_id":"session-456"}'
|
||||
)
|
||||
finally:
|
||||
app_logging.set_log_session_id("")
|
||||
|
||||
|
||||
def test_call_file_handler_is_async_and_writes_to_disk() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
saved = {key: os.environ.get(key) for key in ("LOG_DIR", "LOG_TO_FILE")}
|
||||
os.environ["LOG_DIR"] = tmp
|
||||
os.environ["LOG_TO_FILE"] = "1"
|
||||
try:
|
||||
logger = get_call_logger(
|
||||
phone_number="5500000000123",
|
||||
session_id=f"async{time.time_ns()}",
|
||||
)
|
||||
async_handlers = [
|
||||
handler
|
||||
for handler in logger.handlers
|
||||
if isinstance(handler, app_logging._AsyncFileHandler)
|
||||
]
|
||||
assert len(async_handlers) == 1
|
||||
|
||||
logger.info("LINHA_DE_TESTE_ASYNC_123")
|
||||
assert app_logging._LOG_WRITER.flush(timeout=5.0)
|
||||
|
||||
files = list(Path(tmp).glob("*.txt"))
|
||||
assert len(files) == 1
|
||||
assert "LINHA_DE_TESTE_ASYNC_123" in files[0].read_text(encoding="utf-8")
|
||||
finally:
|
||||
for key, value in saved.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def test_queue_full_is_counted_and_flush_honors_timeout() -> None:
|
||||
writer = _LogWriter(max_queue=1, warning_interval_s=3_600)
|
||||
writer.start = lambda: True # type: ignore[method-assign]
|
||||
writer._last_drop_warning_at = time.monotonic()
|
||||
handler = logging.NullHandler()
|
||||
record = logging.LogRecord("test", logging.INFO, "", 0, "message", (), None)
|
||||
|
||||
assert writer.submit(handler, record) is True
|
||||
assert writer.submit(handler, record) is False
|
||||
|
||||
started = time.monotonic()
|
||||
assert writer.flush(timeout=0.01) is False
|
||||
assert time.monotonic() - started < 0.5
|
||||
assert writer.dropped == 1
|
||||
|
||||
|
||||
def test_handler_error_is_counted_and_shutdown_stays_healthy() -> None:
|
||||
writer = _LogWriter(max_queue=10, warning_interval_s=3_600)
|
||||
writer._last_error_warning_at = time.monotonic()
|
||||
record = logging.LogRecord("test", logging.INFO, "", 0, "message", (), None)
|
||||
|
||||
assert writer.submit(_FailingHandler(), record) is True
|
||||
assert writer.flush(timeout=2.0) is True
|
||||
assert writer.write_errors == 1
|
||||
assert writer.shutdown(timeout=2.0) is True
|
||||
460
tests/utils/test_logging_pubsub.py
Normal file
460
tests/utils/test_logging_pubsub.py
Normal file
@@ -0,0 +1,460 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from app.utils import logging as logging_utils
|
||||
from app.utils import structured_otlp
|
||||
from app.utils import structured_pubsub
|
||||
|
||||
|
||||
class _FakeFuture:
|
||||
def add_done_callback(self, callback):
|
||||
callback(self)
|
||||
|
||||
def result(self):
|
||||
return "message-id"
|
||||
|
||||
|
||||
class _FakePublisher:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
def publish(self, *args, **kwargs):
|
||||
self.calls.append((args, kwargs))
|
||||
return _FakeFuture()
|
||||
|
||||
def topic_path(self, project_id: str, topic: str) -> str:
|
||||
return f"projects/{project_id}/topics/{topic}"
|
||||
|
||||
|
||||
def test_log_structured_event_publishes_to_pubsub_when_configured(monkeypatch) -> None:
|
||||
published_events = []
|
||||
|
||||
monkeypatch.setenv("STRUCTURED_EVENT_LOG_ENABLED", "1")
|
||||
monkeypatch.setattr(logging_utils, "publish_structured_event", published_events.append)
|
||||
|
||||
logger = logging.getLogger("test.logging_pubsub")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
event = logging_utils.log_structured_event(
|
||||
logger,
|
||||
logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
),
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
message_id="message-1",
|
||||
inicio_ns=1_700_000_000_000_000_000,
|
||||
)
|
||||
|
||||
assert event is not None
|
||||
assert published_events == [event]
|
||||
|
||||
|
||||
def test_log_structured_event_publishes_to_pubsub_and_otlp_when_trace_id_is_valid(monkeypatch) -> None:
|
||||
published_events = []
|
||||
published_spans = []
|
||||
|
||||
monkeypatch.setenv("STRUCTURED_EVENT_LOG_ENABLED", "1")
|
||||
monkeypatch.setattr(logging_utils, "publish_structured_event", published_events.append)
|
||||
monkeypatch.setattr(logging_utils, "publish_structured_span", published_spans.append)
|
||||
|
||||
logger = logging.getLogger("test.logging_pubsub_otlp")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
event = logging_utils.log_structured_event(
|
||||
logger,
|
||||
logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="550e8400-e29b-41d4-a716-446655440101",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
),
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
message_id="message-1",
|
||||
inicio_ns=1_700_000_000_000_000_000,
|
||||
)
|
||||
|
||||
assert event is not None
|
||||
assert published_events == [event]
|
||||
assert published_spans == [event]
|
||||
|
||||
|
||||
def test_publish_structured_event_sends_json_to_configured_topic(monkeypatch) -> None:
|
||||
fake_publisher = _FakePublisher()
|
||||
|
||||
monkeypatch.setenv("GCP_PROJECT_ID", "project-1")
|
||||
monkeypatch.setenv("AGENT_PUBSUB_TOPIC", "agent-logs")
|
||||
monkeypatch.setattr(structured_pubsub, "_PUBLISHER", fake_publisher)
|
||||
monkeypatch.setattr(structured_pubsub, "_TOPIC_PATH", "projects/project-1/topics/agent-logs")
|
||||
|
||||
event = {
|
||||
"tipo_evento": "recebimento msg",
|
||||
"dat_hora_inicio": "2026-05-11T22:07:29.965Z",
|
||||
"dat_hora_fim": "2026-05-11T22:07:30.965Z",
|
||||
"callid": "call-1",
|
||||
"session_id": "session-1",
|
||||
"nome_agente": "conta",
|
||||
"cod_ani": "1234",
|
||||
"message_id": "message-1",
|
||||
"original_message_id": "original-message-1",
|
||||
"http_cod_status": 200,
|
||||
"http_cod_desc": "OK",
|
||||
}
|
||||
structured_pubsub.publish_structured_event(event)
|
||||
|
||||
assert len(fake_publisher.calls) == 1
|
||||
|
||||
args, kwargs = fake_publisher.calls[0]
|
||||
assert args[0] == "projects/project-1/topics/agent-logs"
|
||||
payload = json.loads(args[1].decode("utf-8"))
|
||||
assert payload == {
|
||||
"tipo_evento": "recebimento msg",
|
||||
"dat_hora_inicio": "11/05/2026 19:07:29,965000000",
|
||||
"dat_hora_termino": "11/05/2026 19:07:30,965000000",
|
||||
"callid": "call-1",
|
||||
"sessionId": "session-1",
|
||||
"nome_agente": "conta",
|
||||
"cod_ani": "1234",
|
||||
"message_id": "message-1",
|
||||
"http_cod_status": 200,
|
||||
"http_cod_desc": "OK",
|
||||
}
|
||||
assert "session_id" not in payload
|
||||
assert "dat_hora_fim" not in payload
|
||||
assert "original_message_id" not in payload
|
||||
assert kwargs == {
|
||||
"tipo_evento": "recebimento msg",
|
||||
"dat_hora_inicio": "11/05/2026 19:07:29,965000000",
|
||||
"dat_hora_termino": "11/05/2026 19:07:30,965000000",
|
||||
"sessionId": "session-1",
|
||||
"callid": "call-1",
|
||||
"nome_agente": "conta",
|
||||
"cod_ani": "1234",
|
||||
"http_cod_status": "200",
|
||||
"http_cod_desc": "OK",
|
||||
}
|
||||
|
||||
|
||||
def test_log_structured_event_skips_pubsub_without_required_env(monkeypatch) -> None:
|
||||
fake_publisher = _FakePublisher()
|
||||
|
||||
monkeypatch.delenv("GCP_PROJECT_ID", raising=False)
|
||||
monkeypatch.delenv("AGENT_PUBSUB_TOPIC", raising=False)
|
||||
monkeypatch.setattr(structured_pubsub, "_PUBLISHER", fake_publisher)
|
||||
monkeypatch.setattr(structured_pubsub, "_TOPIC_PATH", "projects/project-1/topics/agent-logs")
|
||||
|
||||
structured_pubsub.publish_structured_event({"tipo_evento": "envio msg"})
|
||||
|
||||
assert fake_publisher.calls == []
|
||||
|
||||
|
||||
def test_build_structured_event_includes_session_id_and_lowercase_nome_agente() -> None:
|
||||
event = logging_utils.build_structured_event(
|
||||
logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
),
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
message_id="message-1",
|
||||
inicio_ns=1_700_000_000_000_000_000,
|
||||
)
|
||||
|
||||
assert event["session_id"] == "session-1"
|
||||
assert event["nome_agente"] == "conta"
|
||||
assert event["dat_hora_fim"] == ""
|
||||
assert event["latencia_total_STT_TTS"] == ""
|
||||
assert event["latencia_TFFB_STT_TTS"] == ""
|
||||
assert event["duracao_audio"] == ""
|
||||
assert event["tts_max_gap_ms"] == ""
|
||||
assert event["tts_max_underrun_0ms"] == ""
|
||||
assert event["tts_underflow_count"] == ""
|
||||
assert event["tts_avg_underflow_ms"] == ""
|
||||
assert "tts_clear_rtt_ms" not in event
|
||||
assert "tts_connection_queue_wait_ms" not in event
|
||||
assert "tts_clear_stale_messages" not in event
|
||||
assert "tts_clear_stale_audio_bytes" not in event
|
||||
assert event["interrupcao"] == ""
|
||||
assert event["erro_msg"] == ""
|
||||
assert event["erro_detalhe"] == ""
|
||||
assert event["http_cod_status"] == ""
|
||||
assert event["http_cod_desc"] == ""
|
||||
assert event["finalizacao"] == ""
|
||||
assert all(value is not None for value in event.values())
|
||||
assert "Nome_agente" not in event
|
||||
|
||||
|
||||
def test_build_structured_event_includes_audio_duration_and_interruption_flag() -> None:
|
||||
event = logging_utils.build_structured_event(
|
||||
logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
),
|
||||
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
|
||||
message_id="message-1",
|
||||
inicio_ns=1_700_000_000_000_000_000,
|
||||
duracao_audio_ms=2400,
|
||||
tts_max_gap_ms=840,
|
||||
tts_max_underrun_0ms=125,
|
||||
interrupcao=True,
|
||||
)
|
||||
|
||||
assert event["duracao_audio"] == 2400
|
||||
assert event["tts_max_gap_ms"] == 840
|
||||
assert event["tts_max_underrun_0ms"] == 125
|
||||
assert event["interrupcao"] == 1
|
||||
|
||||
|
||||
def test_build_structured_event_uses_uuid_when_message_id_is_missing(monkeypatch) -> None:
|
||||
generated = uuid.UUID("12345678-1234-4234-9234-123456789abc")
|
||||
monkeypatch.setattr(logging_utils.uuid, "uuid4", lambda: generated)
|
||||
|
||||
event = logging_utils.build_structured_event(
|
||||
logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
),
|
||||
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
|
||||
inicio_ns=1_700_000_000_000_000_000,
|
||||
)
|
||||
|
||||
assert event["message_id"] == "12345678-1234-4234-9234-123456789abc"
|
||||
|
||||
|
||||
def test_error_message_from_resource_uses_event_specific_contract() -> None:
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
resource="agent_runtime",
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
)
|
||||
== "Falha TIA"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
resource="stt",
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
)
|
||||
== "Falha STT"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
resource="bridge",
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
)
|
||||
== "Falha comunicacao"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
status="stop_silencio_longo",
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
)
|
||||
== "Silencio Longo"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
resource="agent_backend",
|
||||
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
|
||||
)
|
||||
== "Falha comunicacao"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
resource="tts",
|
||||
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
|
||||
)
|
||||
== "Falha TTS"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
status="transferred",
|
||||
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
|
||||
)
|
||||
== "Transferido"
|
||||
)
|
||||
|
||||
|
||||
def test_format_event_timestamp_uses_iso_utc_milliseconds() -> None:
|
||||
assert logging_utils.format_event_timestamp(1_700_000_001_234_567_890) == (
|
||||
"2023-11-14T22:13:21.234Z"
|
||||
)
|
||||
|
||||
|
||||
def test_structured_context_from_start_data_keeps_session_id_empty_when_not_provided() -> None:
|
||||
context = logging_utils.structured_context_from_start_data(
|
||||
{
|
||||
"callIdGed": "ged-1",
|
||||
"gsm": "5511999999999",
|
||||
"ani": "1234",
|
||||
"agent": "conta",
|
||||
}
|
||||
)
|
||||
|
||||
assert context.callid == "ged-1"
|
||||
assert context.session_id == ""
|
||||
|
||||
|
||||
def test_topic_path_accepts_short_topic_or_full_resource_name() -> None:
|
||||
fake_publisher = _FakePublisher()
|
||||
|
||||
assert structured_pubsub._topic_path(fake_publisher, "project-1", "agent-logs") == (
|
||||
"projects/project-1/topics/agent-logs"
|
||||
)
|
||||
assert structured_pubsub._topic_path(
|
||||
fake_publisher,
|
||||
"project-1",
|
||||
"projects/another-project/topics/agent-logs",
|
||||
) == "projects/another-project/topics/agent-logs"
|
||||
|
||||
|
||||
def test_trace_id_from_session_id_sanitizes_uuid_and_rejects_invalid_values() -> None:
|
||||
assert structured_otlp.trace_id_from_session_id(
|
||||
"550e8400-e29b-41d4-a716-446655440101"
|
||||
) == "550e8400e29b41d4a716446655440101"
|
||||
assert structured_otlp.trace_id_from_session_id("sess-001") == ""
|
||||
assert structured_otlp.trace_id_from_session_id("00000000-0000-0000-0000-000000000000") == ""
|
||||
|
||||
|
||||
def _install_memory_tracer(monkeypatch):
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
|
||||
endpoint = "http://otel.example/v1/traces"
|
||||
exporter = InMemorySpanExporter()
|
||||
provider = TracerProvider(id_generator=structured_otlp._ID_GENERATOR)
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", endpoint)
|
||||
monkeypatch.setattr(structured_otlp, "_TRACER_PROVIDER", provider)
|
||||
monkeypatch.setattr(structured_otlp, "_TRACER", provider.get_tracer("test.structured_otlp"))
|
||||
monkeypatch.setattr(structured_otlp, "_TRACER_ENDPOINT", endpoint)
|
||||
return exporter
|
||||
|
||||
|
||||
def test_publish_structured_span_exports_otlp_span_with_timestamps_and_attributes(monkeypatch) -> None:
|
||||
exporter = _install_memory_tracer(monkeypatch)
|
||||
start_ns = 1_700_000_000_000_000_000
|
||||
end_ns = 1_700_000_001_234_000_000
|
||||
|
||||
event = {
|
||||
"tipo_evento": "envio msg",
|
||||
"message_id": "message-1",
|
||||
"dat_hora_inicio": logging_utils.format_event_timestamp(start_ns),
|
||||
"dat_hora_fim": logging_utils.format_event_timestamp(end_ns),
|
||||
"callid": "call-1",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440101",
|
||||
"num_telefone": "5511999999999",
|
||||
"cod_ani": "1234",
|
||||
"latencia_total_STT_TTS": 1234,
|
||||
"latencia_TFFB_STT_TTS": 123,
|
||||
"erro_msg": "Falha TTS",
|
||||
"erro_detalhe": "",
|
||||
"http_cod_status": "",
|
||||
"http_cod_desc": "",
|
||||
"finalizacao": "",
|
||||
"nome_agente": "conta",
|
||||
"original_message_id": "original-message-1",
|
||||
}
|
||||
|
||||
structured_otlp.publish_structured_span(event)
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
assert span.name == "structured_log.envio msg"
|
||||
assert span.context.trace_id == int("550e8400e29b41d4a716446655440101", 16)
|
||||
assert span.context.span_id != 0
|
||||
assert span.parent is None
|
||||
assert span.start_time == start_ns
|
||||
assert span.end_time == end_ns
|
||||
assert span.attributes["tipo_evento"] == "envio msg"
|
||||
assert span.attributes["message_id"] == "message-1"
|
||||
assert span.attributes["latencia_total_STT_TTS"] == 1234
|
||||
assert span.attributes["erro_msg"] == "Falha TTS"
|
||||
assert span.attributes["erro_detalhe"] == ""
|
||||
assert span.attributes["http_cod_status"] == ""
|
||||
assert span.attributes["http_cod_desc"] == ""
|
||||
assert span.attributes["finalizacao"] == ""
|
||||
assert "original_message_id" not in span.attributes
|
||||
assert span.status.status_code.name == "ERROR"
|
||||
|
||||
|
||||
def test_publish_structured_span_keeps_same_trace_id_and_lets_sdk_generate_span_id(monkeypatch) -> None:
|
||||
exporter = _install_memory_tracer(monkeypatch)
|
||||
trace_id = int("550e8400e29b41d4a716446655440101", 16)
|
||||
|
||||
base_event = {
|
||||
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440101",
|
||||
}
|
||||
structured_otlp.publish_structured_span({**base_event, "tipo_evento": "recebimento msg"})
|
||||
structured_otlp.publish_structured_span({**base_event, "tipo_evento": "envio msg"})
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert len(spans) == 2
|
||||
assert {span.context.trace_id for span in spans} == {trace_id}
|
||||
assert spans[0].context.span_id != spans[1].context.span_id
|
||||
assert spans[0].parent is None
|
||||
assert spans[1].parent is None
|
||||
|
||||
|
||||
def test_publish_structured_span_omits_empty_tffb_attribute(monkeypatch) -> None:
|
||||
exporter = _install_memory_tracer(monkeypatch)
|
||||
|
||||
structured_otlp.publish_structured_span(
|
||||
{
|
||||
"tipo_evento": "envio msg",
|
||||
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440101",
|
||||
"latencia_TFFB_STT_TTS": "",
|
||||
}
|
||||
)
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
assert "latencia_TFFB_STT_TTS" not in spans[0].attributes
|
||||
|
||||
|
||||
def test_publish_structured_span_skips_invalid_session_id(monkeypatch) -> None:
|
||||
exporter = _install_memory_tracer(monkeypatch)
|
||||
|
||||
structured_otlp.publish_structured_span(
|
||||
{
|
||||
"tipo_evento": "recebimento msg",
|
||||
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
|
||||
"session_id": "sess-001",
|
||||
}
|
||||
)
|
||||
|
||||
assert exporter.get_finished_spans() == ()
|
||||
|
||||
|
||||
def test_publish_structured_span_skips_without_otlp_endpoint(monkeypatch) -> None:
|
||||
monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", raising=False)
|
||||
monkeypatch.setattr(structured_otlp, "_TRACER", None)
|
||||
monkeypatch.setattr(structured_otlp, "_TRACER_ENDPOINT", "")
|
||||
|
||||
structured_otlp.publish_structured_span(
|
||||
{
|
||||
"tipo_evento": "recebimento msg",
|
||||
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440101",
|
||||
}
|
||||
)
|
||||
85
tests/utils/test_structured_otlp.py
Normal file
85
tests/utils/test_structured_otlp.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from opentelemetry.sdk.trace.export import SpanExportResult
|
||||
|
||||
import app.utils.structured_otlp as structured_otlp
|
||||
|
||||
|
||||
class _RecordingExporter:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.exported: list[int] = []
|
||||
self.shutdown_calls = 0
|
||||
|
||||
def export(self, spans: object) -> SpanExportResult:
|
||||
self.exported.append(len(list(spans)))
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_calls += 1
|
||||
|
||||
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _reset_provider_without_shutdown() -> None:
|
||||
with structured_otlp._TRACER_LOCK:
|
||||
structured_otlp._TRACER = None
|
||||
structured_otlp._TRACER_PROVIDER = None
|
||||
structured_otlp._TRACER_ENDPOINT = ""
|
||||
|
||||
|
||||
def test_span_export_is_batched_not_synchronous(monkeypatch) -> None:
|
||||
old_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = "http://localhost:4318/v1/traces"
|
||||
exporter = _RecordingExporter()
|
||||
monkeypatch.setattr(
|
||||
structured_otlp,
|
||||
"OTLPSpanExporter",
|
||||
lambda *args, **kwargs: exporter,
|
||||
)
|
||||
_reset_provider_without_shutdown()
|
||||
try:
|
||||
structured_otlp.publish_structured_span(
|
||||
{
|
||||
"session_id": "0123456789abcdef0123456789abcdef",
|
||||
"tipo_evento": "audio_in",
|
||||
"dat_hora_inicio": "2026-07-24T12:00:00.000000Z",
|
||||
"dat_hora_fim": "2026-07-24T12:00:00.010000Z",
|
||||
}
|
||||
)
|
||||
|
||||
assert exporter.exported == []
|
||||
assert structured_otlp.force_flush_structured_otlp(timeout_millis=5_000)
|
||||
assert sum(exporter.exported) >= 1
|
||||
finally:
|
||||
structured_otlp.shutdown_structured_otlp()
|
||||
if old_endpoint is None:
|
||||
os.environ.pop("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", None)
|
||||
else:
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = old_endpoint
|
||||
|
||||
|
||||
def test_shutdown_flushes_provider_once(monkeypatch) -> None:
|
||||
old_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = "http://localhost:4318/v1/traces"
|
||||
exporter = _RecordingExporter()
|
||||
monkeypatch.setattr(
|
||||
structured_otlp,
|
||||
"OTLPSpanExporter",
|
||||
lambda *args, **kwargs: exporter,
|
||||
)
|
||||
_reset_provider_without_shutdown()
|
||||
try:
|
||||
assert structured_otlp._tracer() is not None
|
||||
structured_otlp.shutdown_structured_otlp()
|
||||
structured_otlp.shutdown_structured_otlp()
|
||||
assert exporter.shutdown_calls == 1
|
||||
assert structured_otlp._TRACER_PROVIDER is None
|
||||
finally:
|
||||
structured_otlp.shutdown_structured_otlp()
|
||||
if old_endpoint is None:
|
||||
os.environ.pop("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", None)
|
||||
else:
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = old_endpoint
|
||||
221
tests/utils/test_stt_audio_upload.py
Normal file
221
tests/utils/test_stt_audio_upload.py
Normal file
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime
|
||||
|
||||
from app.utils import stt_audio_upload
|
||||
from app.utils.stt_audio_upload import (
|
||||
build_stt_vad_object_name,
|
||||
enqueue_stt_vad_audio_upload,
|
||||
)
|
||||
|
||||
|
||||
_BUCKET_ENV_NAMES = (
|
||||
"OCI_AUTH_MODE",
|
||||
"BUCKET_USER_ID",
|
||||
"BUCKET_PRIVATE_KEY",
|
||||
"BUCKET_FINGERPRINT",
|
||||
"BUCKET_TENANCY_ID",
|
||||
"BUCKET_REGION",
|
||||
"BUCKET_NAME",
|
||||
"BUCKET_NAMESPACE",
|
||||
)
|
||||
|
||||
|
||||
def _clear_bucket_env(monkeypatch) -> None:
|
||||
for name in _BUCKET_ENV_NAMES:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
def _install_fake_oci_modules(monkeypatch) -> dict[str, object]:
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
class FakeObjectStorageClient:
|
||||
def __init__(self, config, **kwargs) -> None:
|
||||
calls["client_config"] = config
|
||||
calls["client_kwargs"] = kwargs
|
||||
|
||||
def fake_oke_signer() -> object:
|
||||
signer = object()
|
||||
calls["signer"] = signer
|
||||
return signer
|
||||
|
||||
fake_oci = types.ModuleType("oci")
|
||||
fake_auth = types.ModuleType("oci.auth")
|
||||
fake_signers = types.ModuleType("oci.auth.signers")
|
||||
fake_object_storage = types.ModuleType("oci.object_storage")
|
||||
|
||||
fake_object_storage.ObjectStorageClient = FakeObjectStorageClient
|
||||
fake_signers.get_oke_workload_identity_resource_principal_signer = fake_oke_signer
|
||||
fake_auth.signers = fake_signers
|
||||
fake_oci.auth = fake_auth
|
||||
fake_oci.object_storage = fake_object_storage
|
||||
|
||||
monkeypatch.setitem(sys.modules, "oci", fake_oci)
|
||||
monkeypatch.setitem(sys.modules, "oci.auth", fake_auth)
|
||||
monkeypatch.setitem(sys.modules, "oci.auth.signers", fake_signers)
|
||||
monkeypatch.setitem(sys.modules, "oci.object_storage", fake_object_storage)
|
||||
return calls
|
||||
|
||||
|
||||
def _reset_oci_client_cache(monkeypatch) -> None:
|
||||
monkeypatch.setattr(stt_audio_upload, "_OCI_CLIENT", None)
|
||||
monkeypatch.setattr(stt_audio_upload, "_OCI_CLIENT_CONFIG", None)
|
||||
|
||||
|
||||
def test_stt_vad_object_name_uses_requested_layout_and_stays_short() -> None:
|
||||
object_name = build_stt_vad_object_name(
|
||||
session_id="sessao muito longa/" * 30,
|
||||
message_id="message-id-muito-longo/" * 30,
|
||||
req_id="req-id-muito-longo/" * 30,
|
||||
now=datetime(2026, 6, 24, 13, 52, 21),
|
||||
)
|
||||
|
||||
parts = object_name.split("/")
|
||||
assert parts[0] == "2026-06-24"
|
||||
assert object_name.endswith(".wav")
|
||||
assert len(parts) == 3
|
||||
assert len(object_name.encode("utf-8")) <= 512
|
||||
assert all(len(part) <= 68 for part in parts[1:])
|
||||
assert " " not in object_name
|
||||
|
||||
|
||||
def test_stt_vad_object_name_uses_message_id_as_filename() -> None:
|
||||
object_name = build_stt_vad_object_name(
|
||||
session_id="417a5a59-961a-4ba6-9c68-71e92a3da46d",
|
||||
message_id="0bc6875e-ebd1-4dca-a43d-2f7de404895b",
|
||||
req_id="c182775c8c114e3f833bfb7675e0be70",
|
||||
now=datetime(2026, 6, 29, 10, 30, 0),
|
||||
)
|
||||
|
||||
assert (
|
||||
object_name
|
||||
== "2026-06-29/417a5a59-961a-4ba6-9c68-71e92a3da46d/"
|
||||
"0bc6875e-ebd1-4dca-a43d-2f7de404895b.wav"
|
||||
)
|
||||
assert "c182775c8c114e3f833bfb7675e0be70" not in object_name
|
||||
|
||||
|
||||
def test_oci_upload_config_reads_bucket_env_and_private_key(monkeypatch) -> None:
|
||||
_clear_bucket_env(monkeypatch)
|
||||
monkeypatch.setenv("OCI_AUTH_MODE", "local")
|
||||
monkeypatch.setenv("BUCKET_USER_ID", "user-ocid")
|
||||
monkeypatch.setenv("BUCKET_PRIVATE_KEY", "-----BEGIN KEY-----\\nabc\\n-----END KEY-----")
|
||||
monkeypatch.setenv("BUCKET_FINGERPRINT", "fingerprint")
|
||||
monkeypatch.setenv("BUCKET_TENANCY_ID", "tenancy-ocid")
|
||||
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
|
||||
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
|
||||
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
|
||||
|
||||
config = stt_audio_upload._oci_upload_config_from_env()
|
||||
|
||||
assert config is not None
|
||||
assert config.auth_mode == "local"
|
||||
assert config.bucket == "tia-audio"
|
||||
assert config.namespace == "namespace"
|
||||
assert config.key_content == "-----BEGIN KEY-----\nabc\n-----END KEY-----"
|
||||
|
||||
|
||||
def test_oci_upload_config_uses_oke_when_auth_mode_is_absent(monkeypatch) -> None:
|
||||
_clear_bucket_env(monkeypatch)
|
||||
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
|
||||
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
|
||||
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
|
||||
|
||||
config = stt_audio_upload._oci_upload_config_from_env()
|
||||
|
||||
assert config is not None
|
||||
assert config.auth_mode == "oke_workload_identity"
|
||||
assert config.bucket == "tia-audio"
|
||||
assert config.namespace == "namespace"
|
||||
assert config.user == ""
|
||||
assert config.key_content == ""
|
||||
|
||||
|
||||
def test_oci_upload_config_uses_oke_when_auth_mode_is_not_local(monkeypatch) -> None:
|
||||
_clear_bucket_env(monkeypatch)
|
||||
monkeypatch.setenv("OCI_AUTH_MODE", "prd")
|
||||
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
|
||||
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
|
||||
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
|
||||
|
||||
config = stt_audio_upload._oci_upload_config_from_env()
|
||||
|
||||
assert config is not None
|
||||
assert config.auth_mode == "oke_workload_identity"
|
||||
|
||||
|
||||
def test_oci_upload_config_is_missing_when_local_required_env_is_absent(monkeypatch) -> None:
|
||||
_clear_bucket_env(monkeypatch)
|
||||
monkeypatch.setenv("OCI_AUTH_MODE", "local")
|
||||
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
|
||||
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
|
||||
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
|
||||
|
||||
assert stt_audio_upload._oci_upload_config_from_env() is None
|
||||
|
||||
|
||||
def test_oci_upload_config_is_missing_when_oke_required_env_is_absent(monkeypatch) -> None:
|
||||
_clear_bucket_env(monkeypatch)
|
||||
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
|
||||
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
|
||||
|
||||
assert stt_audio_upload._oci_upload_config_from_env() is None
|
||||
|
||||
|
||||
def test_oci_client_uses_api_key_config_for_local_mode(monkeypatch) -> None:
|
||||
_reset_oci_client_cache(monkeypatch)
|
||||
calls = _install_fake_oci_modules(monkeypatch)
|
||||
config = stt_audio_upload.OCIUploadConfig(
|
||||
auth_mode="local",
|
||||
region="sa-saopaulo-1",
|
||||
bucket="tia-audio",
|
||||
namespace="namespace",
|
||||
user="user-ocid",
|
||||
key_content="private-key",
|
||||
fingerprint="fingerprint",
|
||||
tenancy="tenancy-ocid",
|
||||
)
|
||||
|
||||
stt_audio_upload._oci_client(config)
|
||||
|
||||
assert calls["client_config"] == {
|
||||
"user": "user-ocid",
|
||||
"key_content": "private-key",
|
||||
"fingerprint": "fingerprint",
|
||||
"tenancy": "tenancy-ocid",
|
||||
"region": "sa-saopaulo-1",
|
||||
}
|
||||
assert calls["client_kwargs"] == {}
|
||||
assert "signer" not in calls
|
||||
|
||||
|
||||
def test_oci_client_uses_oke_signer_for_workload_identity(monkeypatch) -> None:
|
||||
_reset_oci_client_cache(monkeypatch)
|
||||
calls = _install_fake_oci_modules(monkeypatch)
|
||||
config = stt_audio_upload.OCIUploadConfig(
|
||||
auth_mode="oke_workload_identity",
|
||||
region="sa-saopaulo-1",
|
||||
bucket="tia-audio",
|
||||
namespace="namespace",
|
||||
)
|
||||
|
||||
stt_audio_upload._oci_client(config)
|
||||
|
||||
assert calls["client_config"] == {"region": "sa-saopaulo-1"}
|
||||
assert calls["client_kwargs"] == {"signer": calls["signer"]}
|
||||
|
||||
|
||||
def test_enqueue_stt_vad_audio_upload_is_noop_without_oci_config(monkeypatch) -> None:
|
||||
monkeypatch.setattr(stt_audio_upload, "_oci_upload_config_from_env", lambda: None)
|
||||
|
||||
assert (
|
||||
enqueue_stt_vad_audio_upload(
|
||||
wav_bytes=b"fake-wav",
|
||||
req_id="req-1",
|
||||
message_id="message-1",
|
||||
structured_log_context={"session_id": "session-1"},
|
||||
)
|
||||
is None
|
||||
)
|
||||
56
tests/utils/test_turn_ids.py
Normal file
56
tests/utils/test_turn_ids.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from app.utils.turn_ids import (
|
||||
clear_started_turn_message_id,
|
||||
next_turn_message_id,
|
||||
peek_started_turn_message_id,
|
||||
register_started_turn_message_id,
|
||||
reset_turn_message_sequence,
|
||||
)
|
||||
|
||||
|
||||
def test_next_turn_message_id_uses_uuid4() -> None:
|
||||
generated = uuid.UUID("12345678-1234-4234-9234-123456789abc")
|
||||
|
||||
with mock.patch("app.utils.turn_ids.uuid.uuid4", return_value=generated):
|
||||
message_id = next_turn_message_id(
|
||||
{
|
||||
"callIdGed": "GED-555",
|
||||
"protocol_id": "PRT-555",
|
||||
"session_id": "session-555",
|
||||
}
|
||||
)
|
||||
|
||||
assert message_id == "12345678-1234-4234-9234-123456789abc"
|
||||
|
||||
|
||||
def test_started_turn_message_id_can_be_registered_peeked_and_cleared() -> None:
|
||||
source = {"session_id": "session-started-turn"}
|
||||
reset_turn_message_sequence(source, clear_pending=True)
|
||||
|
||||
assert peek_started_turn_message_id(source) == ""
|
||||
|
||||
register_started_turn_message_id(source, "message-1")
|
||||
assert peek_started_turn_message_id(source) == "message-1"
|
||||
|
||||
register_started_turn_message_id(source, "message-2")
|
||||
assert peek_started_turn_message_id(source) == "message-2"
|
||||
|
||||
clear_started_turn_message_id(source, "message-1")
|
||||
assert peek_started_turn_message_id(source) == "message-2"
|
||||
|
||||
clear_started_turn_message_id(source, "message-2")
|
||||
assert peek_started_turn_message_id(source) == ""
|
||||
|
||||
|
||||
def test_reset_turn_message_sequence_clears_started_turn_message_id() -> None:
|
||||
source = {"session_id": "session-reset-started-turn"}
|
||||
reset_turn_message_sequence(source, clear_pending=True)
|
||||
register_started_turn_message_id(source, "message-to-reset")
|
||||
|
||||
reset_turn_message_sequence(source, clear_pending=True)
|
||||
|
||||
assert peek_started_turn_message_id(source) == ""
|
||||
Reference in New Issue
Block a user