from __future__ import annotations import io import tempfile import unittest from http import HTTPStatus from pathlib import Path from types import SimpleNamespace from biliup_next.app.control_plane_post_dispatcher import ControlPlanePostDispatcher from biliup_next.core.models import Task class FakeRepo: def __init__(self) -> None: self.actions = [] def add_action_record(self, action) -> None: # type: ignore[no-untyped-def] self.actions.append(action) class ModuleError(Exception): def to_dict(self) -> dict[str, object]: return {"error": "conflict"} class ControlPlanePostDispatcherTests(unittest.TestCase): def _dispatcher(self, tmpdir: str, repo: FakeRepo, *, ingest_service: object | None = None) -> ControlPlanePostDispatcher: state = { "repo": repo, "root": Path(tmpdir), "settings": { "paths": {"stage_dir": str(Path(tmpdir) / "stage"), "session_dir": str(Path(tmpdir) / "session")}, "ingest": {"stage_min_free_space_mb": 100}, }, "ingest_service": ingest_service or SimpleNamespace( create_task_from_file=lambda path, settings: Task( "task-1", "local_file", str(path), "task-title", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00", ) ), } return ControlPlanePostDispatcher( state, bind_full_video_action=lambda task_id, bvid: {"task_id": task_id, "full_video_bvid": bvid}, merge_session_action=lambda session_key, task_ids: {"session_key": session_key, "task_ids": task_ids}, receive_full_video_webhook=lambda payload: {"ok": True, **payload}, rebind_session_full_video_action=lambda session_key, bvid: {"session_key": session_key, "full_video_bvid": bvid}, reset_to_step_action=lambda task_id, step_name: {"task_id": task_id, "step_name": step_name}, retry_step_action=lambda task_id, step_name: {"task_id": task_id, "step_name": step_name}, run_task_action=lambda task_id: {"task_id": task_id}, run_once=lambda: {"scheduler": {"scan_count": 1}, "worker": {"picked": 1}}, stage_importer_factory=lambda: SimpleNamespace( import_file=lambda source, dest, min_free_bytes=0: {"imported_to": str(dest / source.name)}, import_upload=lambda filename, fileobj, dest, min_free_bytes=0: {"filename": filename, "dest": str(dest)}, ), systemd_runtime_factory=lambda: SimpleNamespace(act=lambda service, action: {"service": service, "action": action, "command_ok": True}), ) def test_handle_bind_full_video_maps_missing_bvid(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: dispatcher = self._dispatcher(tmpdir, FakeRepo()) body, status = dispatcher.handle_bind_full_video("task-1", {}) self.assertEqual(status, HTTPStatus.BAD_REQUEST) self.assertEqual(body["error"], "missing full_video_bvid") def test_handle_worker_run_once_records_action(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: repo = FakeRepo() dispatcher = self._dispatcher(tmpdir, repo) body, status = dispatcher.handle_worker_run_once() self.assertEqual(status, HTTPStatus.ACCEPTED) self.assertEqual(body["worker"]["picked"], 1) self.assertEqual(repo.actions[-1].action_name, "worker_run_once") def test_handle_stage_upload_returns_created(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: dispatcher = self._dispatcher(tmpdir, FakeRepo()) file_item = SimpleNamespace(filename="incoming.mp4", file=io.BytesIO(b"video")) body, status = dispatcher.handle_stage_upload(file_item) self.assertEqual(status, HTTPStatus.CREATED) self.assertEqual(body["filename"], "incoming.mp4") def test_handle_create_task_maps_module_error_to_conflict(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: repo = FakeRepo() def raise_module_error(path, settings): # type: ignore[no-untyped-def] raise ModuleError() dispatcher = self._dispatcher( tmpdir, repo, ingest_service=SimpleNamespace(create_task_from_file=raise_module_error), ) body, status = dispatcher.handle_create_task({"source_path": str(Path(tmpdir) / "source.mp4")}) self.assertEqual(status, HTTPStatus.CONFLICT) self.assertEqual(body["error"], "conflict")