feat: VK OAuth flow, catalog sync improvements, and expanded test suite

- Add VK OAuth implicit flow: /vk-auth redirect, /vk-callback JS page,
  /vk-callback/save endpoint with state validation
- Add VK_CLIENT_ID/VK_CLIENT_SECRET to config
- Add refresh_token/token_expires_at columns to vk_connections (migration 0006)
- Fix vk_catalog task: handle price/thumb_photo as string or dict (VK API v5.199)
- Fix connections/vk/test: use groups.getById instead of market.getAlbums
  (works with both user and group tokens)
- Add orphan deletion to mirror_to_vk: VK products not in Evotor are removed
- Handle ungrouped Evotor products: push to "Без категории" VK album
- Respect SyncConfig.is_enabled in mirror_to_vk
- Add product count column to catalog groups page
- Add group name column to catalog products page
- Expand test suite: 73 new tests covering connections routes, catalog routes,
  vk_sync task logic, and catalog task helpers (138 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-05-12 15:09:47 +03:00
parent 4f4081c54c
commit 7b4f52b005
16 changed files with 1624 additions and 32 deletions

169
tests/test_tasks_vk_sync.py Normal file
View File

@@ -0,0 +1,169 @@
"""Unit tests for vk_sync task logic (price calc, name sanitization, orphan deletion)."""
from datetime import datetime
from decimal import Decimal
from unittest.mock import MagicMock, call, patch
import pytest
from web.tasks.vk_sync import (
_build_description,
_calc_price,
_delete_orphans,
_is_weight,
_name_for_vk,
)
# ── _is_weight ────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("measure,expected", [
("г", True),
("г.", True),
("гр", True),
("гр.", True),
("грамм", True),
("граммов", True),
(" Г ", True), # case-insensitive, stripped
("кг", False),
("шт", False),
("л", False),
(None, False),
("", False),
])
def test_is_weight(measure, expected):
assert _is_weight(measure) == expected
# ── _calc_price ───────────────────────────────────────────────────────────────
def test_calc_price_normal(monkeypatch):
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
assert _calc_price(Decimal("150"), "шт") == 15000 # 150 руб * 100 копеек
def test_calc_price_weight_multiplier(monkeypatch):
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
# 50 руб/г → 50 * 10 (multiplier) * 100 (kopecks) = 50000
assert _calc_price(Decimal("50"), "г") == 50000
def test_calc_price_none():
assert _calc_price(None, "шт") == 0
def test_calc_price_zero():
assert _calc_price(Decimal("0"), "шт") == 0
# ── _name_for_vk ──────────────────────────────────────────────────────────────
def test_name_replaces_semicolons():
assert _name_for_vk("Чай; зелёный; Китай") == "Чай, зелёный, Китай"
def test_name_no_semicolons():
assert _name_for_vk("Пуэр (выдержанный)") == "Пуэр (выдержанный)"
# ── _build_description ────────────────────────────────────────────────────────
def test_build_description_weight(monkeypatch):
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
desc = _build_description("Чай", "г", None)
assert "10г" in desc
assert "Чай" in desc
def test_build_description_with_evo_desc(monkeypatch):
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
desc = _build_description("Чай", "шт", "Вкусный чай из Китая")
assert "Вкусный чай из Китая" in desc
def test_build_description_no_evo_desc(monkeypatch):
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
desc = _build_description("Чай", "шт", None)
assert "Чай" in desc
# ── _delete_orphans ───────────────────────────────────────────────────────────
def test_delete_orphans_removes_stale_vk_products():
from web.models.connections import VkCachedProduct, CachedProduct
# Build fake VK cached products
vk1 = MagicMock(spec=VkCachedProduct)
vk1.vk_product_id = "111"
vk1.name = "Существующий"
vk2 = MagicMock(spec=VkCachedProduct)
vk2.vk_product_id = "222"
vk2.name = "Удалённый из Эвотор"
db = MagicMock()
# owned_ids contains only "111" — "222" is orphan
owned_ids = {"111"}
# query().filter_by().filter().all() chain
query_mock = MagicMock()
query_mock.filter_by.return_value.filter.return_value.all.return_value = [vk2]
# second query for stale cached_products
query_mock.filter.return_value.all.return_value = []
db.query.return_value = query_mock
results = {"deleted": 0, "errors": 0}
mock_post_resp = {"response": 1}
with patch("web.tasks.vk_sync._vk_post", return_value=mock_post_resp):
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids=owned_ids,
token="tok", results=results)
assert results["deleted"] == 1
db.delete.assert_called_once_with(vk2)
def test_delete_orphans_vk_api_error_counted():
from web.models.connections import VkCachedProduct
vk1 = MagicMock(spec=VkCachedProduct)
vk1.vk_product_id = "999"
vk1.name = "Сломанный"
db = MagicMock()
query_mock = MagicMock()
query_mock.filter_by.return_value.filter.return_value.all.return_value = [vk1]
query_mock.filter.return_value.all.return_value = []
db.query.return_value = query_mock
results = {"deleted": 0, "errors": 0}
with patch("web.tasks.vk_sync._vk_post", return_value={"error": {"error_code": 15}}):
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids={"other"},
token="tok", results=results)
assert results["deleted"] == 0
assert results["errors"] == 1
def test_delete_orphans_empty_owned_ids_deletes_all():
"""If no Evotor products exist (owned_ids empty), all VK products are orphans."""
from web.models.connections import VkCachedProduct
vk1 = MagicMock(spec=VkCachedProduct)
vk1.vk_product_id = "1"
vk1.name = "Лишний"
db = MagicMock()
query_mock = MagicMock()
# With empty owned_ids, query without .filter() is used
query_mock.filter_by.return_value.all.return_value = [vk1]
query_mock.filter.return_value.all.return_value = []
db.query.return_value = query_mock
results = {"deleted": 0, "errors": 0}
with patch("web.tasks.vk_sync._vk_post", return_value={"response": 1}):
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids=set(),
token="tok", results=results)
assert results["deleted"] == 1