feat: expand platform APIs, sources, and test coverage
Some checks failed
CI/CD Pipeline / Run Tests (pull_request) Successful in 1m53s
CI/CD Pipeline / Telegram Notify Success (push) Has been cancelled
CI/CD Pipeline / Run Tests (push) Has been cancelled
CI/CD Pipeline / Code Quality Checks (push) Has been cancelled
CI/CD Pipeline / Code Quality Checks (pull_request) Failing after 2m54s
CI/CD Pipeline / Telegram Notify Success (pull_request) Has been skipped

This commit is contained in:
2026-03-17 12:56:48 +01:00
parent b505c67968
commit 3d298ce352
101 changed files with 8387 additions and 292 deletions

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,17 @@
"""Tests for backups models."""
from datetime import date
from apps.backups.models import BackupExportJob
from django.test import TestCase
class BackupExportJobModelTest(TestCase):
def test_string_representation(self):
job = BackupExportJob.objects.create(
actual_date=date(2026, 3, 17),
status=BackupExportJob.Status.SUCCESS,
task_id="task-1",
)
self.assertEqual(str(job), "Backup 2026-03-17 [success]")

View File

@@ -0,0 +1,498 @@
from __future__ import annotations
import base64
import json
import struct
import zlib
from datetime import date, datetime
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from uuid import uuid4
from zipfile import ZipFile
from apps.backups.models import BackupExportJob
from apps.backups.services import (
BackupArtifact,
BackupExportError,
BackupExportJobService,
BackupExportService,
)
from apps.parsers.models import FinancialReport, FinancialReportLine
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from django.db import IntegrityError
from django.test import TestCase, override_settings
from tests.apps.parsers.factories import (
IndustrialCertificateRecordFactory,
InspectionRecordFactory,
ManufacturerRecordFactory,
ProcurementRecordFactory,
)
from tests.apps.registers.factories import (
OrganizationFactory,
RegisterFactory,
RegisterUploadFactory,
RegistryMembershipPeriodFactory,
)
from tests.apps.user.factories import UserFactory
TEST_BACKUP_KEY = base64.urlsafe_b64encode(b"k" * 32).decode("ascii").rstrip("=")
def _decode_backup_payload(bin_bytes: bytes) -> tuple[dict, dict]:
header_size = struct.unpack(">I", bin_bytes[5:9])[0]
header = json.loads(bin_bytes[9 : 9 + header_size].decode("utf-8"))
encrypted_payload = bin_bytes[9 + header_size :]
nonce = header["nonce"]
normalized_nonce = nonce + ("=" * (-len(nonce) % 4))
raw_nonce = base64.urlsafe_b64decode(normalized_nonce)
payload_bytes = AESGCM(BackupExportService._read_encryption_key()).decrypt(
raw_nonce,
encrypted_payload,
BackupExportService.AAD,
)
payload = json.loads(zlib.decompress(payload_bytes).decode("utf-8"))
return header, payload
@override_settings(BACKUP_ENCRYPTION_KEY=TEST_BACKUP_KEY, BACKUP_KEY_ID="test-key")
class BackupExportServiceTest(TestCase):
def test_build_backup_archive_raises_when_no_active_organizations(self):
with self.assertRaisesMessage(
BackupExportError,
"Нет актуальных организаций для экспорта",
):
BackupExportService.build_backup_archive(actual_date=date(2026, 3, 1))
def test_build_backup_archive_exports_zip_and_payload(self):
registry = RegisterFactory(name="Main registry")
active_upload = RegisterUploadFactory(
registry=registry,
actual_date=date(2026, 3, 1),
)
organization = OrganizationFactory(
pn_name="Active Org",
mn_ogrn=10_277_001_189_840,
mn_inn=7_702_000_000,
mn_okpo="12345678",
)
RegistryMembershipPeriodFactory(
registry=registry,
organization=organization,
started_at=date(2026, 3, 1),
started_by_upload=active_upload,
)
inactive_org = OrganizationFactory(
pn_name="Inactive Org",
mn_ogrn=10_277_001_189_841,
mn_inn=7_702_000_001,
mn_okpo="87654321",
)
old_upload = RegisterUploadFactory(
registry=registry,
actual_date=date(2026, 2, 1),
)
RegistryMembershipPeriodFactory(
registry=registry,
organization=inactive_org,
started_at=date(2026, 2, 1),
ended_at=date(2026, 2, 20),
started_by_upload=old_upload,
ended_by_upload=active_upload,
)
IndustrialCertificateRecordFactory(
registry_organization=organization,
inn=str(organization.mn_inn),
ogrn=str(organization.mn_ogrn),
)
ManufacturerRecordFactory(
registry_organization=organization,
inn=str(organization.mn_inn),
ogrn=str(organization.mn_ogrn),
)
InspectionRecordFactory(
registry_organization=organization,
inn=str(organization.mn_inn),
ogrn=str(organization.mn_ogrn),
)
ProcurementRecordFactory(
registry_organization=organization,
customer_inn=str(organization.mn_inn),
customer_ogrn=str(organization.mn_ogrn),
)
report = FinancialReport.objects.create(
external_id="100500",
ogrn=str(organization.mn_ogrn),
registry_organization=organization,
file_name="fin_100500_10277001189840.xlsx",
file_hash="f" * 64,
load_batch=1,
status=FinancialReport.Status.SUCCESS,
source=FinancialReport.SourceType.API,
)
FinancialReportLine.objects.create(
report=report,
form_code="1",
line_code="1100",
line_name="Assets",
year=2025,
period_start=100,
period_end=200,
)
artifact = BackupExportService.build_backup_archive(actual_date=date(2026, 3, 15))
self.assertIsInstance(artifact, BackupArtifact)
self.assertEqual(artifact.organizations_count, 1)
self.assertEqual(artifact.actual_date, date(2026, 3, 15))
self.assertTrue(artifact.archive_filename.endswith(".zip"))
self.assertTrue(artifact.bin_filename.endswith(".bin"))
self.assertTrue(artifact.checksum_filename.endswith(".sha256"))
with ZipFile(BytesIO(artifact.archive_bytes)) as archive:
self.assertEqual(
sorted(archive.namelist()),
sorted([artifact.bin_filename, artifact.checksum_filename]),
)
checksum_content = archive.read(artifact.checksum_filename).decode("utf-8")
bin_bytes = archive.read(artifact.bin_filename)
self.assertTrue(bin_bytes.startswith(BackupExportService.MAGIC))
self.assertIn(artifact.checksum_sha256, checksum_content)
self.assertIn(artifact.bin_filename, checksum_content)
header, payload = _decode_backup_payload(bin_bytes)
self.assertEqual(header["format"], "mostovik-backup-bin")
self.assertEqual(header["key_id"], "test-key")
self.assertEqual(payload["format"], "mostovik-backup-payload")
self.assertEqual(payload["actual_date"], "2026-03-15")
self.assertEqual(payload["organizations_count"], 1)
self.assertEqual(len(payload["data"]["registers.Organization"]), 1)
self.assertEqual(payload["data"]["registers.Organization"][0]["pn_name"], "Active Org")
self.assertEqual(len(payload["data"]["parsers.FinancialReportLine"]), 1)
def test_normalize_value_supports_scalar_types(self):
random_uuid = uuid4()
self.assertEqual(
BackupExportService._normalize_value(date(2026, 3, 15)),
"2026-03-15",
)
self.assertEqual(
BackupExportService._normalize_value(datetime(2026, 3, 15, 12, 30, 0)),
"2026-03-15T12:30:00",
)
self.assertEqual(
BackupExportService._normalize_value(Decimal("12.34")),
"12.34",
)
self.assertEqual(
BackupExportService._normalize_value(random_uuid),
str(random_uuid),
)
self.assertEqual(
BackupExportService._normalize_value(b"payload"),
{
"__type__": "bytes",
"base64": base64.b64encode(b"payload").decode("ascii"),
},
)
self.assertEqual(BackupExportService._normalize_value("plain"), "plain")
@override_settings(BACKUP_ENCRYPTION_KEY="")
def test_read_encryption_key_requires_setting(self):
with self.assertRaisesMessage(
BackupExportError,
"Не задан BACKUP_ENCRYPTION_KEY в настройках",
):
BackupExportService._read_encryption_key()
def test_read_encryption_key_rejects_invalid_base64(self):
with patch(
"apps.backups.services.base64.urlsafe_b64decode",
side_effect=ValueError("bad base64"),
):
with self.assertRaisesMessage(
BackupExportError,
"BACKUP_ENCRYPTION_KEY должен быть base64-url кодированным ключом",
):
BackupExportService._read_encryption_key()
@override_settings(
BACKUP_ENCRYPTION_KEY=base64.urlsafe_b64encode(b"short").decode("ascii")
)
def test_read_encryption_key_requires_32_bytes(self):
with self.assertRaisesMessage(
BackupExportError,
"BACKUP_ENCRYPTION_KEY после декодирования должен быть 32 байта",
):
BackupExportService._read_encryption_key()
def test_build_bin_container_rejects_oversized_header(self):
class HugeBytes(bytes):
def __len__(self):
return 2**32
class HugeJson(str):
def encode(self, *_args, **_kwargs):
return HugeBytes(b"{}")
with patch("apps.backups.services.json.dumps", return_value=HugeJson("{}")):
with self.assertRaisesMessage(
BackupExportError,
"Заголовок backup контейнера слишком большой",
):
BackupExportService._build_bin_container(
encrypted_payload=b"payload",
header_payload={},
)
class BackupExportJobServiceTest(TestCase):
def test_result_for_existing_job_returns_wait_and_download(self):
today = date(2026, 3, 15)
pending_job = BackupExportJob.objects.create(
actual_date=today,
status=BackupExportJob.Status.PENDING,
task_id="task-pending",
)
wait_result = BackupExportJobService._result_for_existing_job(
actual_date=today,
job=pending_job,
)
self.assertEqual(wait_result.action, "wait")
self.assertEqual(wait_result.task_id, "task-pending")
with TemporaryDirectory() as tmp_dir:
archive_path = Path(tmp_dir) / "backup.zip"
archive_path.write_bytes(b"zip")
pending_job.status = BackupExportJob.Status.SUCCESS
pending_job.archive_path = str(archive_path)
pending_job.save(update_fields=["status", "archive_path", "updated_at"])
download_result = BackupExportJobService._result_for_existing_job(
actual_date=today,
job=pending_job,
)
self.assertEqual(download_result.action, "download")
def test_enqueue_backup_task_calls_celery(self):
with patch("apps.backups.tasks.generate_backup_for_date.apply_async") as apply_async:
BackupExportJobService._enqueue_backup_task(job_id=5, task_id="task-5")
apply_async.assert_called_once_with(kwargs={"job_id": 5}, task_id="task-5")
def test_consume_ready_archive_raises_when_job_missing_or_not_ready(self):
with self.assertRaisesMessage(BackupExportError, "Задача бэкапа не найдена"):
BackupExportJobService.consume_ready_archive(actual_date=date(2026, 3, 20))
job = BackupExportJob.objects.create(
actual_date=date(2026, 3, 21),
status=BackupExportJob.Status.STARTED,
task_id="task-started",
)
with self.assertRaisesMessage(BackupExportError, "Бэкап еще не готов"):
BackupExportJobService.consume_ready_archive(actual_date=job.actual_date)
def test_consume_ready_archive_deletes_job_when_file_missing(self):
job = BackupExportJob.objects.create(
actual_date=date(2026, 3, 22),
status=BackupExportJob.Status.SUCCESS,
task_id="task-success",
archive_path="/tmp/does-not-exist.zip",
)
with self.assertRaisesMessage(
BackupExportError,
"Файл бэкапа отсутствует, запустите формирование снова",
):
BackupExportJobService.consume_ready_archive(actual_date=job.actual_date)
self.assertTrue(BackupExportJob.objects.filter(id=job.id).exists())
def test_consume_ready_archive_reads_file_and_uses_path_name_as_fallback(self):
with TemporaryDirectory() as tmp_dir:
archive_path = Path(tmp_dir) / "backup-export.zip"
archive_bytes = b"archive-bytes"
archive_path.write_bytes(archive_bytes)
job = BackupExportJob.objects.create(
actual_date=date(2026, 3, 23),
status=BackupExportJob.Status.SUCCESS,
task_id="task-success-2",
archive_path=str(archive_path),
archive_filename="",
checksum_filename="backup-export.zip.sha256",
organizations_count=3,
)
artifact = BackupExportJobService.consume_ready_archive(actual_date=job.actual_date)
self.assertEqual(artifact.archive_bytes, archive_bytes)
self.assertEqual(artifact.archive_filename, "backup-export.zip")
self.assertEqual(artifact.organizations_count, 3)
self.assertFalse(BackupExportJob.objects.filter(id=job.id).exists())
def test_check_or_start_job_replaces_stale_failed_job(self):
today = date(2026, 3, 24)
user = UserFactory.create_user()
with TemporaryDirectory() as tmp_dir:
stale_path = Path(tmp_dir) / "stale.zip"
stale_path.write_bytes(b"stale")
stale_job = BackupExportJob.objects.create(
actual_date=today,
status=BackupExportJob.Status.FAILURE,
task_id="stale-task",
archive_path=str(stale_path),
)
with patch("apps.backups.services.uuid.uuid4", return_value="new-task-id"):
with patch.object(
BackupExportJobService,
"_enqueue_backup_task",
) as enqueue_mock:
with self.captureOnCommitCallbacks(execute=True):
result = BackupExportJobService.check_or_start_job(
actual_date=today,
requested_by_id=user.id,
)
self.assertEqual(result.action, "started")
self.assertEqual(result.task_id, "new-task-id")
self.assertFalse(BackupExportJob.objects.filter(id=stale_job.id).exists())
self.assertFalse(stale_path.exists())
new_job = BackupExportJob.objects.get(actual_date=today)
self.assertEqual(new_job.task_id, "new-task-id")
enqueue_mock.assert_called_once_with(job_id=new_job.id, task_id="new-task-id")
def test_check_or_start_job_retries_create_after_integrity_error_with_stale_job(self):
today = date(2026, 3, 26)
user = UserFactory.create_user()
stale_job = BackupExportJob.objects.create(
actual_date=today,
status=BackupExportJob.Status.FAILURE,
task_id="stale-task",
)
original_create = BackupExportJob.objects.create
def create_side_effect(*args, **kwargs):
if not hasattr(create_side_effect, "called"):
create_side_effect.called = True
raise IntegrityError("duplicate")
return original_create(*args, **kwargs)
with patch.object(
BackupExportJobService,
"_get_job_for_update",
side_effect=[None, stale_job],
):
with patch.object(
BackupExportJobService,
"_result_for_existing_job",
return_value=None,
):
with patch(
"apps.backups.services.BackupExportJob.objects.create",
side_effect=create_side_effect,
):
with patch(
"apps.backups.services.uuid.uuid4",
return_value="retry-task-id",
):
with patch.object(
BackupExportJobService,
"_enqueue_backup_task",
) as enqueue_mock:
with self.captureOnCommitCallbacks(execute=True):
result = BackupExportJobService.check_or_start_job(
actual_date=today,
requested_by_id=user.id,
)
self.assertEqual(result.action, "started")
self.assertEqual(result.task_id, "retry-task-id")
self.assertFalse(BackupExportJob.objects.filter(id=stale_job.id).exists())
new_job = BackupExportJob.objects.get(actual_date=today)
self.assertEqual(new_job.task_id, "retry-task-id")
enqueue_mock.assert_called_once_with(job_id=new_job.id, task_id="retry-task-id")
def test_check_or_start_job_retries_create_after_integrity_error_without_concurrent_job(self):
today = date(2026, 3, 28)
user = UserFactory.create_user()
original_create = BackupExportJob.objects.create
def create_side_effect(*args, **kwargs):
if not hasattr(create_side_effect, "called"):
create_side_effect.called = True
raise IntegrityError("duplicate")
return original_create(*args, **kwargs)
with patch.object(
BackupExportJobService,
"_get_job_for_update",
side_effect=[None, None],
):
with patch.object(
BackupExportJobService,
"_result_for_existing_job",
return_value=None,
):
with patch(
"apps.backups.services.BackupExportJob.objects.create",
side_effect=create_side_effect,
):
with patch(
"apps.backups.services.uuid.uuid4",
return_value="retry-task-id-2",
):
with patch.object(
BackupExportJobService,
"_enqueue_backup_task",
) as enqueue_mock:
with self.captureOnCommitCallbacks(execute=True):
result = BackupExportJobService.check_or_start_job(
actual_date=today,
requested_by_id=user.id,
)
self.assertEqual(result.action, "started")
self.assertEqual(result.task_id, "retry-task-id-2")
new_job = BackupExportJob.objects.get(actual_date=today)
self.assertEqual(new_job.task_id, "retry-task-id-2")
enqueue_mock.assert_called_once_with(job_id=new_job.id, task_id="retry-task-id-2")
def test_archive_exists_and_cleanup_job_artifact(self):
with TemporaryDirectory() as tmp_dir:
archive_path = Path(tmp_dir) / "backup.zip"
archive_path.write_bytes(b"zip")
job = BackupExportJob.objects.create(
actual_date=date(2026, 3, 25),
status=BackupExportJob.Status.SUCCESS,
task_id="task-cleanup",
archive_path=str(archive_path),
)
self.assertTrue(BackupExportJobService._archive_exists(job))
BackupExportJobService._cleanup_job_artifact(job)
self.assertFalse(archive_path.exists())
def test_cleanup_job_artifact_is_noop_without_archive_path(self):
job = BackupExportJob.objects.create(
actual_date=date(2026, 3, 27),
status=BackupExportJob.Status.FAILURE,
task_id="task-no-artifact",
archive_path="",
)
BackupExportJobService._cleanup_job_artifact(job)
self.assertTrue(BackupExportJob.objects.filter(id=job.id).exists())

