Corp/backend/tests/commerce/test_connections.py
2026-08-18 09:49:09 +03:00

868 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import timedelta
from decimal import Decimal
import io
from unittest.mock import Mock, patch
import httpx
import pytest
from PIL import Image
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from commerce.models import (
IntegrationConnection,
IntegrationStatus,
Product,
ProductBinding,
ProductImage,
Sale,
SourceRecord,
SourceSystem,
StockSnapshot,
)
from commerce.integrations.moysklad import (
MoySkladClient,
MoySkladError,
MoySkladRemoteImage,
)
from commerce.media import (
_validated_image_and_thumbnail,
sync_moysklad_product_images,
)
from commerce.realtime import COMMERCE_CONNECTIONS_GROUP, send_connection_update
from commerce.services.moysklad_sync import (
MoySkladSyncStats,
_position_cost,
_position_revenue,
normalize_moysklad_assortment_row,
normalize_moysklad_document,
normalize_moysklad_stock_row,
reconcile_moysklad_variant_parents,
)
from commerce.tasks import dispatch_due_connection_syncs, sync_moysklad_run
pytestmark = pytest.mark.django_db
def _jpeg_bytes():
output = io.BytesIO()
Image.new("RGB", (80, 60), (42, 133, 210)).save(output, format="JPEG")
return output.getvalue()
def test_product_thumbnail_rejects_decompression_bomb(monkeypatch):
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 100)
with pytest.raises((Image.DecompressionBombError, Image.DecompressionBombWarning)):
_validated_image_and_thumbnail(_jpeg_bytes())
def test_moysklad_client_keeps_api_prefix(settings):
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
requested_urls = []
def handler(request):
requested_urls.append(str(request.url))
return httpx.Response(200, json={"name": "Администратор"})
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
client.client.close()
client.client = httpx.Client(
base_url=client.base_url,
transport=httpx.MockTransport(handler),
)
try:
client.employee_context()
finally:
client.close()
assert requested_urls == ["https://api.example/api/remap/1.2/context/employee"]
def test_moysklad_client_accepts_token_with_bearer_prefix():
client = MoySkladClient(
auth_type="token",
credentials={"token": " Bearer access-token-value "},
)
try:
assert client.client.headers["Authorization"] == "Bearer access-token-value"
finally:
client.close()
def test_moysklad_client_uses_vendor_accept_header():
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
try:
assert client.client.headers["Accept"] == "application/json;charset=utf-8"
finally:
client.close()
def test_moysklad_client_reads_variant_images_and_downloads_file(settings):
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
image_content = _jpeg_bytes()
def handler(request):
if request.url.path.endswith("/images"):
return httpx.Response(
200,
json={
"rows": [
{
"filename": "variant.jpg",
"title": "Главное фото",
"size": len(image_content),
"updated": "2026-08-17 12:00:00.000",
"meta": {
"href": "https://api.example/api/remap/1.2/entity/variant/product-1/images/image-1",
"downloadHref": "https://api.example/download/image-1",
},
}
]
},
request=request,
)
return httpx.Response(
200,
content=image_content,
headers={"Content-Type": "image/jpeg"},
request=request,
)
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
client.client.close()
client.client = httpx.Client(
base_url=client.base_url,
headers={"Authorization": "Bearer test"},
transport=httpx.MockTransport(handler),
)
try:
images = client.product_images("variant", "product-1")
content, content_type = client.download_image(images[0].download_href)
finally:
client.close()
assert images[0].external_id == "image-1"
assert images[0].filename == "variant.jpg"
assert content == image_content
assert content_type == "image/jpeg"
def test_assortment_expands_images_in_supported_page_size(settings):
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
queries = []
def handler(request):
queries.append(dict(request.url.params))
return httpx.Response(200, json={"rows": []}, request=request)
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
client.client.close()
client.client = httpx.Client(
base_url=client.base_url,
transport=httpx.MockTransport(handler),
)
try:
assert list(client.iter_assortment(limit=1000)) == []
finally:
client.close()
assert queries == [
{"limit": "100", "offset": "0", "stockMode": "all", "expand": "images"},
{
"limit": "100",
"offset": "0",
"filter": "archived=true",
"expand": "images",
},
{
"limit": "100",
"offset": "0",
"filter": "archived=true",
"expand": "images",
},
{
"limit": "100",
"offset": "0",
"filter": "archived=true",
"expand": "images",
},
{
"limit": "100",
"offset": "0",
"filter": "archived=true",
"expand": "images",
},
]
def test_assortment_includes_archived_entity_endpoints(settings):
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
def handler(request):
entity_type = request.url.path.rstrip("/").rsplit("/", 1)[-1]
if entity_type == "assortment":
rows = [{"id": "active", "name": "Активный", "archived": False}]
else:
rows = [
{
"id": f"archived-{entity_type}",
"name": f"Архивный {entity_type}",
"archived": True,
"meta": {"type": entity_type},
}
]
return httpx.Response(200, json={"rows": rows}, request=request)
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
client.client.close()
client.client = httpx.Client(
base_url=client.base_url,
transport=httpx.MockTransport(handler),
)
try:
rows = list(client.iter_assortment())
finally:
client.close()
assert [row["id"] for row in rows] == [
"active",
"archived-product",
"archived-variant",
"archived-bundle",
"archived-service",
]
def test_product_images_are_stored_deduplicated_and_removed_with_source_state():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
)
row = {
"id": "external-product-1",
"name": "Товар с фото",
"article": "PHOTO-1",
"meta": {"type": "variant"},
"images": {"meta": {"size": 1}},
}
normalize_moysklad_assortment_row(
connection=connection,
row=row,
captured_at=timezone.now(),
)
content = _jpeg_bytes()
remote = MoySkladRemoteImage(
external_id="image-1",
filename="photo.jpg",
title="Главное фото",
size=len(content),
updated="2026-08-17 12:00:00.000",
download_href="https://api.example/download/image-1",
)
client = Mock()
client.product_images.return_value = [remote]
client.download_image.return_value = (content, "image/jpeg")
first_count = sync_moysklad_product_images(
connection=connection,
row=row,
client=client,
)
second_count = sync_moysklad_product_images(
connection=connection,
row=row,
client=client,
)
image = ProductImage.objects.get(source=source, external_id="image-1")
assert first_count == second_count == 1
assert image.product.sku == "PHOTO-1"
assert image.file.name.endswith(".jpg")
assert image.thumbnail.name.endswith(".webp")
assert image.is_primary is True
assert client.download_image.call_count == 1
client.product_images.return_value = []
assert sync_moysklad_product_images(
connection=connection,
row=row,
client=client,
) == 0
assert not ProductImage.objects.filter(pk=image.pk).exists()
def test_moysklad_client_includes_safe_upstream_error_detail(settings):
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
def handler(request):
return httpx.Response(
400,
json={"errors": [{"error": "Некорректный токен доступа"}]},
request=request,
)
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
client.client.close()
client.client = httpx.Client(
base_url=client.base_url,
transport=httpx.MockTransport(handler),
)
try:
with pytest.raises(MoySkladError, match="Некорректный токен доступа"):
client.employee_context()
finally:
client.close()
def test_connection_update_is_sent_to_shared_realtime_group():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
status=IntegrationStatus.SYNCING,
)
run = connection.sync_runs.create(status="running")
sent = []
class ChannelLayer:
async def group_send(self, group, event):
sent.append((group, event))
with patch("commerce.realtime.get_channel_layer", return_value=ChannelLayer()):
send_connection_update(connection, run=run)
assert sent[0][0] == COMMERCE_CONNECTIONS_GROUP
assert sent[0][1]["type"] == "commerce_connection_updated"
assert sent[0][1]["connection"]["status"] == IntegrationStatus.SYNCING
assert sent[0][1]["connection"]["recent_run"]["id"] == str(run.pk)
def test_staff_connects_moysklad_without_exposing_token(api_client, user_factory):
user = user_factory(staff=True)
api_client.force_authenticate(user=user)
response = api_client.post(
reverse("api:v1:commerce:connection-list"),
{"provider": "moysklad", "auth_type": "token", "token": "secret-token"},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert "token" not in response.data
assert response.data["credentials_configured"] is True
connection = IntegrationConnection.objects.get()
assert "secret-token" not in connection.encrypted_credentials
assert connection.source.code == "moysklad"
def test_connection_test_action_updates_account(api_client, user_factory):
user = user_factory(staff=True)
api_client.force_authenticate(user=user)
create_response = api_client.post(
reverse("api:v1:commerce:connection-list"),
{"provider": "moysklad", "auth_type": "token", "token": "secret-token"},
format="json",
)
with patch(
"api.v1.commerce.viewsets.test_moysklad_connection",
return_value={"account_id": "account-1", "account_name": "Иван Иванов"},
):
response = api_client.post(
reverse(
"api:v1:commerce:connection-test",
args=[create_response.data["id"]],
)
)
assert response.status_code == status.HTTP_200_OK
assert response.data["status"] == IntegrationStatus.READY
assert response.data["account_name"] == "Иван Иванов"
def test_connection_sync_action_queues_background_run(api_client, user_factory):
user = user_factory(staff=True)
api_client.force_authenticate(user=user)
create_response = api_client.post(
reverse("api:v1:commerce:connection-list"),
{"provider": "moysklad", "auth_type": "token", "token": "secret-token"},
format="json",
)
async_result = Mock(id="celery-task-1")
with patch("commerce.tasks.sync_moysklad_run.delay", return_value=async_result):
response = api_client.post(
reverse(
"api:v1:commerce:connection-sync",
args=[create_response.data["id"]],
)
)
assert response.status_code == status.HTTP_202_ACCEPTED
assert response.data["status"] == "queued"
connection = IntegrationConnection.objects.get()
assert connection.status == IntegrationStatus.QUEUED
assert connection.task_id == "celery-task-1"
def test_sync_run_persists_product_image_progress():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
)
run = connection.sync_runs.create()
def synchronize(_connection, *, progress_callback):
stats = MoySkladSyncStats(
processed=25,
created_products=4,
stock_snapshots=20,
images=12,
sales=3,
)
progress_callback(stats)
return stats
with (
patch("commerce.tasks.sync_moysklad_connection", side_effect=synchronize),
patch("commerce.tasks.send_connection_update"),
):
result = sync_moysklad_run.run(str(run.pk))
run.refresh_from_db()
connection.refresh_from_db()
assert result["images"] == 12
assert run.status == "success"
assert run.processed == 25
assert run.images == 12
assert run.image_errors == 0
assert run.sales == 3
assert connection.last_stats["images"] == 12
def test_beat_dispatcher_queues_due_connection():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
status=IntegrationStatus.READY,
auto_sync=True,
sync_interval_minutes=15,
next_sync_at=timezone.now() - timedelta(minutes=1),
)
async_result = Mock(id="scheduled-task-1")
with patch("commerce.tasks.sync_moysklad_run.delay", return_value=async_result):
result = dispatch_due_connection_syncs()
connection.refresh_from_db()
assert result == {"queued": 1}
assert connection.status == IntegrationStatus.QUEUED
assert connection.task_id == "scheduled-task-1"
assert connection.next_sync_at > timezone.now()
def test_schedule_update_does_not_disconnect_connection(api_client, user_factory):
user = user_factory(staff=True)
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="encrypted",
status=IntegrationStatus.READY,
)
api_client.force_authenticate(user=user)
response = api_client.patch(
reverse("api:v1:commerce:connection-detail", args=[connection.pk]),
{"auto_sync": True, "sync_interval_minutes": 60},
format="json",
)
assert response.status_code == status.HTTP_200_OK
connection.refresh_from_db()
assert connection.status == IntegrationStatus.READY
assert connection.sync_interval_minutes == 60
assert connection.next_sync_at is not None
def test_moysklad_row_creates_product_binding_and_raw_record():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
)
row = {
"id": "external-product-1",
"name": "Товар из МойСклад",
"article": "MS-001",
"description": "Описание",
"barcodes": [{"ean13": "460000000001"}],
"stock": 12,
"reserve": 2,
"inTransit": 3,
"updated": "2026-08-17 12:00:00.000",
"meta": {"type": "product"},
}
created, matched, stock_created = normalize_moysklad_assortment_row(
connection=connection,
row=row,
captured_at=timezone.now(),
)
assert created is True
assert matched is False
assert stock_created is True
product = Product.objects.get(sku="MS-001")
assert product.barcode == "460000000001"
assert ProductBinding.objects.filter(
product=product,
source=source,
external_id="external-product-1",
).exists()
assert SourceRecord.objects.filter(
source=source,
entity_type="product",
external_id="external-product-1",
).exists()
assert not StockSnapshot.objects.filter(product=product, source=source).exists()
def test_moysklad_stock_report_creates_separate_warehouse_snapshots():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
)
product = Product.objects.create(sku="STOCK-1", name="Товар")
ProductBinding.objects.create(
source=source,
product=product,
external_id="external-product-1",
)
captured_at = timezone.now()
created = normalize_moysklad_stock_row(
connection=connection,
captured_at=captured_at,
row={
"meta": {
"href": "https://api.moysklad.ru/api/remap/1.2/entity/product/external-product-1?expand=supplier"
},
"stockByStore": [
{
"name": "Основной склад",
"stock": 12,
"reserve": 2,
"inTransit": 3,
"meta": {
"href": "https://api.moysklad.ru/api/remap/1.2/entity/store/store-1"
},
},
{
"name": "Магазин",
"stock": 4,
"reserve": 1,
"inTransit": 0,
"meta": {
"href": "https://api.moysklad.ru/api/remap/1.2/entity/store/store-2"
},
},
],
},
)
assert created == 2
assert set(
StockSnapshot.objects.values_list("warehouse_external_id", flat=True)
) == {"store-1", "store-2"}
first = StockSnapshot.objects.get(warehouse_external_id="store-1")
assert first.quantity == 12
assert first.reserved == 2
def test_moysklad_row_matches_existing_product_by_sku():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
)
product = Product.objects.create(sku="SAME-001", name="Существующий товар")
created, matched, _ = normalize_moysklad_assortment_row(
connection=connection,
row={
"id": "external-product-2",
"name": "Название в МойСклад",
"article": "SAME-001",
"meta": {"type": "product"},
},
captured_at=timezone.now(),
)
assert created is False
assert matched is True
assert (
ProductBinding.objects.get(
source=source, external_id="external-product-2"
).product
== product
)
assert Product.objects.count() == 1
def test_moysklad_variant_is_not_merged_with_parent_and_parent_is_reconciled():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
)
captured_at = timezone.now()
variant_row = {
"id": "variant-1",
"name": "Футболка / XL",
"article": "SHIRT-1",
"meta": {"type": "variant"},
"product": {
"meta": {
"href": "https://api.moysklad.ru/api/remap/1.2/entity/product/product-1"
}
},
"characteristics": [{"name": "Размер", "value": "XL"}],
}
parent_row = {
"id": "product-1",
"name": "Футболка",
"article": "SHIRT-1",
"meta": {"type": "product"},
}
normalize_moysklad_assortment_row(
connection=connection,
row=variant_row,
captured_at=captured_at,
)
normalize_moysklad_assortment_row(
connection=connection,
row=parent_row,
captured_at=captured_at,
)
assert reconcile_moysklad_variant_parents(source) == 1
variant = ProductBinding.objects.get(
source=source,
external_id="variant-1",
)
parent = ProductBinding.objects.get(
source=source,
external_id="product-1",
)
assert variant.product != parent.product
assert variant.product.parent == parent.product
assert variant.metadata["characteristics"] == [
{"name": "Размер", "value": "XL"}
]
def test_moysklad_existing_binding_refreshes_product_fields():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
)
captured_at = timezone.now()
row = {
"id": "product-1",
"name": "Старое название",
"article": "REFRESH-1",
"meta": {"type": "product"},
}
normalize_moysklad_assortment_row(
connection=connection,
row=row,
captured_at=captured_at,
)
row.update(
{
"name": "Новое название",
"description": "Новое описание",
"barcodes": [{"ean13": "460000000002"}],
"archived": True,
}
)
created, matched, _ = normalize_moysklad_assortment_row(
connection=connection,
row=row,
captured_at=captured_at,
)
product = ProductBinding.objects.get(
source=source,
external_id="product-1",
).product
assert created is False
assert matched is True
assert product.name == "Новое название"
assert product.description == "Новое описание"
assert product.barcode == "460000000002"
assert product.is_active is False
def test_moysklad_demand_creates_sale_and_matches_lines():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
)
product = Product.objects.create(sku="SALE-001", name="Товар")
ProductBinding.objects.create(
source=source,
product=product,
external_id="external-product-1",
external_sku="SALE-001",
)
row = {
"id": "demand-1",
"name": "00042",
"moment": "2026-08-17 13:00:00.000",
"updated": "2026-08-17 13:10:00.000",
"applicable": True,
"state": {"name": "Отгружен"},
"salesChannel": {
"meta": {
"href": "https://api.moysklad.ru/api/remap/1.2/entity/saleschannel/channel-1"
}
},
"positions": {
"rows": [
{
"id": "position-1",
"quantity": 2,
"price": 15000,
"discount": 10,
"assortment": {
"id": "external-product-1",
"name": "Товар",
"article": "SALE-001",
"buyPrice": {"value": 7000},
},
}
]
},
}
line_count = normalize_moysklad_document(
connection=connection,
row=row,
document_type="demand",
channel_names={"channel-1": "robotail.pro"},
)
assert line_count == 1
sale = Sale.objects.get(source=source, external_id="demand:demand-1")
assert sale.number == "00042"
assert sale.status == "Отгружен"
assert sale.channel == "robotail.pro"
line = sale.lines.get()
assert line.product == product
assert line.gross_amount == 300
assert line.discount_amount == 30
assert line.cost_amount == 140
assert line.metadata["cost_source"] == "assortment.buyPrice"
def test_moysklad_position_amounts_keep_source_precision_and_external_vat():
precise_position = {
"quantity": 2007,
"price": 1400.0996512207275,
"discount": 0,
"cost": 700.0498256103638,
}
gross, discount, vat_amount = _position_revenue({}, precise_position)
cost, cost_source = _position_cost(precise_position, Decimal("2007"))
assert gross == Decimal("28100.00")
assert discount == Decimal("0.00")
assert vat_amount == Decimal("0.00")
assert cost == Decimal("14050.00")
assert cost_source == "position.cost"
gross, discount, vat_amount = _position_revenue(
{"vatIncluded": False},
{
"quantity": 1,
"price": 19500000,
"discount": 0,
"vat": 5,
"vatEnabled": True,
},
)
assert gross == Decimal("204750.00")
assert discount == Decimal("0.00")
assert vat_amount == Decimal("9750.00")
def test_moysklad_document_retains_product_missing_from_current_assortment():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
)
row = {
"id": "demand-historical",
"name": "00043",
"moment": "2026-08-17 13:00:00.000",
"positions": {
"rows": [
{
"id": "position-historical",
"quantity": 1,
"price": 10000,
"discount": 0,
"assortment": {
"id": "deleted-product-1",
"name": "Удалённый товар",
"article": "OLD-001",
"meta": {"type": "product"},
},
}
]
},
}
normalize_moysklad_document(
connection=connection,
row=row,
document_type="demand",
)
line = Sale.objects.get(external_id="demand:demand-historical").lines.get()
assert line.product is not None
assert line.product.name == "Удалённый товар"
assert line.product.is_active is False
binding = ProductBinding.objects.get(
source=source,
external_id="deleted-product-1",
)
assert binding.product == line.product
assert binding.metadata["historical_from_document"] is True