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
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:
61
tests/apps/core/test_celery_module.py
Normal file
61
tests/apps/core/test_celery_module.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
CELERY_MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[3] / "src" / "core" / "celery.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module(module_name: str):
|
||||
spec = importlib.util.spec_from_file_location(module_name, CELERY_MODULE_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class CeleryModuleTest(SimpleTestCase):
|
||||
def test_import_requires_django_settings_module(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaisesMessage(
|
||||
RuntimeError,
|
||||
"DJANGO_SETTINGS_MODULE is not set.",
|
||||
):
|
||||
_load_module("isolated_core_celery_missing")
|
||||
|
||||
def test_import_runs_startup_checks_for_worker_runtime(self):
|
||||
app_mock = MagicMock()
|
||||
app_mock.conf = SimpleNamespace()
|
||||
|
||||
with patch.dict(os.environ, {"DJANGO_SETTINGS_MODULE": "settings.test"}, clear=True):
|
||||
with patch.object(sys, "argv", ["celery", "-A", "project", "worker"]):
|
||||
with patch("apps.core.startup_checks.run_startup_checks") as checks_mock:
|
||||
with patch("celery.Celery", return_value=app_mock):
|
||||
module = _load_module("isolated_core_celery_worker")
|
||||
|
||||
checks_mock.assert_called_once_with(component="celery")
|
||||
app_mock.config_from_object.assert_called_once_with(
|
||||
"django.conf:settings",
|
||||
namespace="CELERY",
|
||||
)
|
||||
app_mock.autodiscover_tasks.assert_called_once_with()
|
||||
self.assertEqual(module.app, app_mock)
|
||||
|
||||
def test_debug_task_prints_request(self):
|
||||
with patch.dict(os.environ, {"DJANGO_SETTINGS_MODULE": "settings.test"}, clear=True):
|
||||
with patch.object(sys, "argv", ["python", "manage.py", "shell"]):
|
||||
module = _load_module("isolated_core_celery_debug")
|
||||
|
||||
with patch("builtins.print") as print_mock:
|
||||
module.debug_task.run()
|
||||
|
||||
print_mock.assert_called_once()
|
||||
@@ -51,6 +51,12 @@ class CustomExceptionHandlerTest(SimpleTestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(len(response.data["errors"]), 2)
|
||||
|
||||
def test_validation_error_scalar_message(self):
|
||||
exc = ValidationError("plain error")
|
||||
response = custom_exception_handler(exc, self._context())
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["errors"][0]["message"], "plain error")
|
||||
|
||||
def test_unhandled_exception(self):
|
||||
response = custom_exception_handler(RuntimeError("boom"), self._context())
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
@@ -126,3 +126,13 @@ class FilterMixinTest(TestCase):
|
||||
self.assertIn(filters.DjangoFilterBackend, backends)
|
||||
self.assertIn(StandardSearchFilter, backends)
|
||||
self.assertIn(StandardOrderingFilter, backends)
|
||||
|
||||
def test_get_queryset_returns_super_queryset(self):
|
||||
class Parent:
|
||||
def get_queryset(self):
|
||||
return ["ok"]
|
||||
|
||||
class DummyFilterMixin(FilterMixin, Parent):
|
||||
pass
|
||||
|
||||
self.assertEqual(DummyFilterMixin().get_queryset(), ["ok"])
|
||||
|
||||
@@ -156,8 +156,7 @@ class BaseAppCommandTest(TestCase):
|
||||
cmd.silent = True
|
||||
|
||||
def generator():
|
||||
for idx in range(3):
|
||||
yield idx
|
||||
yield from range(3)
|
||||
|
||||
result = list(cmd.progress_iter(generator(), "Iter"))
|
||||
self.assertEqual(result, [0, 1, 2])
|
||||
|
||||
@@ -130,6 +130,9 @@ class MixinsBehaviorTest(TransactionTestCase):
|
||||
):
|
||||
name = models.CharField(max_length=50)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
app_label = "core"
|
||||
|
||||
|
||||
22
tests/apps/core/test_pagination.py
Normal file
22
tests/apps/core/test_pagination.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Tests for core pagination classes."""
|
||||
|
||||
from apps.core.pagination import StandardCursorPagination, StandardPagination
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
class PaginationSchemaTest(SimpleTestCase):
|
||||
def test_standard_pagination_schema(self):
|
||||
schema = StandardPagination().get_paginated_response_schema({"type": "array"})
|
||||
|
||||
self.assertEqual(schema["type"], "object")
|
||||
self.assertEqual(schema["properties"]["data"]["type"], "array")
|
||||
|
||||
def test_cursor_pagination_schema(self):
|
||||
schema = StandardCursorPagination().get_paginated_response_schema(
|
||||
{"type": "array"}
|
||||
)
|
||||
|
||||
self.assertEqual(schema["type"], "object")
|
||||
pagination = schema["properties"]["meta"]["properties"]["pagination"]["properties"]
|
||||
self.assertIn("next_cursor", pagination)
|
||||
self.assertIn("previous_cursor", pagination)
|
||||
@@ -101,6 +101,14 @@ class IsOwnerOrReadOnlyTest(TestCase):
|
||||
result = self.permission.has_object_permission(request, APIView(), obj)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_unsafe_methods_use_owner_fallback_field(self):
|
||||
request = self.factory.patch("/")
|
||||
request.user = self.user
|
||||
obj = MockObject(user=None, owner=self.user)
|
||||
|
||||
result = self.permission.has_object_permission(request, APIView(), obj)
|
||||
self.assertTrue(result)
|
||||
|
||||
|
||||
class IsAdminOrReadOnlyTest(TestCase):
|
||||
"""Tests for IsAdminOrReadOnly permission"""
|
||||
@@ -250,3 +258,11 @@ class IsOwnerOrAdminTest(TestCase):
|
||||
|
||||
result = self.permission.has_object_permission(request, APIView(), obj)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_owner_fallback_field_is_used_for_non_admin(self):
|
||||
request = self.factory.get("/")
|
||||
request.user = self.user
|
||||
obj = MockObject(user=None, owner=self.user)
|
||||
|
||||
result = self.permission.has_object_permission(request, APIView(), obj)
|
||||
self.assertTrue(result)
|
||||
|
||||
@@ -17,6 +17,10 @@ from django.test import TestCase
|
||||
from tests.utils.fixtures import fake
|
||||
|
||||
|
||||
def _password() -> str:
|
||||
return fake.password(length=12, special_chars=False)
|
||||
|
||||
|
||||
class SignalDispatcherTest(TestCase):
|
||||
def test_register_connect_disconnect(self):
|
||||
dispatcher = SignalDispatcher()
|
||||
@@ -34,7 +38,7 @@ class SignalDispatcherTest(TestCase):
|
||||
dispatcher.connect_all()
|
||||
|
||||
user = get_user_model().objects.create_user(
|
||||
email=fake.email(), username=fake.user_name(), password="pass"
|
||||
email=fake.email(), username=fake.user_name(), password=_password()
|
||||
)
|
||||
|
||||
self.assertIn(user.pk, events)
|
||||
@@ -81,7 +85,7 @@ class SignalDispatcherTest(TestCase):
|
||||
|
||||
signal_dispatcher.connect_all()
|
||||
user = get_user_model().objects.create_user(
|
||||
email=fake.email(), username=fake.user_name(), password="pass"
|
||||
email=fake.email(), username=fake.user_name(), password=_password()
|
||||
)
|
||||
self.assertIn(user.pk, events)
|
||||
self.assertTrue(signal_dispatcher.list_handlers())
|
||||
|
||||
206
tests/apps/core/test_startup_checks.py
Normal file
206
tests/apps/core/test_startup_checks.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.core import startup_checks
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
|
||||
|
||||
TEST_DATABASES = {
|
||||
"default": {
|
||||
"NAME": "mostovik",
|
||||
"USER": "postgres",
|
||||
"PASSWORD": "secret",
|
||||
"HOST": "db.example.test",
|
||||
"PORT": 5432,
|
||||
"OPTIONS": {"sslmode": "require"},
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
||||
"LOCATION": "redis://redis.example.test:6379/1",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StartupChecksTest(SimpleTestCase):
|
||||
@override_settings(DATABASES=TEST_DATABASES)
|
||||
@patch("apps.core.startup_checks.psycopg2.connect")
|
||||
def test_check_db_success(self, connect_mock):
|
||||
cursor = MagicMock()
|
||||
connection = MagicMock()
|
||||
connection.cursor.return_value.__enter__.return_value = cursor
|
||||
connect_mock.return_value = connection
|
||||
|
||||
success, message = startup_checks._check_db(7)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(message, "OK")
|
||||
connect_mock.assert_called_once_with(
|
||||
dbname="mostovik",
|
||||
user="postgres",
|
||||
password="secret",
|
||||
host="db.example.test",
|
||||
port=5432,
|
||||
connect_timeout=7,
|
||||
sslmode="require",
|
||||
)
|
||||
cursor.execute.assert_called_once_with("SELECT 1")
|
||||
cursor.fetchone.assert_called_once_with()
|
||||
connection.close.assert_called_once_with()
|
||||
|
||||
@override_settings(DATABASES=TEST_DATABASES)
|
||||
@patch(
|
||||
"apps.core.startup_checks.psycopg2.connect",
|
||||
side_effect=RuntimeError("database down"),
|
||||
)
|
||||
def test_check_db_failure(self, connect_mock):
|
||||
success, message = startup_checks._check_db(5)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertIn("db.example.test:5432/mostovik", message)
|
||||
self.assertIn("database down", message)
|
||||
connect_mock.assert_called_once()
|
||||
|
||||
@override_settings(CACHES=TEST_CACHES)
|
||||
@patch("apps.core.startup_checks.redis.Redis.from_url")
|
||||
def test_check_redis_success(self, from_url_mock):
|
||||
client = MagicMock()
|
||||
from_url_mock.return_value = client
|
||||
|
||||
success, message = startup_checks._check_redis(4)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(message, "OK")
|
||||
from_url_mock.assert_called_once_with(
|
||||
"redis://redis.example.test:6379/1",
|
||||
socket_connect_timeout=4,
|
||||
socket_timeout=4,
|
||||
)
|
||||
client.ping.assert_called_once_with()
|
||||
|
||||
@override_settings(CACHES=TEST_CACHES)
|
||||
@patch(
|
||||
"apps.core.startup_checks.redis.Redis.from_url",
|
||||
side_effect=RuntimeError("redis down"),
|
||||
)
|
||||
def test_check_redis_failure(self, from_url_mock):
|
||||
success, message = startup_checks._check_redis(6)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertIn("redis.example.test:6379/1", message)
|
||||
self.assertIn("redis down", message)
|
||||
from_url_mock.assert_called_once()
|
||||
|
||||
@override_settings(STARTUP_CHECKS_ENABLED=False)
|
||||
@patch("apps.core.startup_checks._check_db")
|
||||
@patch("apps.core.startup_checks._check_redis")
|
||||
def test_run_startup_checks_skips_when_disabled(
|
||||
self,
|
||||
redis_mock,
|
||||
db_mock,
|
||||
):
|
||||
startup_checks.run_startup_checks(component="web")
|
||||
|
||||
db_mock.assert_not_called()
|
||||
redis_mock.assert_not_called()
|
||||
|
||||
@override_settings(
|
||||
STARTUP_CHECKS_ENABLED=True,
|
||||
STARTUP_DB_TIMEOUT_SECONDS=9,
|
||||
STARTUP_REDIS_TIMEOUT_SECONDS=11,
|
||||
)
|
||||
@patch("apps.core.startup_checks._check_db", return_value=(True, "OK"))
|
||||
@patch("apps.core.startup_checks._check_redis", return_value=(True, "OK"))
|
||||
def test_run_startup_checks_success(self, redis_mock, db_mock):
|
||||
startup_checks.run_startup_checks(component="worker")
|
||||
|
||||
db_mock.assert_called_once_with(9)
|
||||
redis_mock.assert_called_once_with(11)
|
||||
|
||||
@override_settings(STARTUP_CHECKS_ENABLED=True, STARTUP_DB_TIMEOUT_SECONDS=8)
|
||||
@patch("apps.core.startup_checks._check_db", return_value=(False, "db failed"))
|
||||
@patch("apps.core.startup_checks._log")
|
||||
def test_run_startup_checks_exits_on_db_failure(self, log_mock, db_mock):
|
||||
with self.assertRaises(SystemExit) as error:
|
||||
startup_checks.run_startup_checks(component="wsgi")
|
||||
|
||||
self.assertEqual(error.exception.code, 1)
|
||||
db_mock.assert_called_once_with(8)
|
||||
log_mock.assert_called_once()
|
||||
self.assertIn("[startup:wsgi] DB check failed", log_mock.call_args.args[0])
|
||||
self.assertIn("db failed", log_mock.call_args.args[0])
|
||||
|
||||
@override_settings(
|
||||
STARTUP_CHECKS_ENABLED=True,
|
||||
STARTUP_DB_TIMEOUT_SECONDS=3,
|
||||
STARTUP_REDIS_TIMEOUT_SECONDS=12,
|
||||
)
|
||||
@patch("apps.core.startup_checks._check_db", return_value=(True, "OK"))
|
||||
@patch("apps.core.startup_checks._check_redis", return_value=(False, "redis failed"))
|
||||
@patch("apps.core.startup_checks._log")
|
||||
def test_run_startup_checks_exits_on_redis_failure(
|
||||
self,
|
||||
log_mock,
|
||||
redis_mock,
|
||||
db_mock,
|
||||
):
|
||||
with self.assertRaises(SystemExit) as error:
|
||||
startup_checks.run_startup_checks(component="asgi")
|
||||
|
||||
self.assertEqual(error.exception.code, 1)
|
||||
db_mock.assert_called_once_with(3)
|
||||
redis_mock.assert_called_once_with(12)
|
||||
self.assertIn("[startup:asgi] Redis check failed", log_mock.call_args.args[0])
|
||||
self.assertIn("redis failed", log_mock.call_args.args[0])
|
||||
|
||||
|
||||
class EntryPointImportTest(SimpleTestCase):
|
||||
def _import_fresh(self, module_name: str):
|
||||
sys.modules.pop("core", None)
|
||||
sys.modules.pop(module_name, None)
|
||||
return importlib.import_module(module_name)
|
||||
|
||||
def test_import_core_asgi_runs_startup_checks_and_sets_default_settings(self):
|
||||
sentinel_application = object()
|
||||
celery_stub = SimpleNamespace(app=object())
|
||||
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("DJANGO_SETTINGS_MODULE", None)
|
||||
with patch.dict(sys.modules, {"core.celery": celery_stub}):
|
||||
with patch("apps.core.startup_checks.run_startup_checks") as checks_mock:
|
||||
with patch(
|
||||
"django.core.asgi.get_asgi_application",
|
||||
return_value=sentinel_application,
|
||||
):
|
||||
module = self._import_fresh("core.asgi")
|
||||
|
||||
self.assertEqual(os.environ["DJANGO_SETTINGS_MODULE"], "settings.test")
|
||||
checks_mock.assert_called_once_with(component="asgi")
|
||||
self.assertIs(module.application, sentinel_application)
|
||||
sys.modules.pop("core.asgi", None)
|
||||
|
||||
def test_import_core_wsgi_runs_startup_checks_and_sets_default_settings(self):
|
||||
sentinel_application = object()
|
||||
celery_stub = SimpleNamespace(app=object())
|
||||
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("DJANGO_SETTINGS_MODULE", None)
|
||||
with patch.dict(sys.modules, {"core.celery": celery_stub}):
|
||||
with patch("apps.core.startup_checks.run_startup_checks") as checks_mock:
|
||||
with patch(
|
||||
"django.core.wsgi.get_wsgi_application",
|
||||
return_value=sentinel_application,
|
||||
):
|
||||
module = self._import_fresh("core.wsgi")
|
||||
|
||||
self.assertEqual(os.environ["DJANGO_SETTINGS_MODULE"], "settings.test")
|
||||
checks_mock.assert_called_once_with(component="wsgi")
|
||||
self.assertIs(module.application, sentinel_application)
|
||||
sys.modules.pop("core.wsgi", None)
|
||||
@@ -352,6 +352,13 @@ class BackgroundJobsViewTest(APITestCase):
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_job_status_forbidden_for_unowned_job(self):
|
||||
job = self._create_job(task_id="job-unowned", user_id=None, status="success")
|
||||
self.client.force_authenticate(self.other)
|
||||
url = reverse("api_v1:jobs:job-status", kwargs={"task_id": job.task_id})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_job_list_filters_status(self):
|
||||
self._create_job(task_id="job-1", user_id=self.user.id, status="success")
|
||||
self._create_job(task_id="job-2", user_id=self.user.id, status="pending")
|
||||
@@ -375,7 +382,9 @@ class BackgroundJobsViewTest(APITestCase):
|
||||
self.assertLessEqual(len(response.data), 2)
|
||||
|
||||
def test_job_list_invalid_limit_returns_400(self):
|
||||
self._create_job(task_id="job-invalid-limit", user_id=self.user.id, status="success")
|
||||
self._create_job(
|
||||
task_id="job-invalid-limit", user_id=self.user.id, status="success"
|
||||
)
|
||||
self.client.force_authenticate(self.user)
|
||||
url = reverse("api_v1:jobs:job-list")
|
||||
response = self.client.get(url, {"limit": "abc"})
|
||||
|
||||
Reference in New Issue
Block a user