View File

@@ -0,0 +1,122 @@
from __future__ import annotations
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from apps.backups.models import BackupExportJob
from apps.backups.services import BackupArtifact
from apps.backups.tasks import _resolve_backup_target_path, generate_backup_for_date
from django.test import TestCase, override_settings
from tests.apps.user.factories import UserFactory
class BackupTasksTest(TestCase):
def test_resolve_backup_target_path_creates_directory_and_renames_existing_file(self):
with TemporaryDirectory() as tmp_dir:
with override_settings(BACKUP_EXPORT_DIRECTORY=tmp_dir):
existing = Path(tmp_dir) / "backup.zip"
existing.write_bytes(b"existing")
with patch("apps.backups.tasks.uuid.uuid4") as uuid_mock:
uuid_mock.return_value.hex = "deadbeefcafebabe"
target = _resolve_backup_target_path("backup.zip")
self.assertEqual(target.name, "backup_deadbeef.zip")
def test_generate_backup_for_date_returns_skipped_when_job_is_missing(self):
generate_backup_for_date.push_request(id="task-missing")
try:
result = generate_backup_for_date.run(job_id=999999)
finally:
generate_backup_for_date.pop_request()
self.assertEqual(result, {"status": "skipped", "reason": "job_not_found"})
def test_generate_backup_for_date_builds_archive_and_updates_job(self):
user = UserFactory.create_user()
job = BackupExportJob.objects.create(
actual_date=user.date_joined.date(),
requested_by=user,
status=BackupExportJob.Status.PENDING,
)
background_job = MagicMock()
artifact = BackupArtifact(
archive_bytes=b"zip-bytes",
archive_filename="backup.zip",
bin_filename="backup.bin",
checksum_filename="backup.zip.sha256",
checksum_sha256="a" * 64,
organizations_count=5,
actual_date=job.actual_date,
)
with TemporaryDirectory() as tmp_dir:
with override_settings(BACKUP_EXPORT_DIRECTORY=tmp_dir):
generate_backup_for_date.push_request(id="task-success")
try:
with patch(
"apps.backups.tasks.BackgroundJobService.get_by_task_id_or_none",
return_value=None,
):
with patch(
"apps.backups.tasks.BackgroundJobService.create_job",
return_value=background_job,
) as create_job_mock:
with patch(
"apps.backups.tasks.BackupExportService.build_backup_archive",
return_value=artifact,
) as build_mock:
result = generate_backup_for_date.run(job_id=job.id)
finally:
generate_backup_for_date.pop_request()
job.refresh_from_db()
self.assertEqual(job.status, BackupExportJob.Status.SUCCESS)
self.assertEqual(job.task_id, "task-success")
self.assertEqual(job.archive_filename, "backup.zip")
self.assertEqual(job.checksum_filename, "backup.zip.sha256")
self.assertEqual(job.organizations_count, 5)
self.assertTrue(Path(job.archive_path).is_file())
self.assertEqual(result["status"], "success")
self.assertEqual(result["archive_filename"], "backup.zip")
create_job_mock.assert_called_once()
build_mock.assert_called_once_with(actual_date=job.actual_date)
background_job.mark_started.assert_called_once_with()
background_job.update_progress.assert_any_call(10, "Подготовка backup-данных")
background_job.update_progress.assert_any_call(70, "Запись архива на диск")
background_job.complete.assert_called_once_with(result=result)
def test_generate_backup_for_date_marks_failure(self):
user = UserFactory.create_user()
job = BackupExportJob.objects.create(
actual_date=user.date_joined.date(),
requested_by=user,
status=BackupExportJob.Status.PENDING,
)
background_job = MagicMock()
generate_backup_for_date.push_request(id="task-failure")
try:
with patch(
"apps.backups.tasks.BackgroundJobService.get_by_task_id_or_none",
return_value=background_job,
):
with patch(
"apps.backups.tasks.BackupExportService.build_backup_archive",
side_effect=RuntimeError("boom"),
):
with patch("apps.backups.tasks.logger.exception") as logger_mock:
with self.assertRaisesMessage(RuntimeError, "boom"):
generate_backup_for_date.run(job_id=job.id)
finally:
generate_backup_for_date.pop_request()
logger_mock.assert_called_once()
background_job.fail.assert_called_once_with(error="boom")
job.refresh_from_db()
self.assertEqual(job.status, BackupExportJob.Status.FAILURE)
self.assertEqual(job.error, "boom")

