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) } // ---------------------------------------------------------------------------