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
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:
@@ -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"""
|
||||
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user