From 8e2058a9620aa90da4a146ca9728c04b9ca7dfbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Fri, 4 Sep 2026 01:52:30 +0000 Subject: [PATCH] refactor: pass notification language explicitly (#41770) --- api/controllers/console/notification.py | 17 ++-- api/extensions/ext_application_services.py | 1 - api/openapi/markdown/console-openapi.md | 14 +++- api/services/notification_service.py | 13 +-- .../controllers/console/test_notification.py | 22 ++++- .../test_ext_application_services.py | 1 - .../services/test_notification_service.py | 83 ++++++------------- .../api/console/notification/orpc.gen.ts | 6 +- .../api/console/notification/types.gen.ts | 4 +- .../api/console/notification/zod.gen.ts | 4 + .../__tests__/notification.spec.tsx | 34 ++++++-- .../app/in-site-message/notification.tsx | 3 + 12 files changed, 116 insertions(+), 86 deletions(-) diff --git a/api/controllers/console/notification.py b/api/controllers/console/notification.py index 080080bb361..03ebbc2ba6d 100644 --- a/api/controllers/console/notification.py +++ b/api/controllers/console/notification.py @@ -2,7 +2,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field from controllers.common.fields import SimpleResultResponse -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.flask_admission import console_account_admission from controllers.console.wraps import model_validate @@ -17,6 +17,10 @@ class DismissNotificationPayload(BaseModel): notification_id: str = Field(...) +class NotificationQuery(BaseModel): + language: str = Field(default="en-US", description="Notification language") + + class NotificationItemResponse(ResponseModel): notification_id: str | None = None frequency: str | None = None @@ -32,17 +36,19 @@ class NotificationResponse(ResponseModel): notifications: list[NotificationItemResponse] -register_schema_models(console_ns, DismissNotificationPayload) +register_schema_models(console_ns, DismissNotificationPayload, NotificationQuery) register_response_schema_models(console_ns, SimpleResultResponse, NotificationResponse) @console_ns.route("/notification") class NotificationApi(Resource): @console_ns.doc("get_notification") + @console_ns.doc(params=query_params_from_model(NotificationQuery)) @console_ns.doc( description=( "Return the active in-product notification for the current user " - "in their interface language (falls back to English if unavailable). " + "in the requested language (defaults to English when omitted). " + "Unavailable translations fall back to English, then the first available content. " "The notification is NOT marked as seen here; call POST /notification/dismiss " "when the user explicitly closes the modal." ), @@ -53,8 +59,9 @@ class NotificationApi(Resource): ) @console_ns.response(200, "Success", console_ns.models[NotificationResponse.__name__]) @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) - def get(self, request_context: RequestContext): - result = application_services().notifications.get_active(request_context) + @model_validate(NotificationQuery) + def get(self, query: NotificationQuery, request_context: RequestContext): + result = application_services().notifications.get_active(request_context, query.language) return dump_response(NotificationResponse, result), 200 diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 5885589c27b..d39a84c150c 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -582,7 +582,6 @@ def build_application_services( expected_password=initialization_password, ), notifications=NotificationService( - accounts=accounts, notifications=BillingNotificationGateway(), ), step_by_step_tour=StepByStepTourService( diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 018fe4e47a8..70ddbe73455 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -7196,7 +7196,13 @@ Get instruction generation template | 302 | Redirect to OAuth callback page | ### [GET] /notification -Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal. +Return the active in-product notification for the current user in the requested language (defaults to English when omitted). Unavailable translations fall back to English, then the first available content. The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| language | query | Notification language | No | string,
**Default:** en-US | #### Responses @@ -19670,6 +19676,12 @@ Coarse node-level status used by Inspector to pick a banner. | title | string | | Yes | | title_pic_url | string | | Yes | +#### NotificationQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| language | string,
**Default:** en-US | Notification language | No | + #### NotificationResponse | Name | Type | Description | Required | diff --git a/api/services/notification_service.py b/api/services/notification_service.py index 13236ef16ed..1d20f01f14a 100644 --- a/api/services/notification_service.py +++ b/api/services/notification_service.py @@ -2,8 +2,8 @@ from typing import Protocol +from constants.languages import languages from machinery.context import RequestContext -from services.account_ports import AccountRepository from services.entities.notification_entities import ( AccountNotification, AccountNotificationBatch, @@ -22,20 +22,15 @@ class NotificationGateway(Protocol): class NotificationService: - def __init__(self, *, accounts: AccountRepository, notifications: NotificationGateway) -> None: - self._accounts = accounts + def __init__(self, *, notifications: NotificationGateway) -> None: self._notifications = notifications - def get_active(self, context: RequestContext) -> NotificationResult: + def get_active(self, context: RequestContext, language: str) -> NotificationResult: batch = self._notifications.get_active(context.account_id) if not batch.should_show: return NotificationResult(should_show=False, notifications=()) - account = self._accounts.get(context.account_id) - if account is None: - raise RuntimeError("Console account admission resolved an unknown account") - language = account.interface_language or _FALLBACK_LANGUAGE - + language = language if language in languages else _FALLBACK_LANGUAGE notifications = tuple(self._localize(notification, language) for notification in batch.notifications) return NotificationResult(should_show=bool(notifications), notifications=notifications) diff --git a/api/tests/unit_tests/controllers/console/test_notification.py b/api/tests/unit_tests/controllers/console/test_notification.py index 48843d1af8a..8a37c06c1a0 100644 --- a/api/tests/unit_tests/controllers/console/test_notification.py +++ b/api/tests/unit_tests/controllers/console/test_notification.py @@ -2,6 +2,9 @@ from inspect import unwrap from types import SimpleNamespace from unittest.mock import Mock, patch +import pytest +from flask import Flask + from controllers.console.notification import ( DismissNotificationPayload, NotificationApi, @@ -20,7 +23,15 @@ def _request_context() -> RequestContext: ) -def test_get_notification_delegates_and_serializes_result() -> None: +@pytest.mark.parametrize( + ("query_string", "expected_language"), + [({}, "en-US"), ({"language": "zh-Hans"}, "zh-Hans")], +) +def test_get_notification_validates_language_query_and_serializes_result( + app: Flask, + query_string: dict[str, str], + expected_language: str, +) -> None: service = Mock() service.get_active.return_value = NotificationResult( should_show=True, @@ -38,10 +49,13 @@ def test_get_notification_delegates_and_serializes_result() -> None: ) services = SimpleNamespace(notifications=service) api = NotificationApi() - method = unwrap(api.get) + method = api.get.__wrapped__ context = _request_context() - with patch("controllers.console.notification.application_services", return_value=services): + with ( + app.test_request_context("/notification", query_string=query_string), + patch("controllers.console.notification.application_services", return_value=services), + ): result, status = method(api, context) assert status == 200 @@ -59,7 +73,7 @@ def test_get_notification_delegates_and_serializes_result() -> None: } ], } - service.get_active.assert_called_once_with(context) + service.get_active.assert_called_once_with(context, expected_language) def test_dismiss_notification_delegates_with_stable_account_context() -> None: diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index ade8e369fa1..649958ca5bf 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -434,7 +434,6 @@ def test_build_application_services_wires_account_profile_repository( assert services.accounts.deletion._accounts is accounts assert services.accounts.authentication._accounts is accounts assert services.accounts.authentication._workspaces is services.workspace_queries._workspaces - assert services.notifications._accounts is accounts assert services.step_by_step_tour._accounts is accounts assert services.accounts.deletion._memberships is services.workspace_queries._workspaces integrations = services.accounts.integrations._integrations diff --git a/api/tests/unit_tests/services/test_notification_service.py b/api/tests/unit_tests/services/test_notification_service.py index 3be7f08a6f7..18b3dfef93f 100644 --- a/api/tests/unit_tests/services/test_notification_service.py +++ b/api/tests/unit_tests/services/test_notification_service.py @@ -1,11 +1,4 @@ -from datetime import datetime -from unittest.mock import Mock - -import pytest - from machinery.context import RequestContext -from services.account_ports import AccountRepository -from services.entities.account_entities import AccountSnapshot from services.entities.notification_entities import ( AccountNotification, AccountNotificationBatch, @@ -39,30 +32,6 @@ class NotificationGatewayStub: self.dismissals.append((notification_id, account_id)) -def _account(language: str | None = "zh-Hans") -> AccountSnapshot: - return AccountSnapshot( - id="account-1", - name="Account", - email="account@example.com", - avatar=None, - is_password_set=False, - interface_language=language, - interface_theme="light", - timezone="UTC", - last_login_at=None, - last_login_ip=None, - status="active", - initialized_at=None, - created_at=datetime(2026, 1, 1), - ) - - -def _accounts(account: AccountSnapshot | None) -> Mock: - accounts = Mock(spec=AccountRepository) - accounts.get.return_value = account - return accounts - - def _notification(contents: dict[str, NotificationContent]) -> AccountNotification: return AccountNotification( notification_id="notification-1", @@ -71,19 +40,18 @@ def _notification(contents: dict[str, NotificationContent]) -> AccountNotificati ) -def test_get_active_localizes_notification_for_account_language() -> None: +def test_get_active_localizes_notification_for_requested_language() -> None: chinese = NotificationContent("zh-Hans", "标题", "副标题", "正文", "zh.png") english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png") gateway = NotificationGatewayStub( AccountNotificationBatch(True, (_notification({"zh-Hans": chinese, "en-US": english}),)) ) - service = NotificationService(accounts=_accounts(_account()), notifications=gateway) + service = NotificationService(notifications=gateway) - result = service.get_active(_context()) + result = service.get_active(_context(), "zh-Hans") - assert result == NotificationResult( - should_show=True, - notifications=(NotificationItem("notification-1", "once", "zh-Hans", "标题", "副标题", "正文", "zh.png"),), + assert result.notifications == ( + NotificationItem("notification-1", "once", "zh-Hans", "标题", "副标题", "正文", "zh.png"), ) assert gateway.get_account_ids == ["account-1"] @@ -91,47 +59,48 @@ def test_get_active_localizes_notification_for_account_language() -> None: def test_get_active_falls_back_to_english() -> None: english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png") gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({"en-US": english}),))) - service = NotificationService(accounts=_accounts(_account("fr-FR")), notifications=gateway) + service = NotificationService(notifications=gateway) - result = service.get_active(_context()) + result = service.get_active(_context(), "fr-FR") assert result.notifications[0].lang == "en-US" assert result.notifications[0].title == "Title" -def test_get_active_skips_account_query_when_gateway_says_not_to_show() -> None: - accounts = _accounts(None) - service = NotificationService( - accounts=accounts, - notifications=NotificationGatewayStub(AccountNotificationBatch(False, ())), +def test_get_active_falls_back_to_english_for_unsupported_language() -> None: + unsupported = NotificationContent("xx-YY", "Unknown", "Unknown", "Unknown", "unknown.png") + english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png") + gateway = NotificationGatewayStub( + AccountNotificationBatch(True, (_notification({"xx-YY": unsupported, "en-US": english}),)) ) + service = NotificationService(notifications=gateway) - result = service.get_active(_context()) + result = service.get_active(_context(), "xx-YY") + + assert result.notifications[0].lang == "en-US" + assert result.notifications[0].title == "Title" + + +def test_get_active_returns_empty_when_gateway_says_not_to_show() -> None: + service = NotificationService(notifications=NotificationGatewayStub(AccountNotificationBatch(False, ()))) + + result = service.get_active(_context(), "zh-Hans") assert result == NotificationResult(False, ()) - accounts.get.assert_not_called() def test_get_active_uses_empty_content_when_notification_has_no_translations() -> None: gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),))) - service = NotificationService(accounts=_accounts(_account(None)), notifications=gateway) + service = NotificationService(notifications=gateway) - result = service.get_active(_context()) + result = service.get_active(_context(), "") assert result.notifications == (NotificationItem("notification-1", "once", "en-US", "", "", "", ""),) -def test_get_active_rejects_unknown_admitted_account() -> None: - gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),))) - service = NotificationService(accounts=_accounts(None), notifications=gateway) - - with pytest.raises(RuntimeError, match="unknown account"): - service.get_active(_context()) - - def test_dismiss_delegates_identifiers_to_gateway() -> None: gateway = NotificationGatewayStub(AccountNotificationBatch(False, ())) - service = NotificationService(accounts=_accounts(_account()), notifications=gateway) + service = NotificationService(notifications=gateway) service.dismiss(_context(), "notification-1") diff --git a/packages/contracts/generated/api/console/notification/orpc.gen.ts b/packages/contracts/generated/api/console/notification/orpc.gen.ts index 9e01e1a62e0..7024ed95c31 100644 --- a/packages/contracts/generated/api/console/notification/orpc.gen.ts +++ b/packages/contracts/generated/api/console/notification/orpc.gen.ts @@ -3,6 +3,7 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { + zGetNotificationQuery, zGetNotificationResponse, zPostNotificationDismissBody, zPostNotificationDismissResponse, @@ -28,18 +29,19 @@ export const dismiss = { } /** - * Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal. + * Return the active in-product notification for the current user in the requested language (defaults to English when omitted). Unavailable translations fall back to English, then the first available content. The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal. */ export const get = oc .route({ description: - 'Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal.', + 'Return the active in-product notification for the current user in the requested language (defaults to English when omitted). Unavailable translations fall back to English, then the first available content. The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal.', inputStructure: 'detailed', method: 'GET', operationId: 'getNotification', path: '/notification', tags: ['console'], }) + .input(z.object({ query: zGetNotificationQuery.optional() })) .output(zGetNotificationResponse) export const notification = { diff --git a/packages/contracts/generated/api/console/notification/types.gen.ts b/packages/contracts/generated/api/console/notification/types.gen.ts index 26469735f21..f89148d641a 100644 --- a/packages/contracts/generated/api/console/notification/types.gen.ts +++ b/packages/contracts/generated/api/console/notification/types.gen.ts @@ -30,7 +30,9 @@ export type NotificationItemResponse = { export type GetNotificationData = { body?: never path?: never - query?: never + query?: { + language?: string + } url: '/notification' } diff --git a/packages/contracts/generated/api/console/notification/zod.gen.ts b/packages/contracts/generated/api/console/notification/zod.gen.ts index fb39eada2d2..4fadd36755a 100644 --- a/packages/contracts/generated/api/console/notification/zod.gen.ts +++ b/packages/contracts/generated/api/console/notification/zod.gen.ts @@ -37,6 +37,10 @@ export const zNotificationResponse = z.object({ should_show: z.boolean(), }) +export const zGetNotificationQuery = z.object({ + language: z.string().optional().default('en-US'), +}) + /** * Success — inspect should_show to decide whether to render the modal */ diff --git a/web/app/components/app/in-site-message/__tests__/notification.spec.tsx b/web/app/components/app/in-site-message/__tests__/notification.spec.tsx index 254c40a6e0a..15d750be46a 100644 --- a/web/app/components/app/in-site-message/__tests__/notification.spec.tsx +++ b/web/app/components/app/in-site-message/__tests__/notification.spec.tsx @@ -3,14 +3,19 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import InSiteMessageNotification from '../notification' -const { mockEdition, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({ +const { mockEdition, mockLocale, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({ mockEdition: { value: 'CLOUD' as 'COMMUNITY' | 'ENTERPRISE' | 'CLOUD' | null, }, + mockLocale: { value: 'en-US' }, mockNotification: vi.fn(), mockNotificationDismiss: vi.fn(), })) +vi.mock('@/context/i18n', () => ({ + useLocale: () => mockLocale.value, +})) + vi.mock('@/service/client', () => ({ consoleQuery: { systemFeatures: { @@ -25,9 +30,9 @@ vi.mock('@/service/client', () => ({ }, notification: { get: { - queryOptions: (options?: Record) => ({ - queryKey: ['console', 'notification', 'get'], - queryFn: (...args: unknown[]) => mockNotification(...args), + queryOptions: (options?: { enabled?: boolean; input?: unknown }) => ({ + queryKey: ['console', 'notification', 'get', options?.input], + queryFn: () => mockNotification(options?.input), ...options, }), }, @@ -70,6 +75,7 @@ describe('InSiteMessageNotification', () => { beforeEach(() => { vi.clearAllMocks() mockEdition.value = 'CLOUD' + mockLocale.value = 'en-US' vi.stubGlobal('open', vi.fn()) }) @@ -96,10 +102,28 @@ describe('InSiteMessageNotification', () => { const { container } = render(, { wrapper: Wrapper }) await waitFor(() => { - expect(mockNotification).toHaveBeenCalledTimes(1) + expect(mockNotification).toHaveBeenCalledWith({ query: { language: 'en-US' } }) }) expect(container).toBeEmptyDOMElement() }) + + it('should refetch notification when the interface language changes', async () => { + mockNotification.mockResolvedValue({ notifications: [] }) + const Wrapper = createWrapper() + const { rerender } = render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(mockNotification).toHaveBeenCalledWith({ query: { language: 'en-US' } }) + }) + + mockLocale.value = 'zh-Hans' + rerender() + + await waitFor(() => { + expect(mockNotification).toHaveBeenCalledWith({ query: { language: 'zh-Hans' } }) + }) + expect(mockNotification).toHaveBeenCalledTimes(2) + }) }) // Validate parsed-body behavior and action handling. diff --git a/web/app/components/app/in-site-message/notification.tsx b/web/app/components/app/in-site-message/notification.tsx index 9b9c5f7dfee..cee2b2a9137 100644 --- a/web/app/components/app/in-site-message/notification.tsx +++ b/web/app/components/app/in-site-message/notification.tsx @@ -3,6 +3,7 @@ import type { InSiteMessageActionItem } from './index' import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { useLocale } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { consoleQuery } from '@/service/client' import InSiteMessage from './index' @@ -56,6 +57,7 @@ function parseNotificationBody(body: string): NotificationBodyPayload | null { function InSiteMessageNotification() { const { t } = useTranslation() + const locale = useLocale() const { data: deploymentEdition } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), select: ({ deployment_edition }) => deployment_edition, @@ -67,6 +69,7 @@ function InSiteMessageNotification() { const { data } = useQuery( consoleQuery.notification.get.queryOptions({ + input: { query: { language: locale } }, enabled: isCloudEdition, }), )