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:
27
tests/apps/exchange/test_models.py
Normal file
27
tests/apps/exchange/test_models.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Tests for exchange models."""
|
||||
|
||||
from apps.exchange.models import ExchangeConnection
|
||||
from django.test import TestCase
|
||||
|
||||
from tests.apps.exchange.factories import ExchangeConnectionFactory
|
||||
|
||||
|
||||
class ExchangeConnectionModelTest(TestCase):
|
||||
def test_string_representation_and_plain_password_passthrough(self):
|
||||
connection = ExchangeConnectionFactory(
|
||||
username="postgres",
|
||||
server="127.0.0.1",
|
||||
port=5432,
|
||||
database_name="target_db",
|
||||
schema_name="public",
|
||||
)
|
||||
|
||||
self.assertEqual(str(connection), "postgres@127.0.0.1:5432/target_db[public]")
|
||||
self.assertEqual(ExchangeConnection.decrypt_password("legacy-pass"), "legacy-pass")
|
||||
|
||||
def test_decrypt_password_raises_for_invalid_encrypted_token(self):
|
||||
with self.assertRaisesMessage(
|
||||
ValueError,
|
||||
"Не удалось расшифровать пароль exchange connection",
|
||||
):
|
||||
ExchangeConnection.decrypt_password(f"{ExchangeConnection.PASSWORD_PREFIX}invalid")
|
||||
29
tests/apps/exchange/test_serializers.py
Normal file
29
tests/apps/exchange/test_serializers.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Tests for exchange serializers."""
|
||||
|
||||
from apps.exchange.serializers import ExchangeCopyRequestSerializer
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
class ExchangeCopyRequestSerializerTest(SimpleTestCase):
|
||||
def test_single_mode_requires_table(self):
|
||||
serializer = ExchangeCopyRequestSerializer(data={"mode": "single"})
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("table", serializer.errors)
|
||||
|
||||
def test_selected_mode_requires_tables(self):
|
||||
serializer = ExchangeCopyRequestSerializer(data={"mode": "selected"})
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("tables", serializer.errors)
|
||||
|
||||
def test_table_and_tables_are_rejected_for_wrong_modes(self):
|
||||
serializer_with_table = ExchangeCopyRequestSerializer(
|
||||
data={"mode": "all", "table": "parsers_manufacturer"}
|
||||
)
|
||||
self.assertFalse(serializer_with_table.is_valid())
|
||||
self.assertIn("table", serializer_with_table.errors)
|
||||
|
||||
serializer_with_tables = ExchangeCopyRequestSerializer(
|
||||
data={"mode": "all", "tables": ["parsers_manufacturer"]}
|
||||
)
|
||||
self.assertFalse(serializer_with_tables.is_valid())
|
||||
self.assertIn("tables", serializer_with_tables.errors)
|
||||
546
tests/apps/exchange/test_service_units.py
Normal file
546
tests/apps/exchange/test_service_units.py
Normal file
@@ -0,0 +1,546 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.exchange.services import ExchangeConnectionService, ExchangeServiceError
|
||||
from apps.parsers.models import IndustrialCertificateRecord, ManufacturerRecord, ParserLoadLog
|
||||
from django.test import TestCase
|
||||
|
||||
from tests.apps.exchange.factories import ExchangeConnectionFactory
|
||||
|
||||
|
||||
class _FakeModel:
|
||||
_meta = SimpleNamespace(
|
||||
app_label="tests",
|
||||
model_name="fake_model",
|
||||
db_table="fake_table",
|
||||
local_fields=[
|
||||
SimpleNamespace(attname="id", name="id", column="id"),
|
||||
SimpleNamespace(attname="name", name="name", column="name"),
|
||||
],
|
||||
pk=SimpleNamespace(attname="id"),
|
||||
)
|
||||
objects = MagicMock()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
class _AnotherFakeModel:
|
||||
_meta = SimpleNamespace(
|
||||
app_label="tests",
|
||||
model_name="another_model",
|
||||
db_table="another_table",
|
||||
local_fields=[SimpleNamespace(attname="id", name="id", column="id")],
|
||||
pk=SimpleNamespace(attname="id"),
|
||||
)
|
||||
|
||||
|
||||
class ExchangeConnectionServiceUnitTest(TestCase):
|
||||
def test_create_active_connection_and_prepare_updates_last_check_on_success(self):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"test_connection",
|
||||
return_value="target_alias",
|
||||
) as test_connection_mock:
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"validate_target_structure",
|
||||
) as validate_mock:
|
||||
connection = ExchangeConnectionService.create_active_connection_and_prepare(
|
||||
server="127.0.0.1",
|
||||
port=5432,
|
||||
username="postgres",
|
||||
password="secret",
|
||||
database_name="target_db",
|
||||
schema_name="public",
|
||||
)
|
||||
|
||||
self.assertTrue(connection.is_active)
|
||||
self.assertIsNotNone(connection.last_checked_at)
|
||||
self.assertEqual(connection.last_error, "")
|
||||
test_connection_mock.assert_called_once_with(connection)
|
||||
validate_mock.assert_called_once_with(
|
||||
connection=connection,
|
||||
alias="target_alias",
|
||||
schema_name="public",
|
||||
)
|
||||
|
||||
def test_get_active_connection_raises_when_missing(self):
|
||||
with self.assertRaisesMessage(
|
||||
ExchangeServiceError,
|
||||
"Активное подключение не найдено",
|
||||
):
|
||||
ExchangeConnectionService.get_active_connection()
|
||||
|
||||
def test_test_connection_success_runs_select(self):
|
||||
connection = ExchangeConnectionFactory()
|
||||
db_connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
db_connection.cursor.return_value.__enter__.return_value = cursor
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_configure_alias",
|
||||
return_value="exchange_target_1",
|
||||
):
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
alias = ExchangeConnectionService.test_connection(connection)
|
||||
|
||||
self.assertEqual(alias, "exchange_target_1")
|
||||
db_connection.ensure_connection.assert_called_once_with()
|
||||
cursor.execute.assert_called_once_with("SELECT 1")
|
||||
|
||||
def test_test_connection_marks_error_on_failure(self):
|
||||
connection = ExchangeConnectionFactory(last_error="")
|
||||
db_connection = MagicMock()
|
||||
db_connection.ensure_connection.side_effect = RuntimeError("boom")
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_configure_alias",
|
||||
return_value="exchange_target_1",
|
||||
):
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
with self.assertRaisesMessage(
|
||||
ExchangeServiceError,
|
||||
"Ошибка подключения к целевой БД: boom",
|
||||
):
|
||||
ExchangeConnectionService.test_connection(connection)
|
||||
|
||||
connection.refresh_from_db()
|
||||
self.assertEqual(connection.last_error, "boom")
|
||||
self.assertIsNotNone(connection.last_checked_at)
|
||||
|
||||
def test_validate_target_structure_calls_all_validation_steps(self):
|
||||
connection = ExchangeConnectionFactory()
|
||||
db_connection = MagicMock()
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_extend_models_with_dependencies",
|
||||
return_value=[_FakeModel],
|
||||
) as extend_mock:
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_get_parser_models",
|
||||
return_value=[_FakeModel],
|
||||
):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_validate_schema_exists",
|
||||
) as schema_mock:
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_validate_tables_exist",
|
||||
) as tables_mock:
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_validate_columns_exist",
|
||||
) as columns_mock:
|
||||
ExchangeConnectionService.validate_target_structure(
|
||||
connection=connection,
|
||||
alias="target_alias",
|
||||
schema_name="public",
|
||||
)
|
||||
|
||||
db_connection.ensure_connection.assert_called_once_with()
|
||||
extend_mock.assert_called_once()
|
||||
schema_mock.assert_called_once_with(alias="target_alias", schema_name="public")
|
||||
tables_mock.assert_called_once_with(
|
||||
alias="target_alias",
|
||||
schema_name="public",
|
||||
models_to_copy=[_FakeModel],
|
||||
)
|
||||
columns_mock.assert_called_once_with(
|
||||
alias="target_alias",
|
||||
schema_name="public",
|
||||
models_to_copy=[_FakeModel],
|
||||
)
|
||||
|
||||
def test_validate_target_structure_marks_and_reraises_exchange_error(self):
|
||||
connection = ExchangeConnectionFactory(last_error="")
|
||||
db_connection = MagicMock()
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_validate_schema_exists",
|
||||
side_effect=ExchangeServiceError("bad schema"),
|
||||
):
|
||||
with self.assertRaisesMessage(ExchangeServiceError, "bad schema"):
|
||||
ExchangeConnectionService.validate_target_structure(
|
||||
connection=connection,
|
||||
alias="target_alias",
|
||||
schema_name="public",
|
||||
models_to_copy=[_FakeModel],
|
||||
)
|
||||
|
||||
connection.refresh_from_db()
|
||||
self.assertEqual(connection.last_error, "bad schema")
|
||||
|
||||
def test_validate_target_structure_wraps_generic_error(self):
|
||||
connection = ExchangeConnectionFactory(last_error="")
|
||||
db_connection = MagicMock()
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_validate_schema_exists",
|
||||
side_effect=RuntimeError("unexpected"),
|
||||
):
|
||||
with self.assertRaisesMessage(
|
||||
ExchangeServiceError,
|
||||
"Ошибка проверки структуры целевой БД: unexpected",
|
||||
):
|
||||
ExchangeConnectionService.validate_target_structure(
|
||||
connection=connection,
|
||||
alias="target_alias",
|
||||
schema_name="public",
|
||||
models_to_copy=[_FakeModel],
|
||||
)
|
||||
|
||||
connection.refresh_from_db()
|
||||
self.assertEqual(connection.last_error, "unexpected")
|
||||
|
||||
def test_copy_parsers_data_success(self):
|
||||
connection = ExchangeConnectionFactory(schema_name="target_schema")
|
||||
db_connection = MagicMock()
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_configure_alias",
|
||||
return_value="target_alias",
|
||||
):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_resolve_models",
|
||||
return_value=[_FakeModel, _AnotherFakeModel],
|
||||
):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_extend_models_with_dependencies",
|
||||
return_value=[_FakeModel, _AnotherFakeModel],
|
||||
):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"validate_target_structure",
|
||||
) as validate_mock:
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_copy_model_data",
|
||||
side_effect=[2, 3],
|
||||
) as copy_mock:
|
||||
result = ExchangeConnectionService.copy_parsers_data(
|
||||
connection=connection,
|
||||
mode="selected",
|
||||
tables=["fake_table", "another_table"],
|
||||
truncate_before_copy=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result["mode"], "selected")
|
||||
self.assertEqual(result["tables"], ["fake_table", "another_table"])
|
||||
self.assertEqual(result["rows_by_table"], {"fake_table": 2, "another_table": 3})
|
||||
self.assertEqual(result["total_rows"], 5)
|
||||
self.assertFalse(result["truncate_before_copy"])
|
||||
validate_mock.assert_called_once_with(
|
||||
connection=connection,
|
||||
alias="target_alias",
|
||||
schema_name="target_schema",
|
||||
models_to_copy=[_FakeModel, _AnotherFakeModel],
|
||||
)
|
||||
self.assertEqual(copy_mock.call_count, 2)
|
||||
connection.refresh_from_db()
|
||||
self.assertEqual(connection.last_error, "")
|
||||
self.assertIsNotNone(connection.last_checked_at)
|
||||
|
||||
def test_copy_parsers_data_marks_connection_error_on_connect_failure(self):
|
||||
connection = ExchangeConnectionFactory(last_error="")
|
||||
db_connection = MagicMock()
|
||||
db_connection.ensure_connection.side_effect = RuntimeError("target unavailable")
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_configure_alias",
|
||||
return_value="target_alias",
|
||||
):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_resolve_models",
|
||||
return_value=[_FakeModel],
|
||||
):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_extend_models_with_dependencies",
|
||||
return_value=[_FakeModel],
|
||||
):
|
||||
with self.assertRaisesMessage(
|
||||
ExchangeServiceError,
|
||||
"Ошибка подключения к целевой БД: target unavailable",
|
||||
):
|
||||
ExchangeConnectionService.copy_parsers_data(
|
||||
connection=connection,
|
||||
mode="all",
|
||||
)
|
||||
|
||||
connection.refresh_from_db()
|
||||
self.assertEqual(connection.last_error, "target unavailable")
|
||||
|
||||
def test_configure_alias_closes_existing_connection_and_clears_cache(self):
|
||||
connection = ExchangeConnectionFactory(password="secret")
|
||||
alias = f"exchange_target_{connection.id}"
|
||||
existing_db_connection = MagicMock()
|
||||
storage = SimpleNamespace(**{alias: "stale"})
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.databases = {alias: {"ENGINE": "old"}}
|
||||
connections_mock.__getitem__.return_value = existing_db_connection
|
||||
connections_mock._connections = storage
|
||||
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
configured_alias = ExchangeConnectionService._configure_alias(connection)
|
||||
|
||||
self.assertEqual(configured_alias, alias)
|
||||
existing_db_connection.close.assert_called_once_with()
|
||||
self.assertEqual(connections_mock.databases[alias]["NAME"], connection.database_name)
|
||||
self.assertEqual(connections_mock.databases[alias]["PASSWORD"], "secret")
|
||||
self.assertNotIn(alias, storage.__dict__)
|
||||
|
||||
def test_validate_schema_exists_raises_when_schema_missing(self):
|
||||
cursor = MagicMock()
|
||||
cursor.fetchone.return_value = None
|
||||
db_connection = MagicMock()
|
||||
db_connection.cursor.return_value.__enter__.return_value = cursor
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
with self.assertRaisesMessage(
|
||||
ExchangeServiceError,
|
||||
"Схема 'public' отсутствует в целевой БД",
|
||||
):
|
||||
ExchangeConnectionService._validate_schema_exists(
|
||||
alias="target_alias",
|
||||
schema_name="public",
|
||||
)
|
||||
|
||||
def test_validate_tables_exist_raises_when_tables_missing(self):
|
||||
cursor = MagicMock()
|
||||
cursor.fetchall.return_value = [("fake_table",)]
|
||||
db_connection = MagicMock()
|
||||
db_connection.cursor.return_value.__enter__.return_value = cursor
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
with self.assertRaisesMessage(
|
||||
ExchangeServiceError,
|
||||
"В целевой БД отсутствуют таблицы: another_table",
|
||||
):
|
||||
ExchangeConnectionService._validate_tables_exist(
|
||||
alias="target_alias",
|
||||
schema_name="public",
|
||||
models_to_copy=[_FakeModel, _AnotherFakeModel],
|
||||
)
|
||||
|
||||
def test_validate_columns_exist_raises_when_columns_missing(self):
|
||||
cursor_context = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor.fetchall.return_value = [("id",)]
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
db_connection = MagicMock()
|
||||
db_connection.cursor.return_value = cursor_context
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
with self.assertRaisesMessage(
|
||||
ExchangeServiceError,
|
||||
"В таблице 'fake_table' отсутствуют колонки: name",
|
||||
):
|
||||
ExchangeConnectionService._validate_columns_exist(
|
||||
alias="target_alias",
|
||||
schema_name="public",
|
||||
models_to_copy=[_FakeModel],
|
||||
)
|
||||
|
||||
def test_get_parser_models_uses_configured_labels(self):
|
||||
resolved_models = [_FakeModel for _ in ExchangeConnectionService.PARSER_MODEL_LABELS]
|
||||
|
||||
with patch(
|
||||
"apps.exchange.services.django_apps.get_model",
|
||||
side_effect=resolved_models,
|
||||
) as get_model_mock:
|
||||
result = ExchangeConnectionService._get_parser_models()
|
||||
|
||||
self.assertEqual(result, resolved_models)
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in get_model_mock.call_args_list],
|
||||
ExchangeConnectionService.PARSER_MODEL_LABELS,
|
||||
)
|
||||
|
||||
def test_resolve_models_supports_table_and_class_names(self):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_get_parser_models",
|
||||
return_value=[ParserLoadLog, ManufacturerRecord],
|
||||
):
|
||||
selected = ExchangeConnectionService._resolve_models(
|
||||
mode="selected",
|
||||
table=None,
|
||||
tables=["parsers_load_log", "manufacturerrecord"],
|
||||
)
|
||||
|
||||
self.assertEqual(selected, [ParserLoadLog, ManufacturerRecord])
|
||||
|
||||
def test_resolve_models_raises_on_unknown_table(self):
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_get_parser_models",
|
||||
return_value=[ParserLoadLog],
|
||||
):
|
||||
with self.assertRaisesMessage(ExchangeServiceError, "Неизвестная таблица"):
|
||||
ExchangeConnectionService._resolve_models(
|
||||
mode="single",
|
||||
table="unknown_table",
|
||||
tables=None,
|
||||
)
|
||||
|
||||
def test_truncate_tables_executes_in_reverse_order(self):
|
||||
cursor = MagicMock()
|
||||
db_connection = MagicMock()
|
||||
db_connection.cursor.return_value.__enter__.return_value = cursor
|
||||
connections_mock = MagicMock()
|
||||
connections_mock.__getitem__.return_value = db_connection
|
||||
|
||||
with patch("apps.exchange.services.connections", connections_mock):
|
||||
ExchangeConnectionService._truncate_tables(
|
||||
alias="target_alias",
|
||||
models_to_copy=[_FakeModel, _AnotherFakeModel],
|
||||
)
|
||||
|
||||
executed_sql = [call.args[0] for call in cursor.execute.call_args_list]
|
||||
self.assertEqual(
|
||||
executed_sql,
|
||||
[
|
||||
'TRUNCATE TABLE "another_table" RESTART IDENTITY CASCADE',
|
||||
'TRUNCATE TABLE "fake_table" RESTART IDENTITY CASCADE',
|
||||
],
|
||||
)
|
||||
|
||||
def test_copy_model_data_splits_batches(self):
|
||||
source_objects = [
|
||||
SimpleNamespace(id=1, name="A"),
|
||||
SimpleNamespace(id=2, name="B"),
|
||||
SimpleNamespace(id=3, name="C"),
|
||||
]
|
||||
queryset = MagicMock()
|
||||
queryset.order_by.return_value = queryset
|
||||
queryset.iterator.return_value = source_objects
|
||||
default_manager = MagicMock()
|
||||
default_manager.all.return_value = queryset
|
||||
_FakeModel.objects = MagicMock()
|
||||
_FakeModel.objects.using.return_value = default_manager
|
||||
|
||||
with patch.object(
|
||||
ExchangeConnectionService,
|
||||
"_insert_batch",
|
||||
side_effect=[2, 1],
|
||||
) as insert_mock:
|
||||
total = ExchangeConnectionService._copy_model_data(
|
||||
model=_FakeModel,
|
||||
alias="target_alias",
|
||||
truncate_before_copy=True,
|
||||
chunk_size=2,
|
||||
)
|
||||
|
||||
self.assertEqual(total, 3)
|
||||
self.assertEqual(insert_mock.call_count, 2)
|
||||
|
||||
def test_insert_batch_returns_batch_size_in_truncate_mode(self):
|
||||
manager = MagicMock()
|
||||
_FakeModel.objects = MagicMock()
|
||||
_FakeModel.objects.using.return_value = manager
|
||||
batch = [_FakeModel(id=1, name="A"), _FakeModel(id=2, name="B")]
|
||||
|
||||
created_count = ExchangeConnectionService._insert_batch(
|
||||
model=_FakeModel,
|
||||
alias="target_alias",
|
||||
batch=batch,
|
||||
pk_name="id",
|
||||
chunk_size=100,
|
||||
truncate_before_copy=True,
|
||||
)
|
||||
|
||||
self.assertEqual(created_count, 2)
|
||||
manager.bulk_create.assert_called_once_with(
|
||||
batch,
|
||||
batch_size=100,
|
||||
ignore_conflicts=False,
|
||||
)
|
||||
|
||||
def test_insert_batch_counts_only_new_rows_without_truncate(self):
|
||||
manager = MagicMock()
|
||||
manager.filter.side_effect = [
|
||||
MagicMock(values_list=MagicMock(return_value=[1])),
|
||||
MagicMock(values_list=MagicMock(return_value=[1, 2])),
|
||||
]
|
||||
_FakeModel.objects = MagicMock()
|
||||
_FakeModel.objects.using.return_value = manager
|
||||
batch = [_FakeModel(id=1, name="A"), _FakeModel(id=2, name="B")]
|
||||
|
||||
created_count = ExchangeConnectionService._insert_batch(
|
||||
model=_FakeModel,
|
||||
alias="target_alias",
|
||||
batch=batch,
|
||||
pk_name="id",
|
||||
chunk_size=100,
|
||||
truncate_before_copy=False,
|
||||
)
|
||||
|
||||
self.assertEqual(created_count, 1)
|
||||
manager.bulk_create.assert_called_once_with(
|
||||
batch,
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
def test_mark_connection_error_updates_connection(self):
|
||||
connection = ExchangeConnectionFactory(last_error="")
|
||||
|
||||
ExchangeConnectionService._mark_connection_error(connection, "broken")
|
||||
|
||||
connection.refresh_from_db()
|
||||
self.assertEqual(connection.last_error, "broken")
|
||||
self.assertIsNotNone(connection.last_checked_at)
|
||||
|
||||
def tearDown(self):
|
||||
for alias in list(getattr(ExchangeConnectionService, "__dict__", {})):
|
||||
if alias.startswith("exchange_target_"):
|
||||
with suppress(Exception):
|
||||
from django.db import connections
|
||||
|
||||
connections[alias].close()
|
||||
with suppress(Exception):
|
||||
from django.db import connections
|
||||
|
||||
connections.databases.pop(alias, None)
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Tests for exchange services."""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
from apps.exchange.models import ExchangeConnection
|
||||
from apps.exchange.services import ExchangeConnectionService
|
||||
from apps.parsers.models import IndustrialCertificateRecord, ParserLoadLog
|
||||
from apps.registers.models import Organization
|
||||
from django.db import connections
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
@@ -23,3 +27,63 @@ class ExchangeConnectionServiceDependenciesTest(TestCase):
|
||||
|
||||
self.assertEqual(models_to_copy[0], Organization)
|
||||
self.assertEqual(models_to_copy[1], IndustrialCertificateRecord)
|
||||
|
||||
|
||||
class ExchangeConnectionEncryptionTest(TestCase):
|
||||
def test_save_encrypts_password(self):
|
||||
connection = ExchangeConnection.objects.create(
|
||||
server="127.0.0.1",
|
||||
port=5432,
|
||||
username="postgres",
|
||||
password="secret", # noqa: S106
|
||||
database_name="target_db",
|
||||
schema_name="public",
|
||||
)
|
||||
|
||||
self.assertNotEqual(connection.password, "secret")
|
||||
self.assertTrue(ExchangeConnection.is_password_encrypted(connection.password))
|
||||
self.assertEqual(connection.get_decrypted_password(), "secret")
|
||||
|
||||
def test_save_encrypts_legacy_password_on_partial_update(self):
|
||||
connection = ExchangeConnection.objects.create(
|
||||
server="127.0.0.1",
|
||||
port=5432,
|
||||
username="postgres",
|
||||
password="secret", # noqa: S106
|
||||
database_name="target_db",
|
||||
schema_name="public",
|
||||
)
|
||||
ExchangeConnection.objects.filter(id=connection.id).update(
|
||||
password="legacy-pass" # noqa: S106
|
||||
)
|
||||
|
||||
connection.refresh_from_db()
|
||||
self.assertEqual(connection.password, "legacy-pass")
|
||||
|
||||
connection.last_error = "checked"
|
||||
connection.save(update_fields=["last_error", "updated_at"])
|
||||
connection.refresh_from_db()
|
||||
|
||||
self.assertNotEqual(connection.password, "legacy-pass")
|
||||
self.assertEqual(connection.get_decrypted_password(), "legacy-pass")
|
||||
|
||||
def test_configure_alias_uses_decrypted_password(self):
|
||||
connection = ExchangeConnection.objects.create(
|
||||
server="127.0.0.1",
|
||||
port=5432,
|
||||
username="postgres",
|
||||
password="secret", # noqa: S106
|
||||
database_name="target_db",
|
||||
schema_name="public",
|
||||
)
|
||||
|
||||
alias = ExchangeConnectionService._configure_alias(connection)
|
||||
try:
|
||||
self.assertEqual(connections.databases[alias]["PASSWORD"], "secret")
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
connections[alias].close()
|
||||
connections.databases.pop(alias, None)
|
||||
storage = getattr(connections, "_connections", None)
|
||||
if storage is not None and hasattr(storage, "__dict__"):
|
||||
storage.__dict__.pop(alias, None)
|
||||
|
||||
127
tests/apps/exchange/test_tasks.py
Normal file
127
tests/apps/exchange/test_tasks.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.exchange.tasks import copy_parsers_data_async
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
class ExchangeTasksTest(SimpleTestCase):
|
||||
def test_copy_parsers_data_async_completes_with_existing_job(self):
|
||||
background_job = MagicMock()
|
||||
connection = MagicMock()
|
||||
|
||||
copy_parsers_data_async.push_request(id="task-1")
|
||||
try:
|
||||
with patch(
|
||||
"apps.exchange.tasks.BackgroundJobService.get_by_task_id_or_none",
|
||||
return_value=background_job,
|
||||
) as get_job_mock:
|
||||
with patch(
|
||||
"apps.exchange.tasks.ExchangeConnection.objects.filter",
|
||||
) as filter_mock:
|
||||
with patch(
|
||||
"apps.exchange.tasks.ExchangeConnectionService.copy_parsers_data",
|
||||
return_value={
|
||||
"mode": "all",
|
||||
"tables": ["fake_table"],
|
||||
"rows_by_table": {"fake_table": 3},
|
||||
"total_rows": 3,
|
||||
"truncate_before_copy": True,
|
||||
},
|
||||
) as copy_mock:
|
||||
filter_mock.return_value.first.return_value = connection
|
||||
|
||||
result = copy_parsers_data_async.run(
|
||||
connection_id=11,
|
||||
payload={"mode": "all", "truncate_before_copy": True},
|
||||
requested_by_id=7,
|
||||
)
|
||||
finally:
|
||||
copy_parsers_data_async.pop_request()
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["connection_id"], 11)
|
||||
get_job_mock.assert_called_once_with("task-1")
|
||||
background_job.mark_started.assert_called_once_with()
|
||||
background_job.update_progress.assert_any_call(
|
||||
10,
|
||||
"Проверка структуры целевой БД",
|
||||
)
|
||||
background_job.update_progress.assert_any_call(90, "Фиксация результата")
|
||||
background_job.complete.assert_called_once_with(result=result)
|
||||
copy_mock.assert_called_once_with(
|
||||
connection=connection,
|
||||
mode="all",
|
||||
truncate_before_copy=True,
|
||||
)
|
||||
|
||||
def test_copy_parsers_data_async_creates_job_and_fails_when_connection_missing(self):
|
||||
background_job = MagicMock()
|
||||
|
||||
copy_parsers_data_async.push_request(id=None)
|
||||
try:
|
||||
with patch("apps.exchange.tasks.uuid.uuid4", return_value="generated-task-id"):
|
||||
with patch(
|
||||
"apps.exchange.tasks.BackgroundJobService.get_by_task_id_or_none",
|
||||
return_value=None,
|
||||
):
|
||||
with patch(
|
||||
"apps.exchange.tasks.BackgroundJobService.create_job",
|
||||
return_value=background_job,
|
||||
) as create_job_mock:
|
||||
with patch(
|
||||
"apps.exchange.tasks.ExchangeConnection.objects.filter",
|
||||
) as filter_mock:
|
||||
filter_mock.return_value.first.return_value = None
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
ValueError,
|
||||
"Active exchange connection not found: 42",
|
||||
):
|
||||
copy_parsers_data_async.run(
|
||||
connection_id=42,
|
||||
payload={"mode": "all"},
|
||||
requested_by_id=3,
|
||||
)
|
||||
finally:
|
||||
copy_parsers_data_async.pop_request()
|
||||
|
||||
create_job_mock.assert_called_once_with(
|
||||
task_id="generated-task-id",
|
||||
task_name="apps.exchange.tasks.copy_parsers_data_async",
|
||||
user_id=3,
|
||||
meta={"connection_id": 42, "mode": "all"},
|
||||
)
|
||||
background_job.fail.assert_called_once_with(error="Активное подключение не найдено")
|
||||
|
||||
def test_copy_parsers_data_async_marks_failure_and_reraises(self):
|
||||
background_job = MagicMock()
|
||||
connection = MagicMock()
|
||||
|
||||
copy_parsers_data_async.push_request(id="task-2")
|
||||
try:
|
||||
with patch(
|
||||
"apps.exchange.tasks.BackgroundJobService.get_by_task_id_or_none",
|
||||
return_value=background_job,
|
||||
):
|
||||
with patch(
|
||||
"apps.exchange.tasks.ExchangeConnection.objects.filter",
|
||||
) as filter_mock:
|
||||
with patch(
|
||||
"apps.exchange.tasks.ExchangeConnectionService.copy_parsers_data",
|
||||
side_effect=RuntimeError("copy failed"),
|
||||
):
|
||||
with patch("apps.exchange.tasks.logger.exception") as logger_mock:
|
||||
filter_mock.return_value.first.return_value = connection
|
||||
|
||||
with self.assertRaisesMessage(RuntimeError, "copy failed"):
|
||||
copy_parsers_data_async.run(
|
||||
connection_id=9,
|
||||
payload={"mode": "selected", "tables": ["fake_table"]},
|
||||
)
|
||||
finally:
|
||||
copy_parsers_data_async.pop_request()
|
||||
|
||||
background_job.fail.assert_called_once_with(error="copy failed")
|
||||
logger_mock.assert_called_once()
|
||||
@@ -56,6 +56,8 @@ class ExchangeViewsTest(APITestCase):
|
||||
|
||||
new_connection = ExchangeConnection.objects.get(id=response.data["data"]["id"])
|
||||
self.assertTrue(new_connection.is_active)
|
||||
self.assertNotEqual(new_connection.password, payload["password"])
|
||||
self.assertEqual(new_connection.get_decrypted_password(), payload["password"])
|
||||
|
||||
old_active.refresh_from_db()
|
||||
self.assertFalse(old_active.is_active)
|
||||
|
||||
Reference in New Issue
Block a user