from __future__ import annotations import json import tempfile import unittest from pathlib import Path from biliup_next.app.serializers import ControlPlaneSerializer from biliup_next.core.models import ActionRecord, Artifact, Task, TaskContext, TaskStep class FakeSerializerRepo: def __init__( self, *, task: Task, context: TaskContext | None = None, steps: list[TaskStep] | None = None, artifacts: list[Artifact] | None = None, actions: list[ActionRecord] | None = None, ) -> None: self.task = task self.context = context self.steps = steps or [] self.artifacts = artifacts or [] self.actions = actions or [] def get_task(self, task_id: str) -> Task | None: return self.task if task_id == self.task.id else None def get_task_context(self, task_id: str) -> TaskContext | None: return self.context if task_id == self.task.id else None def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]: if self.context and self.context.task_id in task_ids: return {self.context.task_id: self.context} return {} def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[TaskStep]]: if self.task.id in task_ids: return {self.task.id: list(self.steps)} return {} def list_steps(self, task_id: str) -> list[TaskStep]: return list(self.steps) if task_id == self.task.id else [] def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: if self.context and self.context.session_key == session_key: return [self.context] return [] def list_artifacts(self, task_id: str) -> list[Artifact]: return list(self.artifacts) if task_id == self.task.id else [] def list_action_records(self, task_id: str, limit: int = 200) -> list[ActionRecord]: return list(self.actions)[:limit] if task_id == self.task.id else [] class SerializerTests(unittest.TestCase): def test_task_payload_includes_context_retry_and_delivery_state(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: task = Task("task-1", "local_file", str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), "task-title", "running", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00") session_dir = Path(tmpdir) / "session" / "task-title" session_dir.mkdir(parents=True, exist_ok=True) (session_dir / "full_video_bvid.txt").write_text("BVFULL123", encoding="utf-8") (session_dir / "bvid.txt").write_text("BVSPLIT123", encoding="utf-8") steps = [ TaskStep(None, "task-1", "publish", "failed_retryable", "ERR", "upload failed", 1, None, "2099-01-01T00:00:00+00:00"), ] context = TaskContext( id=None, task_id="task-1", session_key="session-1", streamer="streamer", room_id="room", source_title="task-title", segment_started_at=None, segment_duration_seconds=None, full_video_bvid=None, created_at="2026-01-01T00:00:00+00:00", updated_at="2026-01-01T00:00:00+00:00", ) repo = FakeSerializerRepo(task=task, context=context, steps=steps) state = { "repo": repo, "settings": { "paths": {"session_dir": str(Path(tmpdir) / "session")}, "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, "cleanup": {}, "publish": {"retry_schedule_minutes": [10]}, }, } payload = ControlPlaneSerializer(state).task_payload("task-1") self.assertIsNotNone(payload) self.assertEqual(payload["session_context"]["session_key"], "session-1") self.assertEqual(payload["session_context"]["full_video_bvid"], "BVFULL123") self.assertEqual(payload["retry_state"]["step_name"], "publish") self.assertEqual(payload["delivery_state"]["split_comment"], "pending") def test_session_payload_reuses_task_payload_serialization(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: task = Task("task-1", "local_file", str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00") context = TaskContext( id=None, task_id="task-1", session_key="session-1", streamer="streamer", room_id="room", source_title="task-title", segment_started_at=None, segment_duration_seconds=None, full_video_bvid="BVFULL123", created_at="2026-01-01T00:00:00+00:00", updated_at="2026-01-01T00:00:00+00:00", ) repo = FakeSerializerRepo(task=task, context=context) state = { "repo": repo, "settings": { "paths": {"session_dir": str(Path(tmpdir) / "session")}, "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, "cleanup": {}, "publish": {}, }, } payload = ControlPlaneSerializer(state).session_payload("session-1") self.assertIsNotNone(payload) self.assertEqual(payload["session_key"], "session-1") self.assertEqual(payload["task_count"], 1) self.assertEqual(payload["full_video_url"], "https://www.bilibili.com/video/BVFULL123") self.assertEqual(payload["tasks"][0]["id"], "task-1") def test_timeline_payload_includes_task_step_artifact_and_action_entries(self) -> None: task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:02:00+00:00") steps = [ TaskStep(None, "task-1", "comment", "succeeded", None, None, 0, "2026-01-01T00:01:00+00:00", "2026-01-01T00:01:30+00:00"), ] artifacts = [ Artifact(None, "task-1", "publish_bvid", "/tmp/bvid.txt", "{}", "2026-01-01T00:01:40+00:00"), ] actions = [ ActionRecord( id=None, task_id="task-1", action_name="comment", status="ok", summary="comment succeeded", details_json=json.dumps({"split": {"status": "ok"}, "full": {"status": "skipped"}}), created_at="2026-01-01T00:01:50+00:00", ) ] repo = FakeSerializerRepo(task=task, steps=steps, artifacts=artifacts, actions=actions) state = { "repo": repo, "settings": { "paths": {"session_dir": "/tmp/session"}, "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, "cleanup": {}, "publish": {}, }, } payload = ControlPlaneSerializer(state).timeline_payload("task-1") self.assertIsNotNone(payload) action_item = next(item for item in payload["items"] if item["kind"] == "action") self.assertIn("split=ok", action_item["summary"]) kinds = {item["kind"] for item in payload["items"]} self.assertTrue({"task", "step", "artifact", "action"}.issubset(kinds)) if __name__ == "__main__": unittest.main()