feat: B3 uit voor veilige archive-downloads - cancel knop toegevoegd

This commit is contained in:
kodi
2026-03-14 14:39:57 +01:00
parent d463b3977d
commit 2981ac2796
24 changed files with 471 additions and 37 deletions
+9 -1
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask
from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, UploadResponse, ViewResponse
from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, TaskDetailResponse, UploadResponse, ViewResponse
from backend.app.dependencies import get_archive_download_task_service, get_file_ops_service
from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService
from backend.app.services.file_ops_service import FileOpsService
@@ -100,6 +100,14 @@ async def archive_download(
)
@router.post("/download/archive/{task_id}/cancel", response_model=TaskDetailResponse)
async def archive_cancel(
task_id: str,
service: ArchiveDownloadTaskService = Depends(get_archive_download_task_service),
) -> TaskDetailResponse:
return TaskDetailResponse(**service.cancel_archive_prepare_task(task_id=task_id))
@router.get("/video")
async def video(
path: str,
+1 -1
View File
@@ -6,7 +6,7 @@ from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
VALID_HISTORY_STATUSES = {"queued", "completed", "failed", "requested", "ready", "preflight_failed"}
VALID_HISTORY_STATUSES = {"queued", "completed", "failed", "requested", "ready", "preflight_failed", "cancelled"}
VALID_HISTORY_OPERATIONS = {"mkdir", "rename", "delete", "copy", "move", "upload", "download"}
+59 -9
View File
@@ -6,7 +6,7 @@ from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready"}
VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"}
VALID_OPERATIONS = {"copy", "move", "download"}
TASK_MIGRATION_COLUMNS: dict[str, str] = {
"operation": "TEXT NOT NULL DEFAULT 'copy'",
@@ -160,17 +160,18 @@ class TaskRepository:
done_items: int | None = None,
total_items: int | None = None,
current_item: str | None = None,
) -> None:
) -> bool:
started_at = self._now_iso()
with self._connection() as conn:
conn.execute(
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, started_at = COALESCE(started_at, ?), done_items = ?, total_items = ?, current_item = ?
WHERE id = ?
WHERE id = ? AND status = ?
""",
("preparing", started_at, done_items, total_items, current_item, task_id),
("preparing", started_at, done_items, total_items, current_item, task_id, "requested"),
)
return cursor.rowcount > 0
def update_progress(
self,
@@ -215,17 +216,18 @@ class TaskRepository:
task_id: str,
done_items: int | None = None,
total_items: int | None = None,
) -> None:
) -> bool:
finished_at = self._now_iso()
with self._connection() as conn:
conn.execute(
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, done_items = ?, total_items = ?, current_item = NULL
WHERE id = ?
WHERE id = ? AND status = ?
""",
("ready", finished_at, done_items, total_items, task_id),
("ready", finished_at, done_items, total_items, task_id, "preparing"),
)
return cursor.rowcount > 0
def mark_failed(
self,
@@ -260,6 +262,54 @@ class TaskRepository:
),
)
def mark_failed_if_not_cancelled(
self,
task_id: str,
error_code: str,
error_message: str,
failed_item: str | None,
done_bytes: int | None,
total_bytes: int | None,
done_items: int | None = None,
total_items: int | None = None,
) -> bool:
finished_at = self._now_iso()
with self._connection() as conn:
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL
WHERE id = ? AND status != ?
""",
(
"failed",
finished_at,
error_code,
error_message,
failed_item,
done_bytes,
total_bytes,
done_items,
total_items,
task_id,
"cancelled",
),
)
return cursor.rowcount > 0
def mark_cancelled(self, task_id: str) -> bool:
finished_at = self._now_iso()
with self._connection() as conn:
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, current_item = NULL
WHERE id = ? AND status IN (?, ?)
""",
("cancelled", finished_at, task_id, "requested", "preparing"),
)
return cursor.rowcount > 0
def _ensure_schema(self) -> None:
db_path = Path(self._db_path)
if db_path.parent and str(db_path.parent) not in {"", "."}:
@@ -17,6 +17,10 @@ from backend.app.tasks_runner import TaskRunner
ARCHIVE_DOWNLOAD_TTL_SECONDS = 30 * 60
class ArchivePrepareCancelled(Exception):
pass
class ArchiveDownloadTaskService:
def __init__(
self,
@@ -103,6 +107,13 @@ class ArchiveDownloadTaskService:
status_code=400,
details={"task_id": task_id},
)
if task["status"] == "cancelled":
raise AppError(
code="download_cancelled",
message="Archive download was cancelled",
status_code=409,
details={"task_id": task_id},
)
if task["status"] != "ready":
raise AppError(
code="download_not_ready",
@@ -147,6 +158,58 @@ class ArchiveDownloadTaskService:
"content_type": "application/zip",
}
def cancel_archive_prepare_task(self, task_id: str) -> dict:
self.sweep_artifacts()
task = self._repository.get_task(task_id)
if not task:
raise AppError(
code="task_not_found",
message="Task was not found",
status_code=404,
details={"task_id": task_id},
)
if task["operation"] != "download":
raise AppError(
code="invalid_request",
message="Task is not an archive download",
status_code=400,
details={"task_id": task_id},
)
if task["status"] == "ready":
raise AppError(
code="download_not_cancellable",
message="Archive download is already ready",
status_code=409,
details={"task_id": task_id, "status": task["status"]},
)
if task["status"] in {"failed", "cancelled"}:
raise AppError(
code="download_not_cancellable",
message="Archive download cannot be cancelled",
status_code=409,
details={"task_id": task_id, "status": task["status"]},
)
if not self._repository.mark_cancelled(task_id):
current = self._repository.get_task(task_id)
current_status = current["status"] if current else task["status"]
raise AppError(
code="download_not_cancellable",
message="Archive download cannot be cancelled",
status_code=409,
details={"task_id": task_id, "status": current_status},
)
self._cleanup_task_artifacts(task_id)
self._update_history_cancelled(task_id)
cancelled_task = self._repository.get_task(task_id)
if not cancelled_task:
raise AppError(
code="task_not_found",
message="Task was not found",
status_code=404,
details={"task_id": task_id},
)
return cancelled_task
def sweep_artifacts(self) -> None:
self._artifact_root.mkdir(parents=True, exist_ok=True)
referenced_paths: set[Path] = set()
@@ -177,37 +240,59 @@ class ArchiveDownloadTaskService:
total_items = len(target_paths)
try:
self._repository.mark_preparing(
self._raise_if_cancelled(task_id)
if not self._repository.mark_preparing(
task_id=task_id,
done_items=0,
total_items=total_items,
current_item=target_paths[0] if target_paths else None,
)
):
self._raise_if_cancelled(task_id)
return
resolved_targets = [self._path_guard.resolve_existing_path(path) for path in target_paths]
self._raise_if_cancelled(task_id)
self._file_ops_service._validate_zip_download_archive_names(resolved_targets)
self._file_ops_service._run_zip_download_preflight(resolved_targets)
self._raise_if_cancelled(task_id)
with zipfile.ZipFile(partial_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for resolved_target in resolved_targets:
self._file_ops_service._write_download_target_to_zip(archive, resolved_target)
for index, resolved_target in enumerate(resolved_targets):
self._raise_if_cancelled(task_id)
self._repository.update_progress(
task_id=task_id,
done_items=index,
total_items=total_items,
current_item=resolved_target.relative,
)
self._file_ops_service._write_download_target_to_zip(
archive,
resolved_target,
on_each_item=lambda: self._raise_if_cancelled(task_id),
)
self._raise_if_cancelled(task_id)
os.replace(partial_path, final_path)
self._raise_if_cancelled(task_id)
self._repository.upsert_artifact(
task_id=task_id,
file_path=str(final_path),
file_name=archive_name,
expires_at=self._expires_at_iso(),
)
self._repository.mark_ready(
if not self._repository.mark_ready(
task_id=task_id,
done_items=total_items,
total_items=total_items,
)
):
self._cleanup_task_artifacts(task_id)
self._raise_if_cancelled(task_id)
return
self._update_history_ready(task_id)
except ArchivePrepareCancelled:
self._cleanup_task_artifacts(task_id)
except AppError as exc:
self._delete_artifact_record_and_file(task_id, str(partial_path))
self._delete_artifact_record_and_file(task_id, str(final_path))
self._repository.mark_failed(
self._cleanup_task_artifacts(task_id)
if self._repository.mark_failed_if_not_cancelled(
task_id=task_id,
error_code=exc.code,
error_message=exc.message,
@@ -216,12 +301,11 @@ class ArchiveDownloadTaskService:
total_bytes=None,
done_items=0,
total_items=total_items,
)
self._update_history_failed(task_id, exc.code, exc.message)
):
self._update_history_failed(task_id, exc.code, exc.message)
except OSError as exc:
self._delete_artifact_record_and_file(task_id, str(partial_path))
self._delete_artifact_record_and_file(task_id, str(final_path))
self._repository.mark_failed(
self._cleanup_task_artifacts(task_id)
if self._repository.mark_failed_if_not_cancelled(
task_id=task_id,
error_code="io_error",
error_message=str(exc),
@@ -230,8 +314,12 @@ class ArchiveDownloadTaskService:
total_bytes=None,
done_items=0,
total_items=total_items,
)
self._update_history_failed(task_id, "io_error", str(exc))
):
self._update_history_failed(task_id, "io_error", str(exc))
def _cleanup_task_artifacts(self, task_id: str) -> None:
self._delete_artifact_record_and_file(task_id, str(self._artifact_root / f"{task_id}.partial.zip"))
self._delete_artifact_record_and_file(task_id, str(self._artifact_root / f"{task_id}.zip"))
def _delete_artifact_record_and_file(self, task_id: str, file_path: str) -> None:
self._repository.delete_artifact(task_id)
@@ -254,6 +342,10 @@ class ArchiveDownloadTaskService:
error_message=error_message,
)
def _update_history_cancelled(self, task_id: str) -> None:
if self._history_repository:
self._history_repository.update_entry(entry_id=task_id, status="cancelled")
def _record_history(self, **kwargs) -> None:
if self._history_repository:
self._history_repository.create_entry(**kwargs)
@@ -264,3 +356,8 @@ class ArchiveDownloadTaskService:
@staticmethod
def _is_expired(expires_at: str) -> bool:
return datetime.now(timezone.utc) >= datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
def _raise_if_cancelled(self, task_id: str) -> None:
task = self._repository.get_task(task_id)
if task and task["status"] == "cancelled":
raise ArchivePrepareCancelled()
@@ -1005,14 +1005,18 @@ class FileOpsService:
details={"reason": reason, **details},
)
def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None:
def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target, on_each_item=None) -> None:
root_name = resolved_target.absolute.name
if resolved_target.absolute.is_file():
if on_each_item:
on_each_item()
archive.write(resolved_target.absolute, arcname=root_name)
return
archive.writestr(f"{root_name}/", b"")
for child in sorted(resolved_target.absolute.rglob("*")):
if on_each_item:
on_each_item()
arcname = f"{root_name}/{child.relative_to(resolved_target.absolute).as_posix()}"
if child.is_dir():
archive.writestr(f"{arcname}/", b"")
Binary file not shown.
@@ -37,11 +37,26 @@ class BlockingArchiveFileOpsService(FileOpsService):
class FailingArchiveFileOpsService(FileOpsService):
def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None:
def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target, on_each_item=None) -> None:
archive.writestr("partial.txt", b"partial")
raise OSError("forced archive failure")
class BlockingArchiveBuildFileOpsService(FileOpsService):
def __init__(self, *args, entered: threading.Event, release: threading.Event, **kwargs):
super().__init__(*args, **kwargs)
self._entered = entered
self._release = release
def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target, on_each_item=None) -> None:
archive.writestr("partial.txt", b"partial")
self._entered.set()
self._release.wait(timeout=2.0)
if on_each_item:
on_each_item()
super()._write_download_target_to_zip(archive, resolved_target, on_each_item=on_each_item)
class DownloadApiGoldenTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
@@ -221,6 +236,71 @@ class DownloadApiGoldenTest(unittest.TestCase):
self.assertEqual(task["error_code"], "io_error")
self.assertEqual(list(self.artifact_root.glob("*")), [])
def test_archive_cancel_during_preparing_sets_cancelled_and_removes_partial_artifact(self) -> None:
entered = threading.Event()
release = threading.Event()
file_ops_service = BlockingArchiveBuildFileOpsService(
path_guard=self.path_guard,
filesystem=self.filesystem,
history_repository=self.history_repo,
zip_download_preflight_limits=ZipDownloadPreflightLimits(),
entered=entered,
release=release,
)
self._override_services(file_ops_service=file_ops_service)
(self.root / "docs").mkdir()
(self.root / "docs" / "a.txt").write_text("a", encoding="utf-8")
created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]})
self.assertEqual(created.status_code, 202)
self.assertTrue(entered.wait(timeout=2.0))
response = self._request("POST", f"/api/files/download/archive/{created.json()['task_id']}/cancel")
release.set()
task = self._wait_for_task_status(created.json()["task_id"], {"cancelled"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "cancelled")
self.assertEqual(task["status"], "cancelled")
self.assertEqual(list(self.artifact_root.glob("*")), [])
def test_archive_retrieval_for_cancelled_task_rejected(self) -> None:
entered = threading.Event()
release = threading.Event()
file_ops_service = BlockingArchiveBuildFileOpsService(
path_guard=self.path_guard,
filesystem=self.filesystem,
history_repository=self.history_repo,
zip_download_preflight_limits=ZipDownloadPreflightLimits(),
entered=entered,
release=release,
)
self._override_services(file_ops_service=file_ops_service)
(self.root / "docs").mkdir()
(self.root / "docs" / "a.txt").write_text("a", encoding="utf-8")
created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]})
self.assertTrue(entered.wait(timeout=2.0))
cancel_response = self._request("POST", f"/api/files/download/archive/{created.json()['task_id']}/cancel")
release.set()
response = self._request("GET", f"/api/files/download/archive/{created.json()['task_id']}")
self.assertEqual(cancel_response.status_code, 200)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "download_cancelled")
def test_archive_cancel_after_ready_rejected(self) -> None:
(self.root / "docs").mkdir()
(self.root / "docs" / "a.txt").write_text("a", encoding="utf-8")
created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]})
task = self._wait_for_task_status(created.json()["task_id"], {"ready"})
response = self._request("POST", f"/api/files/download/archive/{task['id']}/cancel")
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "download_not_cancellable")
def test_expired_artifact_rejected_and_removed(self) -> None:
(self.root / "docs").mkdir()
(self.root / "docs" / "a.txt").write_text("a", encoding="utf-8")
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
@@ -11,12 +12,13 @@ import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from backend.app.dependencies import get_copy_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service
from backend.app.dependencies import get_archive_download_task_service, get_copy_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service
from backend.app.db.history_repository import HistoryRepository
from backend.app.db.task_repository import TaskRepository
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.main import app
from backend.app.security.path_guard import PathGuard
from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService
from backend.app.services.copy_task_service import CopyTaskService
from backend.app.services.file_ops_service import FileOpsService
from backend.app.services.history_service import HistoryService
@@ -30,6 +32,21 @@ class FailingCopyFilesystemAdapter(FilesystemAdapter):
raise OSError('forced copy failure')
class BlockingArchiveBuildFileOpsService(FileOpsService):
def __init__(self, *args, entered: threading.Event, release: threading.Event, **kwargs):
super().__init__(*args, **kwargs)
self._entered = entered
self._release = release
def _write_download_target_to_zip(self, archive, resolved_target, on_each_item=None) -> None:
archive.writestr("partial.txt", b"partial")
self._entered.set()
self._release.wait(timeout=2.0)
if on_each_item:
on_each_item()
super()._write_download_target_to_zip(archive, resolved_target, on_each_item=on_each_item)
class HistoryApiGoldenTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
@@ -38,6 +55,7 @@ class HistoryApiGoldenTest(unittest.TestCase):
self.root1.mkdir(parents=True, exist_ok=True)
self.root2.mkdir(parents=True, exist_ok=True)
db_path = str(Path(self.temp_dir.name) / 'tasks.db')
self.artifact_root = Path(self.temp_dir.name) / "archive_tmp"
self.task_repo = TaskRepository(db_path)
self.history_repo = HistoryRepository(db_path)
self.path_guard = PathGuard({'storage1': str(self.root1), 'storage2': str(self.root2)})
@@ -47,9 +65,17 @@ class HistoryApiGoldenTest(unittest.TestCase):
app.dependency_overrides.clear()
self.temp_dir.cleanup()
def _set_services(self, filesystem: FilesystemAdapter) -> None:
def _set_services(self, filesystem: FilesystemAdapter, file_ops_service: FileOpsService | None = None) -> None:
runner = TaskRunner(repository=self.task_repo, filesystem=filesystem, history_repository=self.history_repo)
file_ops_service = FileOpsService(path_guard=self.path_guard, filesystem=filesystem, history_repository=self.history_repo)
file_ops_service = file_ops_service or FileOpsService(path_guard=self.path_guard, filesystem=filesystem, history_repository=self.history_repo)
archive_service = ArchiveDownloadTaskService(
path_guard=self.path_guard,
repository=self.task_repo,
runner=runner,
history_repository=self.history_repo,
file_ops_service=file_ops_service,
artifact_root=self.artifact_root,
)
copy_service = CopyTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo)
move_service = MoveTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo)
task_service = TaskService(repository=self.task_repo)
@@ -58,6 +84,9 @@ class HistoryApiGoldenTest(unittest.TestCase):
async def _override_file_ops_service() -> FileOpsService:
return file_ops_service
async def _override_archive_service() -> ArchiveDownloadTaskService:
return archive_service
async def _override_copy_service() -> CopyTaskService:
return copy_service
@@ -71,6 +100,7 @@ class HistoryApiGoldenTest(unittest.TestCase):
return history_service
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
app.dependency_overrides[get_archive_download_task_service] = _override_archive_service
app.dependency_overrides[get_copy_task_service] = _override_copy_service
app.dependency_overrides[get_move_task_service] = _override_move_service
app.dependency_overrides[get_task_service] = _override_task_service
@@ -91,7 +121,7 @@ class HistoryApiGoldenTest(unittest.TestCase):
while time.time() < deadline:
response = self._request('GET', f'/api/tasks/{task_id}')
body = response.json()
if body['status'] in {'completed', 'failed', 'ready'}:
if body['status'] in {'completed', 'failed', 'ready', 'cancelled'}:
return body
time.sleep(0.02)
self.fail('task did not reach terminal state in time')
@@ -244,6 +274,38 @@ class HistoryApiGoldenTest(unittest.TestCase):
self.assertEqual(history[0]['error_code'], 'download_preflight_failed')
self.assertEqual(history[0]['error_message'], 'Zip download preflight failed')
def test_download_cancellation_writes_cancelled_history_item(self) -> None:
entered = threading.Event()
release = threading.Event()
file_ops_service = BlockingArchiveBuildFileOpsService(
path_guard=self.path_guard,
filesystem=FilesystemAdapter(),
history_repository=self.history_repo,
entered=entered,
release=release,
)
self._set_services(FilesystemAdapter(), file_ops_service=file_ops_service)
(self.root1 / 'docs').mkdir()
(self.root1 / 'docs' / 'a.txt').write_text('A', encoding='utf-8')
response = self._request('POST', '/api/files/download/archive-prepare', {'paths': ['storage1/docs']})
self.assertEqual(response.status_code, 202)
self.assertTrue(entered.wait(timeout=2.0))
cancel = self._request('POST', f"/api/files/download/archive/{response.json()['task_id']}/cancel")
release.set()
self._wait_task(response.json()['task_id'])
history = self._request('GET', '/api/history').json()['items']
self.assertEqual(cancel.status_code, 200)
self.assertEqual(history[0]['operation'], 'download')
self.assertEqual(history[0]['status'], 'cancelled')
self.assertEqual(history[0]['source'], 'single_directory_zip')
self.assertEqual(history[0]['path'], 'storage1/docs')
self.assertEqual(history[0]['destination'], 'docs.zip')
self.assertEqual(history[0]['error_code'], None)
self.assertEqual(history[0]['error_message'], None)
def test_download_history_uses_server_certain_statuses_only(self) -> None:
(self.root1 / 'report.txt').write_text('hello download', encoding='utf-8')
@@ -251,5 +313,5 @@ class HistoryApiGoldenTest(unittest.TestCase):
self.assertEqual(response.status_code, 200)
history = self._request('GET', '/api/history').json()['items']
self.assertIn(history[0]['status'], {'requested', 'ready', 'preflight_failed', 'failed'})
self.assertIn(history[0]['status'], {'requested', 'ready', 'preflight_failed', 'failed', 'cancelled'})
self.assertNotIn(history[0]['status'], {'completed', 'downloaded', 'saved'})
@@ -263,6 +263,28 @@ class TasksApiGoldenTest(unittest.TestCase):
self.assertEqual(body["status"], "ready")
self.assertEqual(body["destination"], "docs.zip")
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")
@@ -74,6 +74,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="download-modal-progress-bar"', body)
self.assertIn('id="download-modal-count"', body)
self.assertIn('id="download-modal-status"', body)
self.assertIn('id="download-modal-cancel-btn"', body)
self.assertIn('id="download-modal-close-btn"', body)
self.assertIn('id="context-menu"', body)
self.assertIn('id="context-menu-scope"', body)
@@ -231,11 +232,14 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function openZipDownloadModal(selectedItems)', app_js)
self.assertIn('function markZipDownloadReady(fileName)', app_js)
self.assertIn('function markZipDownloadFailed(err)', app_js)
self.assertIn('function markZipDownloadCancelled()', app_js)
self.assertIn('function closeDownloadModal()', app_js)
self.assertIn('function zipDownloadRequestKey(paths)', app_js)
self.assertIn('async function createArchiveDownloadTask(paths)', app_js)
self.assertIn('async function getTaskRequest(taskId)', app_js)
self.assertIn('async function cancelArchiveDownloadTask(taskId)', app_js)
self.assertIn('function startArchiveDownload(taskId, fileName)', app_js)
self.assertIn('async function requestArchiveDownloadCancel()', app_js)
self.assertIn('async function waitForArchiveDownloadReady(taskId)', app_js)
self.assertIn('function contextMenuElements()', app_js)
self.assertIn('function openContextMenu(pane, entry, event)', app_js)
@@ -251,12 +255,15 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('statusText: "Download started"', app_js)
self.assertIn('countText: "Browser download started"', app_js)
self.assertIn('countText: "Zip download failed"', app_js)
self.assertIn('countText: "Zip download cancelled"', app_js)
self.assertIn('statusText: "Cancelling download..."', app_js)
self.assertIn('statusText: err.message || "Download failed"', app_js)
self.assertIn('downloadProgressState.requestKey === requestKey', app_js)
self.assertIn('setStatus("Preparing download...");', app_js)
self.assertIn('"/api/files/download/archive-prepare"', app_js)
self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js)
self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js)
self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js)
self.assertIn('function applyContextMenuSelection()', app_js)
self.assertIn('function startContextMenuOpen()', app_js)
self.assertIn('function startContextMenuEdit()', app_js)
@@ -80,6 +80,21 @@ class TaskRepositoryTest(unittest.TestCase):
self.assertEqual(task["status"], "requested")
self.assertEqual(artifact["file_name"], "docs.zip")
def test_mark_cancelled_transitions_requested_download_task(self) -> None:
created = self.repo.create_task(
operation="download",
source="storage1/docs",
destination="docs.zip",
status="requested",
)
changed = self.repo.mark_cancelled(created["id"])
task = self.repo.get_task(created["id"])
self.assertTrue(changed)
self.assertEqual(task["status"], "cancelled")
self.assertIsNotNone(task["finished_at"])
def test_migrates_legacy_tasks_schema_missing_source_destination(self) -> None:
legacy_db_path = Path(self.temp_dir.name) / "legacy.db"
conn = sqlite3.connect(legacy_db_path)