479 lines
16 KiB
Python
479 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
|
|
from backend.app.dependencies import get_task_service
|
|
from backend.app.db.task_repository import TaskRepository
|
|
from backend.app.main import app
|
|
from backend.app.services.task_service import TaskService
|
|
|
|
|
|
class TasksApiGoldenTest(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.temp_dir = tempfile.TemporaryDirectory()
|
|
self.db_path = str(Path(self.temp_dir.name) / "tasks.db")
|
|
self.repo = TaskRepository(self.db_path)
|
|
self.service = TaskService(self.repo)
|
|
|
|
async def _override_task_service() -> TaskService:
|
|
return self.service
|
|
|
|
app.dependency_overrides[get_task_service] = _override_task_service
|
|
|
|
def tearDown(self) -> None:
|
|
app.dependency_overrides.clear()
|
|
self.temp_dir.cleanup()
|
|
|
|
def _get(self, url: str) -> httpx.Response:
|
|
async def _run() -> httpx.Response:
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
return await client.get(url)
|
|
|
|
return asyncio.run(_run())
|
|
|
|
def _post(self, url: str, payload: dict | None = None) -> httpx.Response:
|
|
async def _run() -> httpx.Response:
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
return await client.post(url, json=payload)
|
|
|
|
return asyncio.run(_run())
|
|
|
|
def _insert_task(
|
|
self,
|
|
*,
|
|
task_id: str,
|
|
operation: str,
|
|
status: str,
|
|
source: str,
|
|
destination: str,
|
|
created_at: str,
|
|
started_at: str | None = None,
|
|
finished_at: str | None = None,
|
|
done_bytes: int | None = None,
|
|
total_bytes: int | None = None,
|
|
done_items: int | None = None,
|
|
total_items: int | None = None,
|
|
current_item: str | None = None,
|
|
failed_item: str | None = None,
|
|
error_code: str | None = None,
|
|
error_message: str | None = None,
|
|
) -> None:
|
|
self.repo.insert_task_for_testing(
|
|
{
|
|
"id": task_id,
|
|
"operation": operation,
|
|
"status": status,
|
|
"source": source,
|
|
"destination": destination,
|
|
"done_bytes": done_bytes,
|
|
"total_bytes": total_bytes,
|
|
"done_items": done_items,
|
|
"total_items": total_items,
|
|
"current_item": current_item,
|
|
"failed_item": failed_item,
|
|
"error_code": error_code,
|
|
"error_message": error_message,
|
|
"created_at": created_at,
|
|
"started_at": started_at,
|
|
"finished_at": finished_at,
|
|
}
|
|
)
|
|
|
|
def test_get_tasks_empty_list(self) -> None:
|
|
response = self._get("/api/tasks")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.json(), {"items": []})
|
|
|
|
def test_get_tasks_list_shape(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-old",
|
|
operation="copy",
|
|
status="completed",
|
|
source="storage1/a.txt",
|
|
destination="storage2/a.txt",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
finished_at="2026-03-10T10:00:05Z",
|
|
)
|
|
self._insert_task(
|
|
task_id="task-new",
|
|
operation="move",
|
|
status="running",
|
|
source="storage1/b.txt",
|
|
destination="storage2/b.txt",
|
|
created_at="2026-03-10T10:01:00Z",
|
|
)
|
|
|
|
response = self._get("/api/tasks")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(
|
|
response.json(),
|
|
{
|
|
"items": [
|
|
{
|
|
"id": "task-new",
|
|
"operation": "move",
|
|
"status": "running",
|
|
"source": "storage1/b.txt",
|
|
"destination": "storage2/b.txt",
|
|
"created_at": "2026-03-10T10:01:00Z",
|
|
"finished_at": None,
|
|
},
|
|
{
|
|
"id": "task-old",
|
|
"operation": "copy",
|
|
"status": "completed",
|
|
"source": "storage1/a.txt",
|
|
"destination": "storage2/a.txt",
|
|
"created_at": "2026-03-10T10:00:00Z",
|
|
"finished_at": "2026-03-10T10:00:05Z",
|
|
},
|
|
]
|
|
},
|
|
)
|
|
|
|
def test_get_task_detail_queued(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-queued",
|
|
operation="copy",
|
|
status="queued",
|
|
source="storage1/a.txt",
|
|
destination="storage2/a.txt",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
)
|
|
|
|
response = self._get("/api/tasks/task-queued")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(
|
|
response.json(),
|
|
{
|
|
"id": "task-queued",
|
|
"operation": "copy",
|
|
"status": "queued",
|
|
"source": "storage1/a.txt",
|
|
"destination": "storage2/a.txt",
|
|
"done_bytes": None,
|
|
"total_bytes": None,
|
|
"done_items": None,
|
|
"total_items": None,
|
|
"current_item": None,
|
|
"failed_item": None,
|
|
"error_code": None,
|
|
"error_message": None,
|
|
"created_at": "2026-03-10T10:00:00Z",
|
|
"started_at": None,
|
|
"finished_at": None,
|
|
},
|
|
)
|
|
|
|
def test_get_task_detail_running(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-running",
|
|
operation="move",
|
|
status="running",
|
|
source="storage1/a.txt",
|
|
destination="storage2/a.txt",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
started_at="2026-03-10T10:00:01Z",
|
|
done_bytes=1024,
|
|
total_bytes=2048,
|
|
done_items=1,
|
|
total_items=2,
|
|
current_item="storage1/a.txt",
|
|
)
|
|
|
|
response = self._get("/api/tasks/task-running")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["status"], "running")
|
|
self.assertEqual(body["done_bytes"], 1024)
|
|
self.assertEqual(body["total_bytes"], 2048)
|
|
self.assertEqual(body["current_item"], "storage1/a.txt")
|
|
|
|
def test_get_task_detail_completed(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-completed",
|
|
operation="copy",
|
|
status="completed",
|
|
source="storage1/a.txt",
|
|
destination="storage2/a.txt",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
started_at="2026-03-10T10:00:01Z",
|
|
finished_at="2026-03-10T10:00:03Z",
|
|
done_bytes=2048,
|
|
total_bytes=2048,
|
|
)
|
|
|
|
response = self._get("/api/tasks/task-completed")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["status"], "completed")
|
|
self.assertEqual(body["finished_at"], "2026-03-10T10:00:03Z")
|
|
self.assertEqual(body["error_code"], None)
|
|
|
|
def test_get_task_detail_failed(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-failed",
|
|
operation="move",
|
|
status="failed",
|
|
source="storage1/a.txt",
|
|
destination="storage2/a.txt",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
started_at="2026-03-10T10:00:01Z",
|
|
finished_at="2026-03-10T10:00:02Z",
|
|
failed_item="storage1/a.txt",
|
|
error_code="io_error",
|
|
error_message="write failed",
|
|
)
|
|
|
|
response = self._get("/api/tasks/task-failed")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["status"], "failed")
|
|
self.assertEqual(body["failed_item"], "storage1/a.txt")
|
|
self.assertEqual(body["error_code"], "io_error")
|
|
self.assertEqual(body["error_message"], "write failed")
|
|
|
|
def test_get_task_detail_delete_running(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-delete",
|
|
operation="delete",
|
|
status="running",
|
|
source="storage1/trash.txt",
|
|
destination="",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
started_at="2026-03-10T10:00:01Z",
|
|
done_items=0,
|
|
total_items=1,
|
|
current_item="trash.txt",
|
|
)
|
|
|
|
response = self._get("/api/tasks/task-delete")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["operation"], "delete")
|
|
self.assertEqual(body["status"], "running")
|
|
self.assertEqual(body["done_items"], 0)
|
|
self.assertEqual(body["total_items"], 1)
|
|
self.assertEqual(body["current_item"], "trash.txt")
|
|
|
|
def test_cancel_running_delete_task_returns_cancelling(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-delete",
|
|
operation="delete",
|
|
status="running",
|
|
source="storage1/trash.txt",
|
|
destination="",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
started_at="2026-03-10T10:00:01Z",
|
|
done_items=0,
|
|
total_items=1,
|
|
current_item="trash.txt",
|
|
)
|
|
|
|
response = self._post("/api/tasks/task-delete/cancel")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["operation"], "delete")
|
|
self.assertEqual(body["status"], "cancelling")
|
|
self.assertEqual(body["current_item"], "trash.txt")
|
|
|
|
def test_cancel_completed_task_rejected(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-copy",
|
|
operation="copy",
|
|
status="completed",
|
|
source="storage1/a.txt",
|
|
destination="storage2/a.txt",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
finished_at="2026-03-10T10:00:04Z",
|
|
)
|
|
|
|
response = self._post("/api/tasks/task-copy/cancel")
|
|
|
|
self.assertEqual(response.status_code, 409)
|
|
self.assertEqual(
|
|
response.json(),
|
|
{
|
|
"error": {
|
|
"code": "task_not_cancellable",
|
|
"message": "Task cannot be cancelled",
|
|
"details": {"task_id": "task-copy", "status": "completed"},
|
|
}
|
|
},
|
|
)
|
|
|
|
def test_cancel_download_task_rejected(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-download",
|
|
operation="download",
|
|
status="preparing",
|
|
source="single_directory_zip",
|
|
destination="docs.zip",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
started_at="2026-03-10T10:00:01Z",
|
|
)
|
|
|
|
response = self._post("/api/tasks/task-download/cancel")
|
|
|
|
self.assertEqual(response.status_code, 409)
|
|
self.assertEqual(
|
|
response.json(),
|
|
{
|
|
"error": {
|
|
"code": "task_not_cancellable",
|
|
"message": "Task cannot be cancelled",
|
|
"details": {"task_id": "task-download", "status": "preparing"},
|
|
}
|
|
},
|
|
)
|
|
|
|
def test_get_task_detail_ready_archive_download(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-download-ready",
|
|
operation="download",
|
|
status="ready",
|
|
source="storage1/docs",
|
|
destination="docs.zip",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
started_at="2026-03-10T10:00:01Z",
|
|
finished_at="2026-03-10T10:00:05Z",
|
|
done_items=1,
|
|
total_items=1,
|
|
)
|
|
|
|
response = self._get("/api/tasks/task-download-ready")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["operation"], "download")
|
|
self.assertEqual(body["status"], "ready")
|
|
self.assertEqual(body["destination"], "docs.zip")
|
|
|
|
def test_get_task_detail_duplicate_completed(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-duplicate",
|
|
operation="duplicate",
|
|
status="completed",
|
|
source="storage1/report.txt",
|
|
destination="storage1/report copy.txt",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
started_at="2026-03-10T10:00:01Z",
|
|
finished_at="2026-03-10T10:00:03Z",
|
|
done_items=1,
|
|
total_items=1,
|
|
current_item="storage1/report.txt",
|
|
)
|
|
|
|
response = self._get("/api/tasks/task-duplicate")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["operation"], "duplicate")
|
|
self.assertEqual(body["status"], "completed")
|
|
self.assertEqual(body["done_items"], 1)
|
|
self.assertEqual(body["total_items"], 1)
|
|
|
|
def test_get_task_detail_requested_archive_download(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-download-requested",
|
|
operation="download",
|
|
status="requested",
|
|
source="storage1/docs",
|
|
destination="docs.zip",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
done_items=0,
|
|
total_items=1,
|
|
)
|
|
|
|
response = self._get("/api/tasks/task-download-requested")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["operation"], "download")
|
|
self.assertEqual(body["status"], "requested")
|
|
self.assertEqual(body["done_items"], 0)
|
|
self.assertEqual(body["total_items"], 1)
|
|
|
|
def test_get_task_detail_preparing_archive_download_with_current_item(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-download-preparing",
|
|
operation="download",
|
|
status="preparing",
|
|
source="storage1/docs",
|
|
destination="docs.zip",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
started_at="2026-03-10T10:00:01Z",
|
|
done_items=1,
|
|
total_items=3,
|
|
current_item="storage1/docs/b.txt",
|
|
)
|
|
|
|
response = self._get("/api/tasks/task-download-preparing")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["operation"], "download")
|
|
self.assertEqual(body["status"], "preparing")
|
|
self.assertEqual(body["done_items"], 1)
|
|
self.assertEqual(body["total_items"], 3)
|
|
self.assertEqual(body["current_item"], "storage1/docs/b.txt")
|
|
|
|
def test_get_task_detail_cancelled_archive_download(self) -> None:
|
|
self._insert_task(
|
|
task_id="task-download-cancelled",
|
|
operation="download",
|
|
status="cancelled",
|
|
source="storage1/docs",
|
|
destination="docs.zip",
|
|
created_at="2026-03-10T10:00:00Z",
|
|
started_at="2026-03-10T10:00:01Z",
|
|
finished_at="2026-03-10T10:00:03Z",
|
|
done_items=0,
|
|
total_items=1,
|
|
)
|
|
|
|
response = self._get("/api/tasks/task-download-cancelled")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["operation"], "download")
|
|
self.assertEqual(body["status"], "cancelled")
|
|
self.assertEqual(body["destination"], "docs.zip")
|
|
|
|
def test_get_task_not_found(self) -> None:
|
|
response = self._get("/api/tasks/task-missing")
|
|
|
|
self.assertEqual(response.status_code, 404)
|
|
self.assertEqual(
|
|
response.json(),
|
|
{
|
|
"error": {
|
|
"code": "task_not_found",
|
|
"message": "Task was not found",
|
|
"details": {"task_id": "task-missing"},
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|