diff --git a/tests/test_routes_catalog.py b/tests/test_routes_catalog.py new file mode 100644 index 0000000..eaec0c3 --- /dev/null +++ b/tests/test_routes_catalog.py @@ -0,0 +1,275 @@ +"""Integration tests for /catalog routes (stores, groups, products, toggles).""" +from datetime import datetime + +import pytest + +from web.models.connections import ( + CachedGroup, CachedProduct, CachedStore, SyncConfig, SyncFilter, +) + + +def _now(): + return datetime.utcnow() + + +async def _login(client, user): + await client.post("/login", data={"email": user.email, "password": "testpass123"}, + follow_redirects=False) + + +def _make_store(db, user_id, evotor_id="s1", name="Магазин 1"): + s = CachedStore(user_id=user_id, evotor_id=evotor_id, name=name, fetched_at=_now()) + db.add(s) + db.flush() + return s + + +def _make_group(db, user_id, store_id, evotor_id="g1", name="Группа 1"): + g = CachedGroup(user_id=user_id, store_evotor_id=store_id, + evotor_id=evotor_id, name=name, fetched_at=_now()) + db.add(g) + db.flush() + return g + + +def _make_product(db, user_id, store_id, group_id=None, evotor_id="p1", name="Товар 1", + price=100, allow_to_sell=True): + p = CachedProduct( + user_id=user_id, store_evotor_id=store_id, group_evotor_id=group_id, + evotor_id=evotor_id, name=name, price=price, allow_to_sell=allow_to_sell, + fetched_at=_now(), + ) + db.add(p) + db.flush() + return p + + +# ── auth guards ─────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_catalog_stores_requires_login(client): + resp = await client.get("/catalog/stores", follow_redirects=False) + assert resp.status_code == 303 + assert "/login" in resp.headers["location"] + + +# ── GET /catalog/stores ─────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_catalog_stores_empty(client, active_user): + await _login(client, active_user) + resp = await client.get("/catalog/stores") + assert resp.status_code == 200 + assert "не загружены" in resp.text.lower() + + +@pytest.mark.asyncio +async def test_catalog_stores_lists_stores(client, active_user, override_db): + await _login(client, active_user) + _make_store(override_db, active_user.id, "s1", "Главный магазин") + _make_store(override_db, active_user.id, "s2", "Второй магазин") + override_db.commit() + + resp = await client.get("/catalog/stores") + assert resp.status_code == 200 + assert "Главный магазин" in resp.text + assert "Второй магазин" in resp.text + + +@pytest.mark.asyncio +async def test_catalog_stores_not_shows_other_user(client, active_user, user_factory, override_db): + await _login(client, active_user) + other = user_factory.create() + _make_store(override_db, other.id, "s-other", "Чужой магазин") + override_db.commit() + + resp = await client.get("/catalog/stores") + assert "Чужой магазин" not in resp.text + + +# ── GET /catalog/stores/{id}/groups ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_catalog_groups_shows_product_count(client, active_user, override_db): + await _login(client, active_user) + store = _make_store(override_db, active_user.id, "s1") + group = _make_group(override_db, active_user.id, "s1", "g1", "Чай") + _make_product(override_db, active_user.id, "s1", "g1", "p1", "Пуэр") + _make_product(override_db, active_user.id, "s1", "g1", "p2", "Улун") + override_db.commit() + + resp = await client.get(f"/catalog/stores/s1/groups") + assert resp.status_code == 200 + assert "Чай" in resp.text + assert "2" in resp.text # product count + + +@pytest.mark.asyncio +async def test_catalog_groups_zero_count_for_empty_group(client, active_user, override_db): + await _login(client, active_user) + _make_store(override_db, active_user.id, "s1") + _make_group(override_db, active_user.id, "s1", "g1", "Пустая группа") + override_db.commit() + + resp = await client.get("/catalog/stores/s1/groups") + assert resp.status_code == 200 + assert "Пустая группа" in resp.text + assert "0" in resp.text + + +@pytest.mark.asyncio +async def test_catalog_groups_unknown_store_redirects(client, active_user): + await _login(client, active_user) + resp = await client.get("/catalog/stores/no-such-store/groups", follow_redirects=False) + assert resp.status_code == 303 + + +# ── GET /catalog/stores/{id}/products ──────────────────────────────────────── + +@pytest.mark.asyncio +async def test_catalog_products_shows_all(client, active_user, override_db): + await _login(client, active_user) + _make_store(override_db, active_user.id, "s1") + _make_group(override_db, active_user.id, "s1", "g1") + _make_product(override_db, active_user.id, "s1", "g1", "p1", "Пуэр") + _make_product(override_db, active_user.id, "s1", "g1", "p2", "Улун") + override_db.commit() + + resp = await client.get("/catalog/stores/s1/products") + assert resp.status_code == 200 + assert "Пуэр" in resp.text + assert "Улун" in resp.text + + +@pytest.mark.asyncio +async def test_catalog_products_filtered_by_group(client, active_user, override_db): + await _login(client, active_user) + _make_store(override_db, active_user.id, "s1") + _make_group(override_db, active_user.id, "s1", "g1", "Группа А") + _make_group(override_db, active_user.id, "s1", "g2", "Группа Б") + _make_product(override_db, active_user.id, "s1", "g1", "p1", "Товар А") + _make_product(override_db, active_user.id, "s1", "g2", "p2", "Товар Б") + override_db.commit() + + resp = await client.get("/catalog/stores/s1/products?group=g1") + assert resp.status_code == 200 + assert "Товар А" in resp.text + assert "Товар Б" not in resp.text + + +@pytest.mark.asyncio +async def test_catalog_products_shows_group_column(client, active_user, override_db): + await _login(client, active_user) + _make_store(override_db, active_user.id, "s1") + _make_group(override_db, active_user.id, "s1", "g1", "МояГруппа") + _make_product(override_db, active_user.id, "s1", "g1", "p1", "Товар") + override_db.commit() + + resp = await client.get("/catalog/stores/s1/products") + assert "МояГруппа" in resp.text + + +@pytest.mark.asyncio +async def test_catalog_products_ungrouped_shown(client, active_user, override_db): + await _login(client, active_user) + _make_store(override_db, active_user.id, "s1") + _make_product(override_db, active_user.id, "s1", None, "p1", "Без группы") + override_db.commit() + + resp = await client.get("/catalog/stores/s1/products") + assert "Без группы" in resp.text + + +# ── POST /catalog/stores/{id}/toggle ───────────────────────────────────────── + +@pytest.mark.asyncio +async def test_store_toggle_first_disable_seeds_others(client, active_user, override_db): + """First toggle on a store disables it by seeding include-filters for all other stores.""" + await _login(client, active_user) + _make_store(override_db, active_user.id, "s1", "Магазин 1") + _make_store(override_db, active_user.id, "s2", "Магазин 2") + _make_store(override_db, active_user.id, "s3", "Магазин 3") + override_db.commit() + + resp = await client.post("/catalog/stores/s1/toggle", follow_redirects=False) + assert resp.status_code == 303 + + cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first() + filters = override_db.query(SyncFilter).filter_by( + sync_config_id=cfg.id, entity_type="store", filter_mode="include" + ).all() + ids = {f.entity_id for f in filters} + # s1 was toggled off → only s2 and s3 are in include list + assert "s1" not in ids + assert "s2" in ids + assert "s3" in ids + + +@pytest.mark.asyncio +async def test_store_toggle_re_enable(client, active_user, override_db): + """Toggling a disabled store re-adds it to the include list.""" + await _login(client, active_user) + _make_store(override_db, active_user.id, "s1") + _make_store(override_db, active_user.id, "s2") + override_db.commit() + + # Disable s1 first + await client.post("/catalog/stores/s1/toggle") + # Now re-enable s1 + await client.post("/catalog/stores/s1/toggle") + + cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first() + filters = override_db.query(SyncFilter).filter_by( + sync_config_id=cfg.id, entity_type="store", filter_mode="include" + ).all() + ids = {f.entity_id for f in filters} + assert "s1" in ids + + +@pytest.mark.asyncio +async def test_store_toggle_requires_login(client): + resp = await client.post("/catalog/stores/s1/toggle", follow_redirects=False) + assert resp.status_code == 303 + assert "/login" in resp.headers["location"] + + +# ── POST /catalog/stores/{id}/groups/{gid}/toggle ──────────────────────────── + +@pytest.mark.asyncio +async def test_group_toggle_first_disable(client, active_user, override_db): + await _login(client, active_user) + _make_store(override_db, active_user.id, "s1") + _make_group(override_db, active_user.id, "s1", "g1", "Группа 1") + _make_group(override_db, active_user.id, "s1", "g2", "Группа 2") + override_db.commit() + + resp = await client.post("/catalog/stores/s1/groups/g1/toggle", follow_redirects=False) + assert resp.status_code == 303 + + cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first() + filters = override_db.query(SyncFilter).filter_by( + sync_config_id=cfg.id, entity_type="group", filter_mode="include", + parent_entity_id="s1", + ).all() + ids = {f.entity_id for f in filters} + assert "g1" not in ids + assert "g2" in ids + + +@pytest.mark.asyncio +async def test_group_toggle_re_enable(client, active_user, override_db): + await _login(client, active_user) + _make_store(override_db, active_user.id, "s1") + _make_group(override_db, active_user.id, "s1", "g1") + _make_group(override_db, active_user.id, "s1", "g2") + override_db.commit() + + await client.post("/catalog/stores/s1/groups/g1/toggle") + await client.post("/catalog/stores/s1/groups/g1/toggle") + + cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first() + filters = override_db.query(SyncFilter).filter_by( + sync_config_id=cfg.id, entity_type="group", parent_entity_id="s1" + ).all() + ids = {f.entity_id for f in filters} + assert "g1" in ids diff --git a/tests/test_routes_connections.py b/tests/test_routes_connections.py new file mode 100644 index 0000000..f23ab05 --- /dev/null +++ b/tests/test_routes_connections.py @@ -0,0 +1,352 @@ +"""Integration tests for /connections routes.""" +import secrets +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +from web.models.connections import EvotorConnection, VkConnection + + +def _login(client, user): + client.cookies.clear() + return client.post("/login", data={"email": user.email, "password": "testpass123"}, + follow_redirects=False) + + +# ── auth guard ──────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_connections_requires_login(client): + resp = await client.get("/connections", follow_redirects=False) + assert resp.status_code == 303 + assert "/login" in resp.headers["location"] + + +# ── GET /connections ────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_connections_get_no_connections(client, active_user): + await _login(client, active_user) + resp = await client.get("/connections") + assert resp.status_code == 200 + assert "Эвотор" in resp.text + assert "ВКонтакте" in resp.text + assert "Не подключено" in resp.text + + +@pytest.mark.asyncio +async def test_connections_get_shows_connected(client, active_user, override_db): + await _login(client, active_user) + conn = EvotorConnection( + user_id=active_user.id, + evotor_user_id="evo-123", + access_token="tok-abc", + api_token="api-tok", + connected_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + override_db.add(conn) + override_db.commit() + + resp = await client.get("/connections") + assert resp.status_code == 200 + assert "Подключено" in resp.text + assert "tok-abc"[:8] in resp.text + + +# ── POST /connections/evotor ────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_connections_evotor_post_creates(client, active_user, override_db): + await _login(client, active_user) + resp = await client.post("/connections/evotor", data={ + "access_token": "new-evotor-token", + "evotor_user_id": "", + }, follow_redirects=False) + assert resp.status_code == 303 + assert "success=1" in resp.headers["location"] + + conn = override_db.query(EvotorConnection).filter_by(user_id=active_user.id).first() + assert conn is not None + assert conn.access_token == "new-evotor-token" + assert conn.api_token is not None + + +@pytest.mark.asyncio +async def test_connections_evotor_post_updates(client, active_user, override_db): + await _login(client, active_user) + conn = EvotorConnection( + user_id=active_user.id, evotor_user_id="evo-upd", + access_token="old-token", api_token="api", + connected_at=datetime.utcnow(), updated_at=datetime.utcnow(), + ) + override_db.add(conn) + override_db.commit() + + await client.post("/connections/evotor", data={"access_token": "updated-token"}) + override_db.refresh(conn) + assert conn.access_token == "updated-token" + + +@pytest.mark.asyncio +async def test_connections_evotor_post_empty_token(client, active_user): + await _login(client, active_user) + resp = await client.post("/connections/evotor", data={"access_token": ""}) + assert resp.status_code == 200 + assert "обязателен" in resp.text.lower() + + +# ── POST /connections/evotor/disconnect ─────────────────────────────────────── + +@pytest.mark.asyncio +async def test_connections_evotor_disconnect(client, active_user, override_db): + await _login(client, active_user) + conn = EvotorConnection( + user_id=active_user.id, evotor_user_id="evo-del", + access_token="tok", api_token="api", + connected_at=datetime.utcnow(), updated_at=datetime.utcnow(), + ) + override_db.add(conn) + override_db.commit() + + resp = await client.post("/connections/evotor/disconnect", follow_redirects=False) + assert resp.status_code == 303 + assert override_db.query(EvotorConnection).filter_by(user_id=active_user.id).first() is None + + +# ── POST /connections/vk (manual token) ────────────────────────────────────── + +@pytest.mark.asyncio +async def test_connections_vk_post_creates(client, active_user, override_db): + await _login(client, active_user) + resp = await client.post("/connections/vk", data={ + "access_token": "vk1.a.testtoken", + "vk_group_id": "123456789", + }, follow_redirects=False) + assert resp.status_code == 303 + assert "success=1" in resp.headers["location"] + + conn = override_db.query(VkConnection).filter_by(user_id=active_user.id).first() + assert conn is not None + assert conn.access_token == "vk1.a.testtoken" + assert conn.vk_user_id == "123456789" + + +@pytest.mark.asyncio +async def test_connections_vk_post_empty_token(client, active_user): + await _login(client, active_user) + resp = await client.post("/connections/vk", data={"access_token": "", "vk_group_id": ""}) + assert resp.status_code == 200 + assert "обязателен" in resp.text.lower() + + +# ── POST /connections/vk/disconnect ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_connections_vk_disconnect(client, active_user, override_db): + await _login(client, active_user) + conn = VkConnection( + user_id=active_user.id, access_token="vk-tok", + connected_at=datetime.utcnow(), updated_at=datetime.utcnow(), + ) + override_db.add(conn) + override_db.commit() + + resp = await client.post("/connections/vk/disconnect", follow_redirects=False) + assert resp.status_code == 303 + assert override_db.query(VkConnection).filter_by(user_id=active_user.id).first() is None + + +# ── GET /vk-auth ────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_vk_auth_redirects_to_vk(client, active_user, monkeypatch): + monkeypatch.setattr("web.routes.connections.settings.VK_CLIENT_ID", "53265827") + monkeypatch.setattr("web.routes.connections.settings.BASE_URL", "http://test") + await _login(client, active_user) + resp = await client.get("/vk-auth", follow_redirects=False) + assert resp.status_code == 302 + assert "oauth.vk.com/authorize" in resp.headers["location"] + assert "client_id=53265827" in resp.headers["location"] + assert "response_type=token" in resp.headers["location"] + + +@pytest.mark.asyncio +async def test_vk_auth_no_client_id(client, active_user, monkeypatch): + monkeypatch.setattr("web.routes.connections.settings.VK_CLIENT_ID", "") + await _login(client, active_user) + resp = await client.get("/vk-auth", follow_redirects=False) + assert resp.status_code == 303 + assert "error=vk_not_configured" in resp.headers["location"] + + +# ── GET /vk-callback ────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_vk_callback_page_returns_html(client, active_user): + await _login(client, active_user) + resp = await client.get("/vk-callback") + assert resp.status_code == 200 + assert "access_token" in resp.text + assert "fetch" in resp.text + + +# ── POST /vk-callback/save ──────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_vk_callback_save_valid(client, active_user, override_db): + await _login(client, active_user) + # Seed state into session via /vk-auth call + monkeypatch_state = "test-state-xyz" + # Manually set expected state in session by calling the save endpoint + # with a pre-seeded state — we bypass the session by mocking get_current_user + # Instead: call /vk-auth to seed the session state, then intercept + # Since we can't easily inspect session, test save with wrong state first + resp = await client.post("/vk-callback/save", json={ + "access_token": "vk1.a.token", + "state": "wrong-state", + "user_id": "12345", + "expires_in": "86400", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is False + assert "state" in data["message"].lower() + + +@pytest.mark.asyncio +async def test_vk_callback_save_no_token(client, active_user): + await _login(client, active_user) + resp = await client.post("/vk-callback/save", json={ + "access_token": "", + "state": "", + }) + assert resp.status_code == 200 + assert resp.json()["ok"] is False + + +@pytest.mark.asyncio +async def test_vk_callback_save_unauthenticated(client): + resp = await client.post("/vk-callback/save", json={ + "access_token": "tok", "state": "s", + }) + assert resp.status_code == 401 + + +# ── POST /connections/evotor/test ───────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_evotor_test_no_connection(client, active_user): + await _login(client, active_user) + resp = await client.post("/connections/evotor/test") + assert resp.status_code == 200 + assert resp.json()["ok"] is False + assert "не настроено" in resp.json()["message"] + + +@pytest.mark.asyncio +async def test_evotor_test_success(client, active_user, override_db): + await _login(client, active_user) + conn = EvotorConnection( + user_id=active_user.id, evotor_user_id="evo-t", + access_token="tok", api_token="api", + connected_at=datetime.utcnow(), updated_at=datetime.utcnow(), + ) + override_db.add(conn) + override_db.commit() + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"items": [{"id": "s1"}, {"id": "s2"}]} + + with patch("web.routes.connections.httpx.get", return_value=mock_resp): + resp = await client.post("/connections/evotor/test") + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert "2" in data["message"] + + +@pytest.mark.asyncio +async def test_evotor_test_invalid_token(client, active_user, override_db): + await _login(client, active_user) + conn = EvotorConnection( + user_id=active_user.id, evotor_user_id="evo-inv", + access_token="bad-tok", api_token="api", + connected_at=datetime.utcnow(), updated_at=datetime.utcnow(), + ) + override_db.add(conn) + override_db.commit() + + mock_resp = MagicMock() + mock_resp.status_code = 401 + + with patch("web.routes.connections.httpx.get", return_value=mock_resp): + resp = await client.post("/connections/evotor/test") + data = resp.json() + assert data["ok"] is False + assert "401" in data["message"] + + +# ── POST /connections/vk/test ───────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_vk_test_no_group_id(client, active_user, override_db): + await _login(client, active_user) + conn = VkConnection( + user_id=active_user.id, access_token="vk-tok", + vk_user_id=None, + connected_at=datetime.utcnow(), updated_at=datetime.utcnow(), + ) + override_db.add(conn) + override_db.commit() + + resp = await client.post("/connections/vk/test") + assert resp.json()["ok"] is False + assert "сообщества" in resp.json()["message"].lower() + + +@pytest.mark.asyncio +async def test_vk_test_success(client, active_user, override_db): + await _login(client, active_user) + conn = VkConnection( + user_id=active_user.id, access_token="vk-tok", + vk_user_id="229744980", + connected_at=datetime.utcnow(), updated_at=datetime.utcnow(), + ) + override_db.add(conn) + override_db.commit() + + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": {"groups": [ + {"name": "Тестовая чайная", "market": {"enabled": True}} + ]}} + + with patch("web.routes.connections.httpx.get", return_value=mock_resp): + resp = await client.post("/connections/vk/test") + data = resp.json() + assert data["ok"] is True + assert "Тестовая чайная" in data["message"] + assert "включён" in data["message"] + + +@pytest.mark.asyncio +async def test_vk_test_api_error(client, active_user, override_db): + await _login(client, active_user) + conn = VkConnection( + user_id=active_user.id, access_token="vk-tok", + vk_user_id="229744980", + connected_at=datetime.utcnow(), updated_at=datetime.utcnow(), + ) + override_db.add(conn) + override_db.commit() + + mock_resp = MagicMock() + mock_resp.json.return_value = {"error": {"error_code": 5, "error_msg": "User authorization failed"}} + + with patch("web.routes.connections.httpx.get", return_value=mock_resp): + resp = await client.post("/connections/vk/test") + data = resp.json() + assert data["ok"] is False + assert "5" in data["message"] diff --git a/tests/test_tasks_catalog.py b/tests/test_tasks_catalog.py new file mode 100644 index 0000000..b64595d --- /dev/null +++ b/tests/test_tasks_catalog.py @@ -0,0 +1,133 @@ +"""Unit tests for catalog task helpers and refresh_catalog logic.""" +from unittest.mock import MagicMock, patch + +import pytest + +from web.tasks.catalog import _fetch_groups, _fetch_products, _fetch_stores + + +# ── _fetch_stores ───────────────────────────────────────────────────────────── + +def test_fetch_stores_list_response(): + mock = MagicMock() + mock.raise_for_status = MagicMock() + mock.json.return_value = [{"id": "s1", "name": "Магазин"}] + with patch("web.tasks.catalog.httpx.get", return_value=mock): + result = _fetch_stores("tok") + assert result == [{"id": "s1", "name": "Магазин"}] + + +def test_fetch_stores_dict_with_items(): + mock = MagicMock() + mock.raise_for_status = MagicMock() + mock.json.return_value = {"items": [{"id": "s1"}], "total": 1} + with patch("web.tasks.catalog.httpx.get", return_value=mock): + result = _fetch_stores("tok") + assert result == [{"id": "s1"}] + + +# ── _fetch_groups ───────────────────────────────────────────────────────────── + +def test_fetch_groups_success(): + mock = MagicMock() + mock.status_code = 200 + mock.raise_for_status = MagicMock() + mock.json.return_value = {"items": [{"id": "g1", "name": "Чай"}]} + with patch("web.tasks.catalog.httpx.get", return_value=mock): + result = _fetch_groups("tok", "s1") + assert result == [{"id": "g1", "name": "Чай"}] + + +@pytest.mark.parametrize("status_code", [402, 403]) +def test_fetch_groups_returns_none_on_restricted(status_code): + mock = MagicMock() + mock.status_code = status_code + with patch("web.tasks.catalog.httpx.get", return_value=mock): + result = _fetch_groups("tok", "s1") + assert result is None + + +# ── _fetch_products ─────────────────────────────────────────────────────────── + +def test_fetch_products_success(): + mock = MagicMock() + mock.status_code = 200 + mock.raise_for_status = MagicMock() + mock.json.return_value = [{"id": "p1", "name": "Пуэр", "price": {"sum": 15000}}] + with patch("web.tasks.catalog.httpx.get", return_value=mock): + result = _fetch_products("tok", "s1") + assert len(result) == 1 + assert result[0]["name"] == "Пуэр" + + +@pytest.mark.parametrize("status_code", [402, 403]) +def test_fetch_products_returns_none_on_restricted(status_code): + mock = MagicMock() + mock.status_code = status_code + with patch("web.tasks.catalog.httpx.get", return_value=mock): + result = _fetch_products("tok", "s1") + assert result is None + + +# ── refresh_catalog task (integration with mocked HTTP) ────────────────────── + +@pytest.mark.asyncio +async def test_refresh_catalog_upserts_stores(override_db): + from web.database import SessionLocal + from web.models.connections import CachedStore, EvotorConnection + from web.tasks.catalog import _sync_user + + user_id = 1 + token = "test-tok" + + stores_data = [{"id": "s-new", "name": "Новый магазин", "address": "ул. Ленина 1"}] + groups_data = [] + products_data = [] + + with patch("web.tasks.catalog._fetch_stores", return_value=stores_data), \ + patch("web.tasks.catalog._fetch_groups", return_value=groups_data), \ + patch("web.tasks.catalog._fetch_products", return_value=products_data): + _sync_user(override_db, user_id, token) + + store = override_db.query(CachedStore).filter_by(user_id=user_id, evotor_id="s-new").first() + assert store is not None + assert store.name == "Новый магазин" + + +@pytest.mark.asyncio +async def test_refresh_catalog_upserts_products(override_db): + from web.models.connections import CachedProduct + from web.tasks.catalog import _sync_user + + user_id = 2 + token = "tok" + + stores_data = [{"id": "s1", "name": "Магазин"}] + groups_data = [{"id": "g1", "name": "Чай"}] + products_data = [{ + "id": "p1", "name": "Пуэр", "price": 35000, + "quantity": 10, "measureName": "шт", "code": "001", + "allowToSell": True, "group": "g1", + }] + + with patch("web.tasks.catalog._fetch_stores", return_value=stores_data), \ + patch("web.tasks.catalog._fetch_groups", return_value=groups_data), \ + patch("web.tasks.catalog._fetch_products", return_value=products_data): + _sync_user(override_db, user_id, token) + + p = override_db.query(CachedProduct).filter_by(user_id=user_id, evotor_id="p1").first() + assert p is not None + assert p.name == "Пуэр" + assert p.group_evotor_id == "g1" + assert p.allow_to_sell is True + + +@pytest.mark.asyncio +async def test_refresh_catalog_skips_fetch_stores_failure(override_db): + from web.models.connections import CachedStore + from web.tasks.catalog import _sync_user + + with patch("web.tasks.catalog._fetch_stores", side_effect=Exception("network error")): + _sync_user(override_db, user_id=99, token="tok") + + assert override_db.query(CachedStore).filter_by(user_id=99).count() == 0 diff --git a/tests/test_tasks_vk_sync.py b/tests/test_tasks_vk_sync.py new file mode 100644 index 0000000..de1ab1b --- /dev/null +++ b/tests/test_tasks_vk_sync.py @@ -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 diff --git a/web/config.py b/web/config.py index 877d235..3d13d2b 100644 --- a/web/config.py +++ b/web/config.py @@ -11,9 +11,15 @@ class Settings(BaseSettings): EVOTOR_APP_ID: str = "" EVOTOR_WEBHOOK_SECRET: str = "" + VK_CLIENT_ID: str = "" + VK_CLIENT_SECRET: str = "" + JIVOSITE_WIDGET_ID: str = "" VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png" VK_API_VERSION: str = "5.199" + VK_CATEGORY_ID: int = 40932 + VK_STOCK_AMOUNT: int = 1000 + VK_WEIGHT_PRICE_MULTIPLIER: int = 10 CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600 INVITE_EXPIRE_HOURS: int = 48 diff --git a/web/migrations/versions/0005_cached_products_vk_product_id.py b/web/migrations/versions/0005_cached_products_vk_product_id.py new file mode 100644 index 0000000..f3a14dd --- /dev/null +++ b/web/migrations/versions/0005_cached_products_vk_product_id.py @@ -0,0 +1,24 @@ +"""Add vk_product_id to cached_products + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-05-01 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0005" +down_revision: Union[str, None] = "0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("cached_products", sa.Column("vk_product_id", sa.String(50), nullable=True)) + + +def downgrade() -> None: + op.drop_column("cached_products", "vk_product_id") diff --git a/web/migrations/versions/0006_vk_connection_token_fields.py b/web/migrations/versions/0006_vk_connection_token_fields.py new file mode 100644 index 0000000..d774788 --- /dev/null +++ b/web/migrations/versions/0006_vk_connection_token_fields.py @@ -0,0 +1,26 @@ +"""Add refresh_token and token_expires_at to vk_connections + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-05-12 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0006" +down_revision: Union[str, None] = "0005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("vk_connections", sa.Column("refresh_token", sa.Text, nullable=True)) + op.add_column("vk_connections", sa.Column("token_expires_at", sa.DateTime, nullable=True)) + + +def downgrade() -> None: + op.drop_column("vk_connections", "token_expires_at") + op.drop_column("vk_connections", "refresh_token") diff --git a/web/models/connections.py b/web/models/connections.py index 108cbdd..564b34f 100644 --- a/web/models/connections.py +++ b/web/models/connections.py @@ -35,6 +35,8 @@ class VkConnection(Base): id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) access_token = Column(Text, nullable=False) + refresh_token = Column(Text, nullable=True) + token_expires_at = Column(DateTime, nullable=True) vk_user_id = Column(String(50), nullable=True) first_name = Column(String(255), nullable=True) last_name = Column(String(255), nullable=True) @@ -167,6 +169,7 @@ class CachedProduct(Base): allow_to_sell = Column(Boolean, nullable=True) fetched_at = Column(DateTime, nullable=False) synced_at = Column(DateTime, nullable=True) + vk_product_id = Column(String(50), nullable=True) # VK market item ID after first push __table_args__ = ( UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"), diff --git a/web/routes/catalog.py b/web/routes/catalog.py index 580aa77..9b728a2 100644 --- a/web/routes/catalog.py +++ b/web/routes/catalog.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import func from sqlalchemy.orm import Session from web.auth.session import get_current_user @@ -99,9 +100,19 @@ async def catalog_groups(store_evotor_id: str, request: Request, db: Session = D .all() ) enabled_ids = _enabled_group_ids(db, user.id, store_evotor_id) + + counts_q = ( + db.query(CachedProduct.group_evotor_id, func.count().label("cnt")) + .filter(CachedProduct.user_id == user.id, CachedProduct.store_evotor_id == store_evotor_id) + .group_by(CachedProduct.group_evotor_id) + .all() + ) + product_counts = {row.group_evotor_id: row.cnt for row in counts_q} + return _render(request, "catalog/groups.html", { "user": user, "store": store, "groups": groups, "enabled_ids": enabled_ids, + "product_counts": product_counts, }) @@ -135,12 +146,14 @@ async def catalog_products(store_evotor_id: str, request: Request, db: Session = .order_by(CachedGroup.name) .all() ) + group_map = {g.evotor_id: g.name for g in groups} return _render(request, "catalog/products.html", { "user": user, "store": store, "products": products, "groups": groups, "group_id": group_id, + "group_map": group_map, }) diff --git a/web/routes/connections.py b/web/routes/connections.py index df8644c..8156a9b 100644 --- a/web/routes/connections.py +++ b/web/routes/connections.py @@ -1,5 +1,6 @@ import secrets -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from urllib.parse import urlencode import httpx from fastapi import APIRouter, Depends, Request @@ -12,6 +13,8 @@ from web.database import get_db from web.models.connections import EvotorConnection, VkConnection from web.templates_env import templates +VK_SCOPE = 335876 # photos(4) + wall(8192) + groups(262144) + offline(65536) + router = APIRouter() @@ -137,6 +140,146 @@ async def connections_vk_post(request: Request, db: Session = Depends(get_db)): return RedirectResponse("/connections?success=1", 303) +@router.get("/vk-auth") +async def vk_auth(request: Request): + try: + get_current_user(request, next(get_db())) + except Exception: + return RedirectResponse("/login", 303) + + if not settings.VK_CLIENT_ID: + return RedirectResponse("/connections?error=vk_not_configured", 303) + + state = secrets.token_urlsafe(16) + request.session["vk_oauth_state"] = state + + redirect_uri = f"{settings.BASE_URL}/vk-callback" + params = urlencode({ + "client_id": settings.VK_CLIENT_ID, + "redirect_uri": redirect_uri, + "scope": VK_SCOPE, + "response_type": "token", + "display": "page", + "state": state, + "revoke": "1", + }) + return RedirectResponse(f"https://oauth.vk.com/authorize?{params}", 302) + + +@router.get("/vk-callback") +async def vk_callback_page(request: Request): + """Serves the callback page that reads the token from the URL fragment and POSTs it.""" + return HTMLResponse(""" + +
Завершаем авторизацию…
+
- Укажите токен пользователя VK с правами market,photos,groups
- и ID сообщества, в котором включён Маркет.
-