from __future__ import annotations import unittest from types import SimpleNamespace from unittest.mock import patch from biliup_next.core.errors import ModuleError from biliup_next.app.task_runner import process_task from biliup_next.core.models import TaskStep class FakeRunnerRepo: def __init__(self, task, steps: list[TaskStep]) -> None: # type: ignore[no-untyped-def] self.task = task self.steps = steps self.step_updates: list[tuple] = [] self.task_updates: list[tuple] = [] self.claims: list[tuple[str, str, str]] = [] def get_task(self, task_id: str): # type: ignore[no-untyped-def] return self.task if task_id == self.task.id else None def list_steps(self, task_id: str) -> list[TaskStep]: return list(self.steps) if task_id == self.task.id else [] def update_step_status(self, task_id: str, step_name: str, status: str, **kwargs) -> None: # type: ignore[no-untyped-def] self.step_updates.append((task_id, step_name, status, kwargs)) for index, step in enumerate(self.steps): if step.task_id == task_id and step.step_name == step_name: self.steps[index] = TaskStep( step.id, step.task_id, step.step_name, status, kwargs.get("error_code", step.error_code), kwargs.get("error_message", step.error_message), kwargs.get("retry_count", step.retry_count), kwargs.get("started_at", step.started_at), kwargs.get("finished_at", step.finished_at), ) def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: self.task_updates.append((task_id, status, updated_at)) if task_id == self.task.id: self.task = SimpleNamespace(**{**self.task.__dict__, "status": status, "updated_at": updated_at}) def claim_step_running(self, task_id: str, step_name: str, *, started_at: str) -> bool: self.claims.append((task_id, step_name, started_at)) for index, step in enumerate(self.steps): if step.task_id == task_id and step.step_name == step_name: self.steps[index] = TaskStep(step.id, step.task_id, step.step_name, "running", None, None, step.retry_count, started_at, None) return True class TaskRunnerTests(unittest.TestCase): def test_process_task_reset_step_marks_task_back_to_pre_step_status(self) -> None: task = SimpleNamespace(id="task-1", status="failed_retryable", updated_at="2026-01-01T00:00:00+00:00") steps = [ TaskStep(None, "task-1", "transcribe", "failed_retryable", "ERR", "boom", 1, "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00"), ] repo = FakeRunnerRepo(task, steps) state = { "repo": repo, "settings": {"ingest": {}, "paths": {}, "comment": {"enabled": True}, "collection": {"enabled": True}, "publish": {}}, } with patch("biliup_next.app.task_runner.ensure_initialized", return_value=state), patch( "biliup_next.app.task_runner.record_task_action" ), patch("biliup_next.app.task_runner.apply_disabled_step_fallbacks", return_value=False), patch( "biliup_next.app.task_runner.next_runnable_step", return_value=(None, None) ): result = process_task("task-1", reset_step="transcribe") self.assertTrue(result["processed"][0]["reset"]) self.assertEqual(repo.step_updates[0][1], "transcribe") self.assertEqual(repo.step_updates[0][2], "pending") self.assertEqual(repo.task_updates[0][1], "created") def test_process_task_sets_task_running_before_execute_step(self) -> None: task = SimpleNamespace(id="task-1", status="created", updated_at="2026-01-01T00:00:00+00:00") steps = [ TaskStep(None, "task-1", "transcribe", "pending", None, None, 0, None, None), ] repo = FakeRunnerRepo(task, steps) state = { "repo": repo, "settings": {"ingest": {}, "paths": {}, "comment": {"enabled": True}, "collection": {"enabled": True}, "publish": {}}, } with patch("biliup_next.app.task_runner.ensure_initialized", return_value=state), patch( "biliup_next.app.task_runner.record_task_action" ), patch("biliup_next.app.task_runner.apply_disabled_step_fallbacks", return_value=False), patch( "biliup_next.app.task_runner.next_runnable_step", side_effect=[("transcribe", None), (None, None)] ), patch("biliup_next.app.task_runner.execute_step", return_value={"task_id": "task-1", "step": "transcribe"}): result = process_task("task-1") self.assertEqual(repo.claims[0][1], "transcribe") self.assertEqual(repo.task_updates[0][1], "running") self.assertEqual(result["processed"][0]["step"], "transcribe") def test_process_task_marks_publish_failed_retryable_on_module_error(self) -> None: task = SimpleNamespace(id="task-1", status="split_done", updated_at="2026-01-01T00:00:00+00:00") steps = [ TaskStep(None, "task-1", "publish", "pending", None, None, 0, None, None), ] repo = FakeRunnerRepo(task, steps) state = { "repo": repo, "settings": { "ingest": {}, "paths": {}, "comment": {"enabled": True}, "collection": {"enabled": True}, "publish": {"retry_schedule_minutes": [15], "rate_limit_retry_schedule_minutes": [30]}, }, } with patch("biliup_next.app.task_runner.ensure_initialized", return_value=state), patch( "biliup_next.app.task_runner.record_task_action" ), patch("biliup_next.app.task_runner.apply_disabled_step_fallbacks", return_value=False), patch( "biliup_next.app.task_runner.next_runnable_step", return_value=("publish", None) ), patch( "biliup_next.app.task_runner.execute_step", side_effect=ModuleError(code="PUBLISH_RATE_LIMITED", message="rate limited", retryable=True), ): result = process_task("task-1") self.assertEqual(result["processed"][-1]["retry_status"], "failed_retryable") self.assertEqual(result["processed"][-1]["next_retry_delay_seconds"], 1800) self.assertEqual(repo.step_updates[-1][1], "publish") self.assertEqual(repo.step_updates[-1][2], "failed_retryable") self.assertEqual(repo.task_updates[-1][1], "failed_retryable") if __name__ == "__main__": unittest.main()