Add periodic exchange task management API
Some checks failed
CI/CD Pipeline / Code Quality Checks (push) Successful in 3m16s
CI/CD Pipeline / Run Tests (push) Successful in 3m26s
CI/CD Pipeline / Telegram Notify Success (push) Failing after 1m29s
CI/CD Pipeline / Run Tests (pull_request) Successful in 1m44s
CI/CD Pipeline / Code Quality Checks (pull_request) Successful in 20m19s
CI/CD Pipeline / Telegram Notify Success (pull_request) Failing after 1m34s

This commit is contained in:
2026-03-19 17:03:47 +01:00
parent 941c268d32
commit 3de66cc25c
8 changed files with 867 additions and 24 deletions

View File

@@ -1,6 +1,11 @@
"""Tests for exchange serializers."""
from apps.exchange.serializers import ExchangeCopyRequestSerializer
from types import SimpleNamespace
from apps.exchange.serializers import (
ExchangeCopyRequestSerializer,
ExchangePeriodicTaskUpsertSerializer,
)
from django.test import SimpleTestCase
@@ -27,3 +32,61 @@ class ExchangeCopyRequestSerializerTest(SimpleTestCase):
)
self.assertFalse(serializer_with_tables.is_valid())
self.assertIn("tables", serializer_with_tables.errors)
class ExchangePeriodicTaskUpsertSerializerTest(SimpleTestCase):
def test_interval_schedule_requires_fields(self):
serializer = ExchangePeriodicTaskUpsertSerializer(
data={"name": "copy-job", "schedule_type": "interval"}
)
self.assertFalse(serializer.is_valid())
self.assertIn("interval_every", serializer.errors)
self.assertIn("interval_period", serializer.errors)
def test_crontab_schedule_requires_fields(self):
serializer = ExchangePeriodicTaskUpsertSerializer(
data={"name": "copy-job", "schedule_type": "crontab"}
)
self.assertFalse(serializer.is_valid())
self.assertIn("crontab_minute", serializer.errors)
self.assertIn("crontab_hour", serializer.errors)
def test_update_mode_to_all_clears_old_single_table(self):
instance = SimpleNamespace(
interval_id=1,
interval=SimpleNamespace(every=5, period="minutes"),
crontab_id=None,
kwargs='{"payload": {"mode": "single", "table": "old_table"}}',
)
serializer = ExchangePeriodicTaskUpsertSerializer(
instance,
data={"mode": "all"},
partial=True,
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertEqual(
serializer.validated_data["payload"],
{
"mode": "all",
"table": None,
"tables": None,
"truncate_before_copy": True,
},
)
def test_periodic_task_uses_copy_payload_validation(self):
serializer = ExchangePeriodicTaskUpsertSerializer(
data={
"name": "copy-job",
"schedule_type": "interval",
"interval_every": 1,
"interval_period": "hours",
"mode": "single",
}
)
self.assertFalse(serializer.is_valid())
self.assertIn("table", serializer.errors)

View File

@@ -2,11 +2,51 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch
from apps.exchange.tasks import copy_parsers_data_async
from apps.exchange.services import ExchangeServiceError
from apps.exchange.tasks import copy_parsers_data_async, dispatch_periodic_exchange_copy
from django.test import SimpleTestCase
class ExchangeTasksTest(SimpleTestCase):
def test_dispatch_periodic_exchange_copy_enqueues_copy_with_active_connection(self):
active_connection = MagicMock(id=15)
with patch(
"apps.exchange.tasks.ExchangeConnectionService.get_active_connection",
return_value=active_connection,
) as get_connection_mock, patch(
"apps.exchange.tasks.copy_parsers_data_async.delay",
return_value=MagicMock(id="task-15"),
) as delay_mock:
result = dispatch_periodic_exchange_copy.run(
payload={"mode": "all", "truncate_before_copy": True}
)
self.assertEqual(
result,
{
"status": "queued",
"task_id": "task-15",
"connection_id": 15,
},
)
get_connection_mock.assert_called_once_with()
delay_mock.assert_called_once_with(
connection_id=15,
payload={"mode": "all", "truncate_before_copy": True},
requested_by_id=None,
)
def test_dispatch_periodic_exchange_copy_fails_without_active_connection(self):
with patch(
"apps.exchange.tasks.ExchangeConnectionService.get_active_connection",
side_effect=ExchangeServiceError("Активное подключение не найдено"),
), self.assertRaisesMessage(
ExchangeServiceError,
"Активное подключение не найдено",
):
dispatch_periodic_exchange_copy.run(payload={"mode": "all"})
def test_copy_parsers_data_async_completes_with_existing_job(self):
background_job = MagicMock()
connection = MagicMock()

View File

@@ -1,11 +1,14 @@
"""Tests for exchange API views."""
import json
from types import SimpleNamespace
from unittest.mock import patch
from apps.exchange.models import ExchangeConnection
from apps.exchange.services import ExchangeServiceError
from apps.exchange.services import ExchangePeriodicTaskService, ExchangeServiceError
from django.conf import settings
from django.urls import reverse
from django_celery_beat.models import IntervalSchedule, PeriodicTask
from rest_framework import status
from rest_framework.test import APITestCase
@@ -20,6 +23,7 @@ class ExchangeViewsTest(APITestCase):
self.connections_url = reverse("api_v1:exchange:connections")
self.test_connection_url = reverse("api_v1:exchange:connections-test")
self.copy_url = reverse("api_v1:exchange:copy")
self.periodic_tasks_url = reverse("api_v1:exchange:periodic-tasks")
def test_connections_endpoint_admin_only(self):
response = self.client.get(self.connections_url)
@@ -164,3 +168,141 @@ class ExchangeViewsTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("table", str(response.data))
def test_periodic_tasks_endpoint_admin_only(self):
response = self.client.get(self.periodic_tasks_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
self.client.force_authenticate(self.user)
response = self.client.get(self.periodic_tasks_url)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.client.force_authenticate(self.admin)
response = self.client.get(self.periodic_tasks_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data["success"])
self.assertEqual(response.data["data"], [])
def test_create_periodic_interval_task_success(self):
payload = {
"name": "exchange-copy-hourly",
"description": "Hourly sync",
"enabled": True,
"schedule_type": "interval",
"interval_every": 1,
"interval_period": "hours",
"mode": "all",
}
self.client.force_authenticate(self.admin)
response = self.client.post(self.periodic_tasks_url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
task = PeriodicTask.objects.get(id=response.data["data"]["id"])
self.assertEqual(task.task, ExchangePeriodicTaskService.TASK_NAME)
self.assertEqual(response.data["data"]["schedule_type"], "interval")
self.assertEqual(response.data["data"]["interval_every"], 1)
self.assertEqual(response.data["data"]["interval_period"], "hours")
self.assertEqual(
json.loads(task.kwargs),
{
"payload": {
"mode": "all",
"table": None,
"tables": None,
"truncate_before_copy": True,
}
},
)
def test_list_periodic_tasks_returns_only_exchange_tasks(self):
interval = IntervalSchedule.objects.create(every=1, period="hours")
PeriodicTask.objects.create(
name="exchange-copy-hourly",
task=ExchangePeriodicTaskService.TASK_NAME,
interval=interval,
kwargs=json.dumps(
{"payload": {"mode": "all", "truncate_before_copy": True}}
),
)
PeriodicTask.objects.create(
name="another-task",
task="apps.parsers.tasks.fake_task",
interval=interval,
kwargs="{}",
)
self.client.force_authenticate(self.admin)
response = self.client.get(self.periodic_tasks_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data["data"]), 1)
self.assertEqual(response.data["data"][0]["name"], "exchange-copy-hourly")
def test_update_periodic_task_switches_to_crontab(self):
interval = IntervalSchedule.objects.create(every=1, period="hours")
task = PeriodicTask.objects.create(
name="exchange-copy-hourly",
task=ExchangePeriodicTaskService.TASK_NAME,
description="Hourly sync",
enabled=True,
interval=interval,
kwargs=json.dumps(
{
"payload": {
"mode": "all",
"table": None,
"tables": None,
"truncate_before_copy": True,
}
}
),
)
detail_url = reverse(
"api_v1:exchange:periodic-task-detail",
kwargs={"task_id": task.id},
)
payload = {
"schedule_type": "crontab",
"crontab_minute": "0",
"crontab_hour": "4",
"crontab_day_of_week": "*",
"crontab_day_of_month": "*",
"crontab_month_of_year": "*",
"mode": "single",
"table": "parsers_proxy",
"enabled": False,
}
self.client.force_authenticate(self.admin)
response = self.client.patch(detail_url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
task.refresh_from_db()
self.assertIsNone(task.interval)
self.assertIsNotNone(task.crontab)
self.assertEqual(str(task.crontab.timezone), settings.TIME_ZONE)
self.assertFalse(task.enabled)
self.assertEqual(response.data["data"]["schedule_type"], "crontab")
self.assertEqual(response.data["data"]["crontab_hour"], "4")
self.assertEqual(response.data["data"]["mode"], "single")
self.assertEqual(response.data["data"]["table"], "parsers_proxy")
self.assertFalse(IntervalSchedule.objects.filter(id=interval.id).exists())
def test_periodic_task_detail_returns_404_for_non_exchange_task(self):
interval = IntervalSchedule.objects.create(every=1, period="hours")
task = PeriodicTask.objects.create(
name="another-task",
task="apps.parsers.tasks.fake_task",
interval=interval,
kwargs="{}",
)
detail_url = reverse(
"api_v1:exchange:periodic-task-detail",
kwargs={"task_id": task.id},
)
self.client.force_authenticate(self.admin)
response = self.client.get(detail_url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)