From 0d3eabeaeec762b601f9eb1ee1cc11321b44bef0 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Tue, 4 Aug 2026 18:34:06 +0800 Subject: [PATCH] fix(hub,web): harden notification preference updates --- .../routes/notificationPreferences.test.ts | 10 +++++ hub/src/web/routes/notificationPreferences.ts | 8 ++-- .../routes/settings/notifications.test.tsx | 37 ++++++++++++++++++- web/src/routes/settings/notifications.tsx | 3 +- 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/hub/src/web/routes/notificationPreferences.test.ts b/hub/src/web/routes/notificationPreferences.test.ts index b9583db9..c7801726 100644 --- a/hub/src/web/routes/notificationPreferences.test.ts +++ b/hub/src/web/routes/notificationPreferences.test.ts @@ -63,6 +63,16 @@ describe('PUT /api/notification-preferences', () => { expect(res.status).toBe(400) }) + it('rejects fractional preference values', async () => { + const app = createApp() + const res = await app.request('/api/notification-preferences', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ permissionRequests: 0.5 }) + }) + expect(res.status).toBe(400) + }) + it('rejects invalid bodies', async () => { const app = createApp() const res = await app.request('/api/notification-preferences', { diff --git a/hub/src/web/routes/notificationPreferences.ts b/hub/src/web/routes/notificationPreferences.ts index 658159d8..aa79ef01 100644 --- a/hub/src/web/routes/notificationPreferences.ts +++ b/hub/src/web/routes/notificationPreferences.ts @@ -4,10 +4,10 @@ import type { Store } from '../../store' import type { WebAppEnv } from '../middleware/auth' const updateSchema = z.object({ - permissionRequests: z.number().min(0).max(1).optional(), - sessionReady: z.number().min(0).max(1).optional(), - taskNotifications: z.number().min(0).max(1).optional(), - sessionCompletion: z.number().min(0).max(1).optional() + permissionRequests: z.number().int().min(0).max(1).optional(), + sessionReady: z.number().int().min(0).max(1).optional(), + taskNotifications: z.number().int().min(0).max(1).optional(), + sessionCompletion: z.number().int().min(0).max(1).optional() }) export function createNotificationPreferencesRoutes(store: Store): Hono { diff --git a/web/src/routes/settings/notifications.test.tsx b/web/src/routes/settings/notifications.test.tsx index 3cf7ee3c..c3555a22 100644 --- a/web/src/routes/settings/notifications.test.tsx +++ b/web/src/routes/settings/notifications.test.tsx @@ -1,7 +1,8 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' +import { queryKeys } from '@/lib/query-keys' import SettingsNotificationsPage from './notifications' const defaultPrefs = { @@ -66,6 +67,7 @@ describe('SettingsNotificationsPage', () => { , ) + return queryClient } it('renders all four toggles from server preferences', async () => { @@ -105,6 +107,16 @@ describe('SettingsNotificationsPage', () => { }) }) + it('keeps the confirmation open when disabling permission notifications fails', async () => { + updateNotificationPreferences.mockRejectedValueOnce(new Error('save failed')) + renderPage() + fireEvent.click(await screen.findByLabelText('Permission requests')) + fireEvent.click(screen.getByText('Turn off anyway')) + + expect(await screen.findByText('save failed')).toBeTruthy() + expect(screen.getByText('Turn off permission request notifications?')).toBeTruthy() + }) + it('sends a test push and reports success', async () => { renderPage() const button = await screen.findByRole('button', { name: 'Send test push' }) @@ -165,6 +177,29 @@ describe('SettingsNotificationsPage', () => { expect(await screen.findByText('Failed to save notification copy')).toBeTruthy() }) + it('accepts refreshed copy after a successful save', async () => { + updateNotificationCopy.mockResolvedValueOnce({ + ...defaultCopyResponse, + copy: { ready: { title: 'Saved title', body: 'Saved body' } } + }) + const queryClient = renderPage() + const readyRow = await screen.findByRole('button', { name: /Session ready.*Ready for input/ }) + fireEvent.click(readyRow) + fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Saved title' } }) + fireEvent.change(screen.getByLabelText('Body'), { target: { value: 'Saved body' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save copy' })) + expect(await screen.findByText('Copy saved')).toBeTruthy() + + act(() => { + queryClient.setQueryData(queryKeys.notificationCopy, { + ...defaultCopyResponse, + copy: { ready: { title: 'Remote title', body: 'Remote body' } } + }) + }) + + expect(await screen.findByRole('button', { name: /Session ready.*Remote title.*Remote body/ })).toBeTruthy() + }) + it('keeps copy editors collapsed and prefills defaults when opened', async () => { renderPage() const readyRow = await screen.findByRole('button', { name: /Session ready.*Ready for input/ }) diff --git a/web/src/routes/settings/notifications.tsx b/web/src/routes/settings/notifications.tsx index 19f11ea6..bf10d220 100644 --- a/web/src/routes/settings/notifications.tsx +++ b/web/src/routes/settings/notifications.tsx @@ -147,6 +147,7 @@ export default function SettingsNotificationsPage() { setCopySaveError(null) }, onSuccess: (data) => { + userEditedRef.current = false queryClient.setQueryData(queryKeys.notificationCopy, data) setDraft(resolveEffectiveCopy(data.copy, data.defaults)) setCopySaveError(null) @@ -336,7 +337,7 @@ export default function SettingsNotificationsPage() { confirmLabel={t('settings.notifications.disablePermissionConfirm')} confirmingLabel={t('settings.notifications.disablePermissionConfirm')} onConfirm={async () => { - mutation.mutate({ permissionRequests: 0 }) + await mutation.mutateAsync({ permissionRequests: 0 }) }} isPending={mutation.isPending} destructive