"""Tests for async backups export API.""" from __future__ import annotations import hashlib from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch from apps.backups.models import BackupExportJob from apps.backups.services import ( BackupExportError, BackupExportJobService, BackupRequestResult, ) from django.db import IntegrityError from django.urls import reverse from django.utils import timezone from rest_framework import status from rest_framework.test import APITestCase from tests.apps.user.factories import UserFactory class BackupExportViewTest(APITestCase): """Tests for async backup export endpoint.""" def setUp(self): self.user = UserFactory.create_user() self.admin = UserFactory.create_superuser() self.url = reverse("api_v1:backups:export") def test_export_admin_only(self): response = self.client.post(self.url, {}, format="json") self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) self.client.force_authenticate(self.user) response = self.client.post(self.url, {}, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @patch("apps.backups.services.BackupExportJobService._enqueue_backup_task") def test_export_starts_job_when_absent(self, enqueue_mock): self.client.force_authenticate(self.admin) today = timezone.localdate() with self.captureOnCommitCallbacks(execute=True): response = self.client.post( self.url, {"actual_date": today.isoformat()}, format="json", ) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertEqual(response.data["status"], "started") job = BackupExportJob.objects.get(actual_date=today) self.assertEqual(job.status, BackupExportJob.Status.PENDING) self.assertEqual(response.data["task_id"], job.task_id) enqueue_mock.assert_called_once_with(job_id=job.id, task_id=job.task_id) @patch("apps.backups.services.BackupExportJobService._enqueue_backup_task") def test_export_returns_wait_when_job_in_progress(self, enqueue_mock): today = timezone.localdate() BackupExportJob.objects.create( actual_date=today, status=BackupExportJob.Status.STARTED, task_id="task-running-1", ) self.client.force_authenticate(self.admin) response = self.client.post( self.url, {"actual_date": today.isoformat()}, format="json", ) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertEqual(response.data["status"], "wait") self.assertEqual(response.data["task_id"], "task-running-1") enqueue_mock.assert_not_called() @patch("apps.backups.services.BackupExportJobService._enqueue_backup_task") def test_export_returns_file_and_deletes_after_download(self, enqueue_mock): with TemporaryDirectory() as tmp_dir: tmp_path = Path(tmp_dir) archive_bytes = b"zip-content" archive_path = tmp_path / "backup.zip" archive_path.write_bytes(archive_bytes) today = timezone.localdate() job = BackupExportJob.objects.create( actual_date=today, status=BackupExportJob.Status.SUCCESS, task_id="task-success-1", archive_path=str(archive_path), archive_filename="backup.zip", checksum_filename="backup.zip.sha256", checksum_sha256=hashlib.sha256(archive_bytes).hexdigest(), organizations_count=7, ) self.client.force_authenticate(self.admin) response = self.client.post( self.url, {"actual_date": today.isoformat()}, format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.content, archive_bytes) self.assertEqual(response["Content-Type"], "application/zip") self.assertIn( 'attachment; filename="backup.zip"', response["Content-Disposition"] ) self.assertEqual(response["X-Backup-Organizations"], "7") self.assertEqual( response["X-Backup-SHA256"], hashlib.sha256(archive_bytes).hexdigest(), ) self.assertFalse(archive_path.exists()) self.assertFalse(BackupExportJob.objects.filter(id=job.id).exists()) enqueue_mock.assert_not_called() @patch("apps.backups.services.BackupExportJobService._enqueue_backup_task") def test_export_restarts_when_success_job_has_no_file(self, enqueue_mock): today = timezone.localdate() with TemporaryDirectory() as tmp_dir: missing_archive_path = Path(tmp_dir) / "non-existent-backup.zip" BackupExportJob.objects.create( actual_date=today, status=BackupExportJob.Status.SUCCESS, task_id="task-old", archive_path=str(missing_archive_path), archive_filename="non-existent-backup.zip", ) self.client.force_authenticate(self.admin) with self.captureOnCommitCallbacks(execute=True): response = self.client.post( self.url, {"actual_date": today.isoformat()}, format="json", ) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertEqual(response.data["status"], "started") restarted_job = BackupExportJob.objects.get(actual_date=today) self.assertEqual(response.data["task_id"], restarted_job.task_id) enqueue_mock.assert_called_once_with( job_id=restarted_job.id, task_id=restarted_job.task_id, ) @patch("apps.backups.services.BackupExportJob.objects.create") def test_check_or_start_job_handles_integrity_race(self, create_mock): today = timezone.localdate() concurrent_job = BackupExportJob( actual_date=today, status=BackupExportJob.Status.STARTED, task_id="task-race-running", ) concurrent_job.save() create_mock.side_effect = IntegrityError("duplicate key value") with patch.object( BackupExportJobService, "_get_job_for_update", side_effect=[None, concurrent_job], ): result = BackupExportJobService.check_or_start_job( actual_date=today, requested_by_id=self.admin.id, ) self.assertEqual(result.action, "wait") self.assertEqual(result.task_id, "task-race-running") @patch("apps.backups.services.BackupExportJobService._enqueue_backup_task") def test_check_or_start_job_enqueues_on_commit(self, enqueue_mock): today = timezone.localdate() with self.captureOnCommitCallbacks(execute=False) as callbacks: result = BackupExportJobService.check_or_start_job( actual_date=today, requested_by_id=self.admin.id, ) self.assertEqual(result.action, "started") self.assertEqual(len(callbacks), 1) enqueue_mock.assert_not_called() job = BackupExportJob.objects.get(actual_date=today) self.assertEqual(job.task_id, result.task_id) callbacks[0]() enqueue_mock.assert_called_once_with(job_id=job.id, task_id=job.task_id) @patch("apps.backups.views.BackupExportJobService.check_or_start_job") def test_export_returns_400_when_job_start_fails(self, check_or_start_mock): self.client.force_authenticate(self.admin) check_or_start_mock.side_effect = BackupExportError("broken") response = self.client.post(self.url, {}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( response.data["errors"][0]["details"]["fields"]["backup"], ["broken"] ) @patch("apps.backups.views.BackupExportJobService.consume_ready_archive") @patch("apps.backups.views.BackupExportJobService.check_or_start_job") def test_export_returns_400_when_archive_consumption_fails( self, check_or_start_mock, consume_mock, ): today = timezone.localdate() self.client.force_authenticate(self.admin) check_or_start_mock.return_value = BackupRequestResult( action="download", message="ready", actual_date=today, task_id="task-ready", ) consume_mock.side_effect = BackupExportError("missing archive") response = self.client.post( self.url, {"actual_date": today.isoformat()}, format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( response.data["errors"][0]["details"]["fields"]["backup"], ["missing archive"], )