From fd39bd9748bc36659e1a55786f4693603c375e16 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Thu, 17 Sep 2026 19:24:26 +0200 Subject: [PATCH] Map the tips payload onto the shape the client declares Tips rendered as blank cards with unresolved placeholder text in their accessibility labels. getTips was the one entity fetch in the client with no translation from the wire's snake_case, so every tip arrived without the key that selects its title and body, and the lookup silently missed. The mapper now matches its siblings in that file. A test drives a card through all eleven message keys the tips engine emits, so a twelfth added later fails loudly instead of rendering an empty card, and two older tests that stubbed the tips response as an empty object rather than an empty list have been corrected. --- .../src/components/inputs/TipCard.test.tsx | 39 +++++++++++++++++++ frontend/src/services/api.test.ts | 20 +++++++--- frontend/src/services/api.ts | 19 ++++++++- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/inputs/TipCard.test.tsx b/frontend/src/components/inputs/TipCard.test.tsx index 3b22c17..4b6007d 100644 --- a/frontend/src/components/inputs/TipCard.test.tsx +++ b/frontend/src/components/inputs/TipCard.test.tsx @@ -74,4 +74,43 @@ describe('TipCard', () => { const { getByTestId } = render() expect(getByTestId('tip-card').textContent).toContain('unknown_key') }) + + // Mirrors every `message_key="..."` literal emitted by + // backend/app/services/tips_service.py — verified with: + // git grep -oh 'message_key="[^"]*"' backend/app/services/tips_service.py | sort -u + // This is the test that would have caught the original bug: two of these + // eleven keys had no entry in TIP_TRANSLATION_KEYS, so their cards rendered + // with an empty title and body. If a twelfth backend rule is added and this + // list isn't updated to match, that new key isn't exercised here — but any + // key in this list that TipCard doesn't recognize now fails loudly. + const ALL_BACKEND_MESSAGE_KEYS = [ + 'tips.use_vacation_before_expiry', + 'tips.balance_between_parents', + 'tips.preserve_for_age_4', + 'tips.use_dubbeldagar_strategically', + 'tips.lagsta_niva_coverage', + 'tips.stretch_time_at_home', + 'tips.sgi_minimum_after_age_1', + 'tips.wrap_weekend_with_vacation', + 'tips.wrap_red_days_with_vacation', + 'tips.long_break_parent_swap', + 'tips.reserve_for_preschool_planning_days', + ] + + it.each(ALL_BACKEND_MESSAGE_KEYS)( + 'renders a non-empty, translated title and body for %s', + (messageKey) => { + const tip = { id: 'tip-x', severity: 'info' as const, messageKey, data: {} } + const { getByTestId } = render() + const title = getByTestId('tip-card').querySelector('.tip-card__title') + const body = getByTestId('tip-card').querySelector('.tip-card__body') + + expect(title?.textContent).toBeTruthy() + expect(body?.textContent).toBeTruthy() + // A falsy translation lookup falls back to rendering the raw key — + // that's the "blank card" bug in disguise, so guard against it too. + expect(title?.textContent).not.toBe(messageKey) + expect(body?.textContent).not.toBe(messageKey) + }, + ) }) diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index d865666..06721bb 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -392,7 +392,7 @@ describe('apiFetch (shared request/response handling)', () => { }) it('omits the Authorization header when no access token is set', async () => { - fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }) + fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => [] }) await api.getTips('ws1') @@ -403,7 +403,7 @@ describe('apiFetch (shared request/response handling)', () => { it('attaches the Authorization header when an access token is set', async () => { api.setAccessToken('my-token') - fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }) + fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => [] }) await api.getTips('ws1') @@ -1322,14 +1322,22 @@ describe('endpoint functions (representative coverage)', () => { expect(result[0]!.createdAt).toBe('') }) - it('getTips fetches tips for a workspace', async () => { - const tips = [{ id: 't1', severity: 'info', messageKey: 'k', data: {} }] - fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tips }) + it('getTips fetches tips for a workspace and maps snake_case to camelCase', async () => { + // Regression test: the backend's real wire shape is snake_case + // (`message_key`, not `messageKey`) — see tips_service.py's `Tip` + // dataclass. A bare pass-through here left `tip.messageKey` undefined + // at runtime, which rendered every tip card blank. + const rawTips = [ + { id: 't1', severity: 'info', message_key: 'tips.use_vacation_before_expiry', data: { foo: 'bar' } }, + ] + fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => rawTips }) const result = await api.getTips('ws1') expect(lastRequest()).toEqual({ url: '/api/tips/ws1', method: undefined }) - expect(result).toEqual(tips) + expect(result).toEqual([ + { id: 't1', severity: 'info', messageKey: 'tips.use_vacation_before_expiry', data: { foo: 'bar' } }, + ]) }) it('getPlannedDays omits the query string when no childId is given', async () => { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e6dbd54..79646c8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -205,6 +205,22 @@ function toPlannedLeaveDay(raw: RawPlannedLeaveDay): PlannedLeaveDay { } } +export interface RawTip { + id: string + severity: 'info' | 'warning' | 'action' + message_key: string + data: Record +} + +function toTip(raw: RawTip): Tip { + return { + id: raw.id, + severity: raw.severity, + messageKey: raw.message_key, + data: raw.data, + } +} + interface RawLeaveWish { id: string workspace_id: string @@ -926,7 +942,8 @@ export async function removeWorkspaceParent( // --------------------------------------------------------------------------- export async function getTips(workspaceId: string): Promise { - return apiFetch(`/tips/${workspaceId}`) + const raw = (await apiFetch(`/tips/${workspaceId}`)) as RawTip[] + return raw.map(toTip) } // ---------------------------------------------------------------------------