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