This commit is contained in:
Daniil
2026-04-07 13:42:45 +03:00
parent 7d2f444e1c
commit 259d3da89f
34 changed files with 2130 additions and 788 deletions
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
import asyncio
from itertools import count
from types import SimpleNamespace
import uuid
from cpv3.modules.media import service as media_service
from cpv3.modules.tasks import service as task_service
def test_parse_ffmpeg_progress_time_seconds_from_timecode() -> None:
assert media_service._get_ffmpeg_output_time_seconds(
{
"out_time": "00:00:12.500000",
"progress": "continue",
}
) == 12.5
def test_parse_ffmpeg_progress_time_seconds_returns_none_for_invalid_snapshot() -> None:
assert media_service._get_ffmpeg_output_time_seconds(
{
"out_time": "not-a-timecode",
"progress": "continue",
}
) is None
def test_media_convert_actor_emits_intermediate_progress_events(monkeypatch) -> None:
sent_events: list[task_service.TaskWebhookEvent] = []
monotonic_values = count(step=2)
async def fake_convert_to_mp4(
_storage: object,
*,
file_key: str,
out_folder: str,
on_progress: object | None = None,
) -> SimpleNamespace:
assert file_key == "uploads/source.mov"
assert out_folder == "projects/1"
assert callable(on_progress)
on_progress("converting", 0.0)
on_progress("converting", 50.0)
on_progress("converting", 100.0)
on_progress("uploading", None)
return SimpleNamespace(
file_path="projects/1/converted/video.mp4",
file_url="https://example.com/video.mp4",
file_size=123,
)
monkeypatch.setattr(task_service, "_run_async", asyncio.run)
monkeypatch.setattr(task_service, "_raise_if_job_cancelled", lambda _job_id: None)
monkeypatch.setattr(task_service, "_get_storage_service", lambda: object())
monkeypatch.setattr(
task_service,
"_send_webhook_event",
lambda _url, event: sent_events.append(event),
)
monkeypatch.setattr(
task_service.time,
"monotonic",
lambda: float(next(monotonic_values)),
)
monkeypatch.setattr(media_service, "convert_to_mp4", fake_convert_to_mp4)
task_service.media_convert_actor.fn(
job_id=str(uuid.uuid4()),
webhook_url="http://backend.test/api/tasks/webhook/job-1/",
file_key="uploads/source.mov",
out_folder="projects/1",
output_format="mp4",
)
progress_events = [event for event in sent_events if event.progress_pct is not None]
assert [event.progress_pct for event in progress_events] == [
5.0,
10.0,
52.5,
95.0,
99.0,
100.0,
]
assert [event.current_message for event in progress_events] == [
"Подготовка файла",
"Конвертация видео",
"Конвертация видео",
"Загрузка результата",
"Сохранение результата",
"Завершено",
]
assert sent_events[-1].status == task_service.JOB_STATUS_DONE
assert sent_events[-1].output_data == {
"file_path": "projects/1/converted/video.mp4",
"file_url": "https://example.com/video.mp4",
"file_size": 123,
}
def test_silence_apply_actor_emits_intermediate_progress_events(monkeypatch) -> None:
sent_events: list[task_service.TaskWebhookEvent] = []
monotonic_values = count(step=2)
async def fake_apply_silence_cuts(
_storage: object,
*,
file_key: str,
out_folder: str,
cuts: list[dict],
output_name: str | None = None,
on_progress: object | None = None,
) -> SimpleNamespace:
assert file_key == "uploads/source.mp4"
assert out_folder == "projects/1"
assert cuts == [{"start_ms": 100, "end_ms": 200}]
assert output_name == "edited.mp4"
assert callable(on_progress)
on_progress("applying_cuts", 0.0)
on_progress("applying_cuts", 50.0)
on_progress("applying_cuts", 100.0)
on_progress("uploading", None)
return SimpleNamespace(
file_path="projects/1/silent/edited.mp4",
file_url="https://example.com/edited.mp4",
file_size=456,
)
monkeypatch.setattr(task_service, "_run_async", asyncio.run)
monkeypatch.setattr(task_service, "_raise_if_job_cancelled", lambda _job_id: None)
monkeypatch.setattr(task_service, "_get_storage_service", lambda: object())
monkeypatch.setattr(
task_service,
"_send_webhook_event",
lambda _url, event: sent_events.append(event),
)
monkeypatch.setattr(
task_service.time,
"monotonic",
lambda: float(next(monotonic_values)),
)
monkeypatch.setattr(media_service, "apply_silence_cuts", fake_apply_silence_cuts)
task_service.silence_apply_actor.fn(
job_id=str(uuid.uuid4()),
webhook_url="http://backend.test/api/tasks/webhook/job-2/",
file_key="uploads/source.mp4",
out_folder="projects/1",
cuts=[{"start_ms": 100, "end_ms": 200}],
output_name="edited.mp4",
)
progress_events = [event for event in sent_events if event.progress_pct is not None]
assert [event.progress_pct for event in progress_events] == [
5.0,
10.0,
52.5,
95.0,
99.0,
100.0,
]
assert [event.current_message for event in progress_events] == [
"Подготовка файла",
"Применение вырезок",
"Применение вырезок",
"Загрузка результата",
"Сохранение результата",
"Завершено",
]
assert sent_events[-1].status == task_service.JOB_STATUS_DONE
assert sent_events[-1].output_data == {
"file_path": "projects/1/silent/edited.mp4",
"file_url": "https://example.com/edited.mp4",
"file_size": 456,
}
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
import asyncio
import uuid
from types import SimpleNamespace
from cpv3.infrastructure.storage.types import FileInfo
from cpv3.modules.media import service as media_service
from cpv3.modules.tasks import service as task_service
def test_extract_ffmpeg_out_time_ms_prefers_out_time_us() -> None:
assert media_service._extract_ffmpeg_out_time_ms(
{
"out_time_us": "2500000",
"out_time_ms": "1000000",
}
) == 2500.0
def test_extract_ffmpeg_out_time_ms_supports_legacy_out_time_ms_field() -> None:
assert media_service._extract_ffmpeg_out_time_ms(
{
"out_time_ms": "1250000",
}
) == 1250.0
def test_extract_ffmpeg_out_time_ms_returns_none_for_invalid_values() -> None:
assert media_service._extract_ffmpeg_out_time_ms(
{
"out_time_us": "N/A",
}
) is None
def test_media_convert_actor_emits_precise_progress_updates(monkeypatch) -> None:
sent_events: list[task_service.TaskWebhookEvent] = []
async def fake_convert_to_mp4(
_storage: object,
*,
file_key: str,
out_folder: str,
on_progress,
) -> FileInfo:
assert file_key == "uploads/source.mov"
assert out_folder == "projects/1"
on_progress("converting", 0.0)
on_progress("converting", 50.0)
on_progress("uploading", None)
return FileInfo(
file_path="projects/1/converted/output.mp4",
file_url="https://example.com/output.mp4",
file_size=1234,
filename="output.mp4",
)
monkeypatch.setattr(media_service, "convert_to_mp4", fake_convert_to_mp4)
monkeypatch.setattr(task_service, "_run_async", asyncio.run)
monkeypatch.setattr(task_service, "_raise_if_job_cancelled", lambda _job_id: None)
monkeypatch.setattr(task_service, "_get_storage_service", lambda: object())
monkeypatch.setattr(task_service, "PROGRESS_CONVERT_THROTTLE_SECONDS", 0.0)
monkeypatch.setattr(
task_service,
"_send_webhook_event",
lambda _url, event: sent_events.append(event),
)
task_service.media_convert_actor.fn(
job_id=str(uuid.uuid4()),
webhook_url="http://backend.test/api/tasks/webhook/job-1/",
file_key="uploads/source.mov",
out_folder="projects/1",
output_format="mp4",
)
progress_events = [
(event.progress_pct, event.current_message)
for event in sent_events
if event.progress_pct is not None
]
assert progress_events == [
(5.0, "Подготовка файла"),
(10.0, "Конвертация видео"),
(52.5, "Конвертация видео"),
(95.0, "Загрузка результата"),
(99.0, "Сохранение результата"),
(100.0, "Завершено"),
]
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from cpv3.modules.media.service import apply_silence_cuts, remove_silence
class _TempDownload:
def __init__(self, path: str) -> None:
self.path = path
def cleanup(self) -> None:
return None
class _FakeProcess:
returncode = 0
async def communicate(self) -> tuple[bytes, bytes]:
return b"", b""
@pytest.mark.asyncio
async def test_remove_silence_uses_single_input_trim_filter(tmp_path) -> None:
input_path = tmp_path / "input.mp4"
input_path.write_bytes(b"video")
file_info = SimpleNamespace(
file_path="uploads/test-file.txt",
file_url="http://example.com/uploads/test-file.txt",
file_size=1024,
filename="test-file.txt",
)
storage = SimpleNamespace(
download_to_temp=AsyncMock(return_value=_TempDownload(str(input_path))),
upload_fileobj=AsyncMock(return_value="uploads/test-file.txt"),
get_file_info=AsyncMock(return_value=file_info),
)
captured: dict[str, tuple[str, ...]] = {}
async def fake_create_subprocess_exec(
*cmd: str, stdout=None, stderr=None
) -> _FakeProcess:
captured["cmd"] = cmd
return _FakeProcess()
with (
patch(
"cpv3.modules.media.service._compute_non_silent_segments",
return_value=[(0, 1000), (2000, 3000)],
),
patch(
"cpv3.modules.media.service.asyncio.create_subprocess_exec",
side_effect=fake_create_subprocess_exec,
),
):
await remove_silence(
storage,
file_key="uploads/input.mp4",
out_folder="processed",
)
cmd = list(captured["cmd"])
assert cmd.count("-i") == 1
filter_complex = cmd[cmd.index("-filter_complex") + 1]
assert "trim=start=0.000:end=1.000" in filter_complex
assert "atrim=start=0.000:end=1.000" in filter_complex
assert "trim=start=2.000:end=3.000" in filter_complex
assert "atrim=start=2.000:end=3.000" in filter_complex
async def _run_sync_immediately(func):
return func()
@pytest.mark.asyncio
async def test_apply_silence_cuts_uses_single_input_trim_filter(tmp_path) -> None:
input_path = tmp_path / "input.mp4"
input_path.write_bytes(b"video")
file_info = SimpleNamespace(
file_path="uploads/test-file.txt",
file_url="http://example.com/uploads/test-file.txt",
file_size=1024,
filename="test-file.txt",
)
storage = SimpleNamespace(
download_to_temp=AsyncMock(return_value=_TempDownload(str(input_path))),
upload_fileobj=AsyncMock(return_value="uploads/test-file.txt"),
get_file_info=AsyncMock(return_value=file_info),
)
captured: dict[str, tuple[str, ...]] = {}
async def fake_create_subprocess_exec(
*cmd: str, stdout=None, stderr=None
) -> _FakeProcess:
captured["cmd"] = cmd
return _FakeProcess()
fake_audio = MagicMock()
fake_audio.__len__.return_value = 5000
with (
patch(
"cpv3.modules.media.service.anyio.to_thread.run_sync",
side_effect=_run_sync_immediately,
),
patch("pydub.AudioSegment.from_file", return_value=fake_audio),
patch(
"cpv3.modules.media.service.asyncio.create_subprocess_exec",
side_effect=fake_create_subprocess_exec,
),
):
await apply_silence_cuts(
storage,
file_key="uploads/input.mp4",
out_folder="processed",
cuts=[
{"start_ms": 1000, "end_ms": 2000},
{"start_ms": 3000, "end_ms": 3500},
],
)
cmd = list(captured["cmd"])
assert cmd.count("-i") == 1
filter_complex = cmd[cmd.index("-filter_complex") + 1]
assert "trim=start=0.000:end=1.000" in filter_complex
assert "atrim=start=0.000:end=1.000" in filter_complex
assert "trim=start=2.000:end=3.000" in filter_complex
assert "atrim=start=3.500:end=5.000" in filter_complex
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from cpv3.modules.notifications.service import NotificationService
from cpv3.modules.tasks.schemas import TaskWebhookEvent
@pytest.mark.asyncio
async def test_create_task_notification_persists_operation_title() -> None:
service = NotificationService(session=AsyncMock())
service._repo = SimpleNamespace(
create=AsyncMock(return_value=SimpleNamespace(id=uuid.uuid4()))
)
job = SimpleNamespace(
id=uuid.uuid4(),
project_id=uuid.uuid4(),
job_type="SILENCE_APPLY",
status="DONE",
project_pct=100,
current_message="Завершено",
)
event = TaskWebhookEvent(
status="DONE",
progress_pct=100,
current_message="Завершено",
)
publish_mock = AsyncMock()
from cpv3.modules import notifications as notifications_module
original_publish = notifications_module.service.publish_to_user
notifications_module.service.publish_to_user = publish_mock
try:
await service.create_task_notification(
user_id=uuid.uuid4(),
job=job,
event=event,
)
finally:
notifications_module.service.publish_to_user = original_publish
create_call = service._repo.create.await_args
notification = create_call.args[0]
assert notification.title == "Применение вырезок"
assert notification.message == "Завершено"
assert notification.payload == {
"job_type": "SILENCE_APPLY",
"progress_pct": 100,
"status": "DONE",
}
publish_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_task_notification_preserves_zero_progress_for_websocket() -> None:
service = NotificationService(session=AsyncMock())
service._repo = SimpleNamespace(
create=AsyncMock(return_value=SimpleNamespace(id=uuid.uuid4()))
)
job = SimpleNamespace(
id=uuid.uuid4(),
project_id=uuid.uuid4(),
job_type="MEDIA_CONVERT",
status="RUNNING",
project_pct=35.0,
current_message="Конвертация видео",
)
event = TaskWebhookEvent(
progress_pct=0.0,
current_message="Подготовка файла",
)
publish_mock = AsyncMock()
from cpv3.modules import notifications as notifications_module
original_publish = notifications_module.service.publish_to_user
notifications_module.service.publish_to_user = publish_mock
try:
await service.create_task_notification(
user_id=uuid.uuid4(),
job=job,
event=event,
)
finally:
notifications_module.service.publish_to_user = original_publish
published_message = publish_mock.await_args.args[1]
assert published_message.progress_pct == 0.0
assert published_message.message == "Подготовка файла"
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from cpv3.modules.transcription import service as transcription_service
class _FakeSSLContext:
def __init__(self) -> None:
self.loaded_cafile: str | None = None
def load_verify_locations(self, *, cafile: str) -> None:
self.loaded_cafile = cafile
def test_build_salute_ssl_context_uses_default_context(monkeypatch) -> None:
fake_context = _FakeSSLContext()
monkeypatch.setattr(
transcription_service,
"get_settings",
lambda: SimpleNamespace(
salute_ssl_verify=True,
salute_ca_cert_path=None,
),
)
monkeypatch.setattr(
transcription_service.ssl,
"create_default_context",
lambda: fake_context,
)
ssl_context = transcription_service._build_salute_ssl_context()
assert ssl_context is fake_context
assert fake_context.loaded_cafile is None
def test_build_salute_ssl_context_loads_custom_ca(monkeypatch) -> None:
fake_context = _FakeSSLContext()
custom_ca_path = Path("/tmp/salute-ca.pem")
monkeypatch.setattr(
transcription_service,
"get_settings",
lambda: SimpleNamespace(
salute_ssl_verify=True,
salute_ca_cert_path=custom_ca_path,
),
)
monkeypatch.setattr(
transcription_service.ssl,
"create_default_context",
lambda: fake_context,
)
ssl_context = transcription_service._build_salute_ssl_context()
assert ssl_context is fake_context
assert fake_context.loaded_cafile == str(custom_ca_path)
def test_build_salute_ssl_context_disables_verification(monkeypatch) -> None:
unverified_context = object()
monkeypatch.setattr(
transcription_service,
"get_settings",
lambda: SimpleNamespace(
salute_ssl_verify=False,
salute_ca_cert_path=None,
),
)
monkeypatch.setattr(
transcription_service.ssl,
"_create_unverified_context",
lambda: unverified_context,
)
ssl_context = transcription_service._build_salute_ssl_context()
assert ssl_context is unverified_context
def test_get_salute_auth_header_value_returns_basic_header(monkeypatch) -> None:
monkeypatch.setattr(
transcription_service,
"get_settings",
lambda: SimpleNamespace(
salute_auth_key=" encoded-credentials ",
),
)
header_value = transcription_service._get_salute_auth_header_value()
assert header_value == "Basic encoded-credentials"
def test_get_salute_auth_header_value_raises_when_missing(monkeypatch) -> None:
monkeypatch.setattr(
transcription_service,
"get_settings",
lambda: SimpleNamespace(
salute_auth_key=" ",
),
)
try:
transcription_service._get_salute_auth_header_value()
except RuntimeError as exc:
assert str(exc) == transcription_service.ERROR_SALUTE_AUTH_KEY_MISSING
else:
raise AssertionError("Expected RuntimeError for missing SaluteSpeech auth key")
+37
View File
@@ -106,3 +106,40 @@ async def test_cancel_job_marks_job_cancelled_and_keeps_record() -> None:
service._create_cancellation_notification.assert_awaited_once_with(
cancelled_job
)
@pytest.mark.asyncio
async def test_record_webhook_event_updates_progress_for_conversion_job() -> None:
job = SimpleNamespace(
id=uuid.uuid4(),
status="RUNNING",
job_type="MEDIA_CONVERT",
user_id=None,
)
updated_job = SimpleNamespace(**job.__dict__, project_pct=52.5)
job_repo = SimpleNamespace(
get_by_id=AsyncMock(return_value=job),
update=AsyncMock(return_value=updated_job),
)
event_repo = SimpleNamespace(create=AsyncMock())
service = TaskService(session=AsyncMock())
service._job_repo = job_repo
service._event_repo = event_repo
result = await service.record_webhook_event(
job_id=job.id,
event=TaskWebhookEvent(
progress_pct=52.5,
current_message="Конвертация видео",
),
)
update_call = job_repo.update.await_args.args[1]
event_call = event_repo.create.await_args.args[0]
assert result is updated_job
assert update_call.project_pct == 52.5
assert update_call.current_message == "Конвертация видео"
assert event_call.event_type == "progress"
assert event_call.payload["progress_pct"] == 52.5