View File

@@ -5,10 +5,14 @@ from __future__ import annotations
import hashlib
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import Mock, patch
from unittest.mock import patch
from apps.backups.models import BackupExportJob
from apps.backups.services import BackupExportJobService
from apps.backups.services import (
BackupExportError,
BackupExportJobService,
BackupRequestResult,
)
from django.db import IntegrityError
from django.urls import reverse
from django.utils import timezone
@@ -34,29 +38,27 @@ class BackupExportViewTest(APITestCase):
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@patch("apps.backups.tasks.generate_backup_for_date.delay")
def test_export_starts_job_when_absent(self, delay_mock):
delay_mock.return_value = Mock(id="task-backup-1")
@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()
response = self.client.post(
self.url,
{"actual_date": today.isoformat()},
format="json",
)
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")
self.assertEqual(response.data["task_id"], "task-backup-1")
job = BackupExportJob.objects.get(actual_date=today)
self.assertEqual(job.status, BackupExportJob.Status.PENDING)
self.assertEqual(job.task_id, "task-backup-1")
delay_mock.assert_called_once_with(job_id=job.id)
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.tasks.generate_backup_for_date.delay")
def test_export_returns_wait_when_job_in_progress(self, delay_mock):
@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,
@@ -74,10 +76,10 @@ class BackupExportViewTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
self.assertEqual(response.data["status"], "wait")
self.assertEqual(response.data["task_id"], "task-running-1")
delay_mock.assert_not_called()
enqueue_mock.assert_not_called()
@patch("apps.backups.tasks.generate_backup_for_date.delay")
def test_export_returns_file_and_deletes_after_download(self, delay_mock):
@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"
@@ -106,7 +108,9 @@ class BackupExportViewTest(APITestCase):
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.assertIn(
'attachment; filename="backup.zip"', response["Content-Disposition"]
)
self.assertEqual(response["X-Backup-Organizations"], "7")
self.assertEqual(
response["X-Backup-SHA256"],
@@ -115,11 +119,10 @@ class BackupExportViewTest(APITestCase):
self.assertFalse(archive_path.exists())
self.assertFalse(BackupExportJob.objects.filter(id=job.id).exists())
delay_mock.assert_not_called()
enqueue_mock.assert_not_called()
@patch("apps.backups.tasks.generate_backup_for_date.delay")
def test_export_restarts_when_success_job_has_no_file(self, delay_mock):
delay_mock.return_value = Mock(id="task-backup-retry")
@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"
@@ -132,16 +135,21 @@ class BackupExportViewTest(APITestCase):
)
self.client.force_authenticate(self.admin)
response = self.client.post(
self.url,
{"actual_date": today.isoformat()},
format="json",
)
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")
self.assertEqual(response.data["task_id"], "task-backup-retry")
delay_mock.assert_called_once()
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):
@@ -166,3 +174,64 @@ class BackupExportViewTest(APITestCase):
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"],
)