Skip to content

Commit 0cbff0e

Browse files
authored
fix(settings): report a failed settings write instead of reporting success (#7023)
The PATCH catch answered `{ success: true }` with 200, so a failed upsert was indistinguishable from a saved one. `useUpdateGeneralSetting` is optimistic: `onMutate` writes the new value into the cache and calls `syncThemeToNextThemes`, and `onError` restores the previous settings. `requestJson` only throws on a non-2xx, so `onError` could never run — the rollback and its theme re-sync were unreachable code. A user toggling a consent-shaped setting (telemetry, email opt-out) saw it applied and it was not saved, until a later refetch quietly reverted it. The catch now returns 500, which is what the mutation was already written to handle. Left alone deliberately: GET still falls back to `defaultUserSettings` on error. Failing it would take the settings page down on a transient read, and the value of changing it is a separate judgement from this one. Covered by a route test that drives the failure through the real handler. Verified it fails when the 200 is put back.
1 parent 297e970 commit 0cbff0e

2 files changed

Lines changed: 53 additions & 1 deletion

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, dbChainMockFns, resetDbChainMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetSession } = vi.hoisted(() => ({
8+
mockGetSession: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/auth', () => ({
12+
auth: { api: { getSession: vi.fn() } },
13+
getSession: mockGetSession,
14+
}))
15+
16+
import { PATCH } from '@/app/api/users/me/settings/route'
17+
18+
describe('PATCH /api/users/me/settings', () => {
19+
beforeEach(() => {
20+
vi.clearAllMocks()
21+
resetDbChainMock()
22+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
23+
})
24+
25+
it('reports success when the write lands', async () => {
26+
const response = await PATCH(createMockRequest('PATCH', { theme: 'dark' }))
27+
28+
expect(response.status).toBe(200)
29+
expect(await response.json()).toEqual({ success: true })
30+
})
31+
32+
/**
33+
* The regression this guards: the catch answered `{ success: true }` with 200, so
34+
* `useUpdateGeneralSetting`'s optimistic rollback in `onError` could never run —
35+
* a failed write showed as applied until the next refetch, including for
36+
* consent-shaped settings the user believes they changed.
37+
*/
38+
it('reports failure when the write throws', async () => {
39+
dbChainMockFns.insert.mockImplementationOnce(() => {
40+
throw new Error('connection terminated unexpectedly')
41+
})
42+
43+
const response = await PATCH(createMockRequest('PATCH', { theme: 'dark' }))
44+
45+
expect(response.status).toBe(500)
46+
expect(await response.json()).not.toMatchObject({ success: true })
47+
})
48+
})

apps/sim/app/api/users/me/settings/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => {
7474
return NextResponse.json({ success: true }, { status: 200 })
7575
} catch (error: any) {
7676
logger.error(`[${requestId}] Settings update error`, error)
77-
return NextResponse.json({ success: true }, { status: 200 })
77+
/* The client mutation is optimistic: it writes the new value into the cache in
78+
`onMutate` and restores it in `onError`. Answering 200 here left that rollback
79+
unreachable, so a failed write showed as applied until the next refetch —
80+
including for consent-shaped settings the user believes they changed. */
81+
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 })
7882
}
7983
})

0 commit comments

Comments
 (0)