diff --git a/backend/bot/app/web/webapp/account.py b/backend/bot/app/web/webapp/account.py index d6ed3ce..fec749b 100644 --- a/backend/bot/app/web/webapp/account.py +++ b/backend/bot/app/web/webapp/account.py @@ -430,6 +430,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response: async def me_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) settings: Settings = request.app["settings"] + fresh = str(request.query.get("fresh") or "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + if fresh: + await _invalidate_webapp_user_caches(settings, user_id) + data = await _build_user_payload(request, user_id) + return web.json_response({"ok": True, **data}) + data = await webapp_cached_user_payload( settings, "me", diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 17fdf36..ed9b5e4 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -1,6 +1,8 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches + async def apply_promo_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) @@ -329,6 +331,8 @@ async def activate_trial_route(request: web.Request) -> web.Response: await session.rollback() logger.exception("Failed to mark WebApp trial activation for ad attribution") + await invalidate_webapp_user_caches(settings, user_id) + return web.json_response( { "ok": True, @@ -724,6 +728,8 @@ async def payment_status_route(request: web.Request) -> web.Response: if not payment or payment.user_id != user_id: return _json_error(404, "not_found", "Payment not found") payment = await _refresh_yookassa_payment_status(request, session, payment) + if payment.status == "succeeded": + await invalidate_webapp_user_caches(request.app["settings"], user_id) return web.json_response( { "ok": True, diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 886aa76..034b93d 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -641,7 +641,7 @@ } } if (shouldRefreshProfile) { - await loadData(); + await loadData({ fresh: true }); const shown = await maybeShowActivationSuccessDialog({ source: "watch", paymentId: pending?.paymentId, @@ -685,7 +685,7 @@ activationResumeLastCheckAt = now; activationResumeRefreshBusy = true; try { - await loadData(); + await loadData({ fresh: true }); const shown = await maybeShowActivationSuccessDialog({ source: "resume" }); if (!shown) startPendingActivationWatch(); } catch (_error) { @@ -1072,6 +1072,7 @@ getCsrfToken: () => csrfToken, }); if (mode === "app" && screen !== "admin") { + if (hasPendingActivationHandoff()) await loadData({ fresh: true }); const shown = await maybeShowActivationSuccessDialog({ source: "boot" }); if (!shown) startPendingActivationWatch(); } @@ -1115,8 +1116,8 @@ syncPasswordLoginPath(nextEnabled, replace); } - async function loadData() { - const payload = await api("/me"); + async function loadData(options = {}) { + const payload = await api(options?.fresh ? "/me?fresh=1" : "/me"); if (!payload.ok) throw new Error(payload.error || "load_failed"); data = payload; billingStore.update((s) => ({ @@ -1458,7 +1459,7 @@ ? t("wa_promo_activated_until", { date: response.end_date_text }) : t("wa_promo_activated"); promoIsError = false; - await loadData(); + await loadData({ fresh: true }); } catch (error) { promoStatus = error?.message || t("wa_promo_activation_failed"); promoIsError = true; @@ -1481,7 +1482,7 @@ if (!response.ok) throw response; trialActivationResult = response; showToast(t("wa_trial_activated")); - await loadData(); + await loadData({ fresh: true }); await maybeShowActivationSuccessDialog({ source: "trial", force: true }); } catch (error) { const message = error?.message || t("wa_trial_activation_failed"); diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 3bc8edb..185ed67 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -751,7 +751,7 @@ export async function mockApi(path, options = {}, context = {}) { return { ok: true, ticket, messages: clone(supportMessages[ticket.ticket_id] || []) }; } if (cleanPath === "/support/unread") return { ok: true, unread: 1 }; - if (path === "/me") return clone(DEV_MOCK.data); + if (cleanPath === "/me") return clone(DEV_MOCK.data); if (path === "/subscription-guides") return clone(DEV_MOCK.data.subscription_guides); if (cleanPath.startsWith("/subscription-guides/public/")) { const shareToken = decodeURIComponent(cleanPath.split("/").pop() || ""); diff --git a/frontend/src/lib/webapp/stores/billingStore.js b/frontend/src/lib/webapp/stores/billingStore.js index 6ce80f4..7728118 100644 --- a/frontend/src/lib/webapp/stores/billingStore.js +++ b/frontend/src/lib/webapp/stores/billingStore.js @@ -64,7 +64,7 @@ export function createBillingStore({ paymentPollToken += 1; } showToast(t("wa_payment_success", {}, "Payment successful")); - await loadData(); + await loadData({ fresh: true }); if ( successContext.initialSubscriptionPayment && typeof onSubscriptionActivated === "function" diff --git a/tests/test_webapp_payment_status.py b/tests/test_webapp_payment_status.py index 6a4410a..a87102e 100644 --- a/tests/test_webapp_payment_status.py +++ b/tests/test_webapp_payment_status.py @@ -2,11 +2,23 @@ from types import SimpleNamespace from unittest import IsolatedAsyncioTestCase from unittest.mock import AsyncMock, patch +import bot.app.web.subscription_webapp # noqa: F401 from bot.app.web.webapp import billing as billing_module from bot.payment_providers.base import WebAppPaymentContext from bot.payment_providers.yookassa import create_webapp_payment +class _SessionFactory: + def __call__(self): + return self + + async def __aenter__(self): + return SimpleNamespace() + + async def __aexit__(self, exc_type, exc, tb): + return False + + class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): async def test_yookassa_pending_payment_refresh_processes_succeeded_provider_status(self): payment = SimpleNamespace( @@ -123,3 +135,34 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): yookassa_service.create_payment.await_args.kwargs["save_payment_method"], False, ) + + async def test_payment_status_invalidates_profile_cache_for_succeeded_payment(self): + settings = SimpleNamespace() + payment = SimpleNamespace(payment_id=42, user_id=1001, status="succeeded") + request = SimpleNamespace( + app={"settings": settings, "async_session_factory": _SessionFactory()}, + match_info={"payment_id": "42"}, + ) + + with ( + patch.object(billing_module, "_require_user_id", return_value=1001), + patch.object( + billing_module.payment_dal, + "get_payment_by_db_id", + AsyncMock(return_value=payment), + ), + patch.object( + billing_module, + "_refresh_yookassa_payment_status", + AsyncMock(return_value=payment), + ), + patch.object( + billing_module, + "invalidate_webapp_user_caches", + AsyncMock(), + ) as invalidate_cache, + ): + response = await billing_module.payment_status_route(request) + + invalidate_cache.assert_awaited_once_with(settings, 1001) + self.assertEqual(response.status, 200) diff --git a/tests/test_webapp_profile_cache.py b/tests/test_webapp_profile_cache.py new file mode 100644 index 0000000..51c7779 --- /dev/null +++ b/tests/test_webapp_profile_cache.py @@ -0,0 +1,41 @@ +import json +from types import SimpleNamespace +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, patch + +import bot.app.web.subscription_webapp # noqa: F401 +from bot.app.web.webapp import account as account_module + + +class WebAppProfileCacheTests(IsolatedAsyncioTestCase): + async def test_me_route_fresh_bypasses_cache_and_invalidates_payload(self): + settings = SimpleNamespace(WEBAPP_ME_CACHE_TTL_SECONDS=60) + request = SimpleNamespace(app={"settings": settings}, query={"fresh": "1"}) + + with ( + patch.object(account_module, "_require_user_id", return_value=42), + patch.object( + account_module, + "_invalidate_webapp_user_caches", + AsyncMock(), + ) as invalidate_cache, + patch.object( + account_module, + "_build_user_payload", + AsyncMock(return_value={"user": {"id": 42}, "subscription": {"active": True}}), + ) as build_payload, + patch.object( + account_module, + "webapp_cached_user_payload", + AsyncMock(), + ) as cached_payload, + ): + response = await account_module.me_route(request) + + invalidate_cache.assert_awaited_once_with(settings, 42) + build_payload.assert_awaited_once_with(request, 42) + cached_payload.assert_not_awaited() + self.assertEqual( + json.loads(response.text), + {"ok": True, "user": {"id": 42}, "subscription": {"active": True}}, + )