refactor: pass notification language explicitly (#41770)

This commit is contained in:
非法操作 2026-09-04 01:52:30 +00:00 committed by GitHub
parent df0460f876
commit 8e2058a962
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 116 additions and 86 deletions

View File

@ -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

View File

@ -582,7 +582,6 @@ def build_application_services(
expected_password=initialization_password,
),
notifications=NotificationService(
accounts=accounts,
notifications=BillingNotificationGateway(),
),
step_by_step_tour=StepByStepTourService(

View File

@ -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, <br>**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, <br>**Default:** en-US | Notification language | No |
#### NotificationResponse
| Name | Type | Description | Required |

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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")

View File

@ -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 = {

View File

@ -30,7 +30,9 @@ export type NotificationItemResponse = {
export type GetNotificationData = {
body?: never
path?: never
query?: never
query?: {
language?: string
}
url: '/notification'
}

View File

@ -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
*/

View File

@ -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<string, unknown>) => ({
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(<InSiteMessageNotification />, { 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(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(mockNotification).toHaveBeenCalledWith({ query: { language: 'en-US' } })
})
mockLocale.value = 'zh-Hans'
rerender(<InSiteMessageNotification />)
await waitFor(() => {
expect(mockNotification).toHaveBeenCalledWith({ query: { language: 'zh-Hans' } })
})
expect(mockNotification).toHaveBeenCalledTimes(2)
})
})
// Validate parsed-body behavior and action handling.

View File

@ -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,
}),
)