feat: add parser source dashboard and scheduling
All checks were successful
CI/CD Pipeline / Code Quality Checks (pull_request) Successful in 1m6s
CI/CD Pipeline / Run Tests (pull_request) Successful in 1m18s
CI/CD Pipeline / Build Docker Images (pull_request) Has been skipped
CI/CD Pipeline / Push to Gitea Registry (pull_request) Has been skipped

This commit is contained in:
2026-04-27 23:36:28 +02:00
parent 199d871923
commit 44355deeb3
96 changed files with 15015 additions and 309 deletions

View File

@@ -1,5 +1,7 @@
"""Tests for core OpenAPI utilities"""
import json
from apps.core.openapi import (
CommonParameters,
CommonResponses,
@@ -41,6 +43,98 @@ class ApiDocsDecoratorTest(TestCase):
self.assertEqual(my_view.__name__, "my_view")
class OpenApiSchemaViewTest(TestCase):
"""Tests for generated Swagger schema."""
def _operation_tags(self, paths: dict) -> list[str]:
tags = []
for operations in paths.values():
for method, operation in operations.items():
if method == "parameters":
continue
tags.extend(operation.get("tags", []))
return tags
def test_schema_exposes_parser_and_job_tags(self):
"""Test dashboard/parser APIs are visible as separate Swagger groups."""
response = self.client.get("/?format=openapi")
self.assertEqual(response.status_code, 200)
schema = json.loads(response.content)
paths = schema["paths"]
self.assertIn("/api/v1/parsers/sources/", paths)
self.assertIn("/api/v1/parsers/records/", paths)
self.assertIn("/api/v1/parsers/run/{source_key}/", paths)
self.assertIn("/api/v1/parsers/upload/{source_key}/", paths)
self.assertIn("/api/v1/users/register/", paths)
self.assertIn("/api/v1/users/login/", paths)
self.assertIn("/api/v1/users/token/refresh/", paths)
self.assertIn("/api/v1/users/token/verify/", paths)
self.assertIn("/api/v1/users/me/", paths)
self.assertIn("/api/v1/fns/reports/", paths)
self.assertIn("/api/v1/fns/reports/{id}/", paths)
self.assertIn("/api/v1/fns/upload/", paths)
self.assertIn("/api/v1/minpromtorg/manufacturers/", paths)
self.assertIn("/api/v1/zakupki/", paths)
self.assertIn("/api/v1/zakupki/{id}/", paths)
self.assertIn("/api/v1/jobs/", paths)
self.assertNotIn("/api/v1/zakupki/procurements-44fz/", paths)
self.assertNotIn("/api/v1/zakupki/procurements-223fz/", paths)
self.assertNotIn("/api/v1/zakupki/contracts/", paths)
self.assertNotIn("/api/v1/zakupki/upload/", paths)
self.assertNotIn("/api/v2/fns/reports/", paths)
self.assertEqual(
paths["/api/v1/parsers/sources/"]["get"]["tags"],
["Parser Management"],
)
self.assertEqual(
paths["/api/v1/parsers/upload/{source_key}/"]["post"]["tags"],
["Parser Management"],
)
self.assertEqual(
paths["/api/v1/fns/reports/"]["get"]["tags"],
["FNS"],
)
self.assertEqual(
paths["/api/v1/fns/reports/{id}/"]["get"]["tags"],
["FNS"],
)
self.assertEqual(
paths["/api/v1/fns/upload/"]["post"]["tags"],
["FNS"],
)
self.assertEqual(
paths["/api/v1/minpromtorg/manufacturers/"]["get"]["tags"],
["Minpromtorg"],
)
self.assertEqual(
paths["/api/v1/zakupki/"]["get"]["tags"],
["EIS Zakupki"],
)
for path in (
"/api/v1/users/register/",
"/api/v1/users/login/",
"/api/v1/users/token/refresh/",
"/api/v1/users/token/verify/",
"/api/v1/users/me/",
):
operation = (
paths[path]["post"] if "post" in paths[path] else paths[path]["get"]
)
self.assertEqual(operation["tags"], ["Users"])
for path in (
"/api/v1/users/register/",
"/api/v1/users/login/",
"/api/v1/users/token/refresh/",
"/api/v1/users/token/verify/",
):
self.assertEqual(paths[path]["post"]["security"], [])
self.assertEqual(paths["/api/v1/jobs/"]["get"]["tags"], ["Jobs"])
for tag in self._operation_tags(paths):
self.assertFalse(any("\u0400" <= char <= "\u04ff" for char in tag), tag)
self.assertIn("Bearer", schema["securityDefinitions"])
class GetStatusDescriptionTest(TestCase):
"""Tests for _get_status_description function"""

View File

@@ -1,7 +1,7 @@
"""Tests for core services"""
from apps.core.exceptions import NotFoundError
from apps.core.services import BaseService
from apps.core.services import BaseService, BulkOperationsMixin
from django.contrib.auth import get_user_model
from django.test import TestCase
@@ -14,6 +14,12 @@ class UserTestService(BaseService[User]):
model = User
class UserBulkTestService(BulkOperationsMixin, BaseService[User]):
"""Test bulk service using User model"""
model = User
class BaseServiceTest(TestCase):
"""Tests for BaseService"""
@@ -21,7 +27,7 @@ class BaseServiceTest(TestCase):
self.user = User.objects.create_user(
username="testuser",
email="test@example.com",
password="testpass123",
password="testpass123", # noqa: S106
)
def test_get_by_id_success(self):
@@ -53,7 +59,7 @@ class BaseServiceTest(TestCase):
User.objects.create_user(
username="testuser2",
email="test2@example.com",
password="testpass123",
password="testpass123", # noqa: S106
)
result = UserTestService.get_all()
@@ -101,3 +107,24 @@ class BaseServiceTest(TestCase):
UserTestService.delete(self.user)
self.assertFalse(User.objects.filter(pk=user_pk).exists())
class BulkOperationsMixinTest(TestCase):
"""Tests for bulk operation helpers."""
def test_bulk_create_rejects_update_conflicts_on_django_3(self):
"""Test Django 4-only bulk upsert API is not exposed as working."""
instances = [
User(
username="bulk-user",
email="bulk@example.com",
)
]
with self.assertRaises(NotImplementedError):
UserBulkTestService.bulk_create_chunked(
instances,
update_conflicts=True,
update_fields=["email"],
unique_fields=["username"],
)

View File

@@ -1,9 +1,16 @@
"""Tests for core views (health checks)"""
"""Tests for core views and API endpoints."""
from unittest.mock import patch
from apps.core.models import JobStatus
from apps.core.services import BackgroundJobService
from apps.user.services import UserService
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from tests.apps.user.factories import UserFactory
class HealthCheckViewTest(APITestCase):
"""Tests for HealthCheckView"""
@@ -99,3 +106,121 @@ class APIVersioningURLTest(APITestCase):
"""Test reverse URL for password change"""
url = reverse("api_v1:user:password_change")
self.assertEqual(url, "/api/v1/users/password/change/")
class BackgroundJobApiTest(APITestCase):
"""Tests for background job API access rules."""
def setUp(self):
self.user = UserFactory.create_user()
tokens = UserService.get_tokens_for_user(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {tokens['access']}")
def test_job_status_denies_other_user_job(self):
"""Test users cannot read jobs owned by another user."""
other_user = UserFactory.create_user()
job = BackgroundJobService.create_job(
task_id="task-other-user",
task_name="test.task",
user_id=other_user.id,
)
response = self.client.get(
reverse("api_v1:jobs:job-status", kwargs={"task_id": job.task_id})
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_job_status_denies_system_job_for_regular_user(self):
"""Test user_id=NULL jobs are not visible to regular users."""
job = BackgroundJobService.create_job(
task_id="task-system",
task_name="test.task",
)
response = self.client.get(
reverse("api_v1:jobs:job-status", kwargs={"task_id": job.task_id})
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_job_status_allows_system_job_for_staff(self):
"""Test staff can inspect system jobs."""
staff = UserFactory.create_user(is_staff=True)
tokens = UserService.get_tokens_for_user(staff)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {tokens['access']}")
job = BackgroundJobService.create_job(
task_id="task-system-staff",
task_name="test.task",
)
response = self.client.get(
reverse("api_v1:jobs:job-status", kwargs={"task_id": job.task_id})
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_job_list_rejects_invalid_limit(self):
"""Test job list validates limit query parameter."""
response = self.client.get(
reverse("api_v1:jobs:job-list"),
{"limit": "bad"},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@patch("apps.core.views.current_app.control.revoke")
def test_job_control_revoke_marks_user_job_revoked(self, mock_revoke):
"""Test owner can revoke a running background job."""
job = BackgroundJobService.create_job(
task_id="task-revoke",
task_name="test.task",
user_id=self.user.id,
)
job.mark_started()
response = self.client.post(
reverse("api_v1:jobs:job-control", kwargs={"task_id": job.task_id}),
{"action": "revoke"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
mock_revoke.assert_called_once_with(job.task_id, terminate=True)
job.refresh_from_db()
self.assertEqual(job.status, JobStatus.REVOKED)
def test_job_control_denies_finished_job(self):
"""Test finished jobs cannot be revoked again."""
job = BackgroundJobService.create_job(
task_id="task-finished",
task_name="test.task",
user_id=self.user.id,
)
job.complete()
response = self.client.post(
reverse("api_v1:jobs:job-control", kwargs={"task_id": job.task_id}),
{"action": "revoke"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_job_stream_returns_completed_sse_event(self):
"""Test dev-compatible SSE job stream endpoint."""
job = BackgroundJobService.create_job(
task_id="task-stream",
task_name="test.task",
user_id=self.user.id,
)
job.complete(result={"ok": True})
response = self.client.get(
reverse("api_v1:jobs:job-stream", kwargs={"task_id": job.task_id})
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
content = b"".join(response.streaming_content).decode("utf-8")
self.assertIn("event: completed", content)
self.assertIn('"task_id": "task-stream"', content)