Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 10 additions & 16 deletions apps/mobile/__tests__/hooks/use-habit-suggestion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API } from '@orbit/shared/api'
import { profileKeys, subscriptionKeys } from '@orbit/shared/query'
import { habitSetupSuggestionSchema } from '@orbit/shared/types/habit'
import { useHabitSuggestion } from '@/hooks/use-habit-suggestion'

const TestRenderer = require('react-test-renderer')
Expand Down Expand Up @@ -66,7 +67,7 @@ describe('mobile useHabitSuggestion', () => {
mocks.queryClient.invalidateQueries.mockClear()
})

it('mutationFn POSTs to the suggest-setup endpoint and returns the parsed suggestion', async () => {
it('mutationFn POSTs to the suggest-setup endpoint and forwards the response schema for boundary validation', async () => {
await renderHook(() => useHabitSuggestion())
const mutationFn = mocks.captured.mutationArgs?.mutationFn as (
data: { title: string; language?: string },
Expand All @@ -75,10 +76,14 @@ describe('mobile useHabitSuggestion', () => {
mocks.apiClient.mockResolvedValue(validSuggestion)
const result = await mutationFn({ title: 'Run', language: 'en' })

expect(mocks.apiClient).toHaveBeenCalledWith(API.habits.suggestSetup, {
method: 'POST',
body: JSON.stringify({ title: 'Run', language: 'en' }),
})
expect(mocks.apiClient).toHaveBeenCalledWith(
API.habits.suggestSetup,
{
method: 'POST',
body: JSON.stringify({ title: 'Run', language: 'en' }),
},
habitSetupSuggestionSchema,
)
expect(result.emoji).toBe('🏃')
expect(result.frequencyUnit).toBe('Day')
})
Expand All @@ -97,17 +102,6 @@ describe('mobile useHabitSuggestion', () => {
})
})

it('mutationFn rejects when the response fails schema validation', async () => {
await renderHook(() => useHabitSuggestion())
const mutationFn = mocks.captured.mutationArgs?.mutationFn as (
data: { title: string },
) => Promise<unknown>

mocks.apiClient.mockResolvedValue({ emoji: 123 })

await expect(mutationFn({ title: 'Run' })).rejects.toBeTruthy()
})

it('mutationFn propagates an apiClient error (e.g. a pay-gate rejection)', async () => {
await renderHook(() => useHabitSuggestion())
const mutationFn = mocks.captured.mutationArgs?.mutationFn as (
Expand Down
13 changes: 9 additions & 4 deletions apps/mobile/__tests__/hooks/use-report-event.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API } from '@orbit/shared/api'
import { reportEventResponseSchema } from '@orbit/shared/types/gamification'
import { useReportEvent } from '@/hooks/use-gamification'
const TestRenderer = require('react-test-renderer')

Expand Down Expand Up @@ -73,10 +74,14 @@ describe('mobile useReportEvent', () => {
const mutation = await callHook(() => useReportEvent())
await mutation.mutate('card_shared')

expect(mocks.apiClient).toHaveBeenCalledWith(API.gamification.reportEvent, {
method: 'POST',
body: JSON.stringify({ eventKey: 'card_shared' }),
})
expect(mocks.apiClient).toHaveBeenCalledWith(
API.gamification.reportEvent,
{
method: 'POST',
body: JSON.stringify({ eventKey: 'card_shared' }),
},
reportEventResponseSchema,
)
expect(mocks.enqueueCelebration).toHaveBeenCalledWith('achievement', {
achievementId: 'show_off',
xpReward: 75,
Expand Down
46 changes: 46 additions & 0 deletions apps/mobile/__tests__/lib/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'

import { apiClient } from '@/lib/api-client'
import { setPendingIdempotencyKey } from '@/lib/idempotency-key'
Expand Down Expand Up @@ -288,4 +289,49 @@ describe('mobile apiClient', () => {
}),
)
})

it('validates and returns the parsed body when a schema is supplied', async () => {
getTokenMock.mockResolvedValue('token-123')
fetchMock.mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({ id: 'h-1', extra: 'stripped' })),
})

const schema = z.object({ id: z.string() })

await expect(apiClient('/api/habits/h-1', {}, schema)).resolves.toEqual({ id: 'h-1' })
})

it('rejects a malformed body with a typed 502 ApiClientError', async () => {
getTokenMock.mockResolvedValue('token-123')
fetchMock.mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({ id: 123 })),
})

const schema = z.object({ id: z.string() })

await expect(apiClient('/api/habits/h-1', {}, schema)).rejects.toMatchObject({
name: 'ApiClientError',
status: 502,
code: 'INVALID_RESPONSE_SCHEMA',
})
})

it('skips schema validation for empty (204) responses', async () => {
getTokenMock.mockResolvedValue('token-123')
fetchMock.mockResolvedValue({
ok: true,
status: 204,
text: () => Promise.resolve(''),
})

const schema = z.object({ id: z.string() })

await expect(
apiClient('/api/habits/h-1', { method: 'DELETE' }, schema),
).resolves.toBeUndefined()
})
})
13 changes: 9 additions & 4 deletions apps/mobile/hooks/use-gamification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
ReportEventResponse,
StreakInfo,
} from '@orbit/shared/types/gamification'
import { reportEventResponseSchema } from '@orbit/shared/types/gamification'
import {
deriveGamificationProfileState,
detectCrossedStreakMilestones,
Expand Down Expand Up @@ -130,10 +131,14 @@ export function useReportEvent() {

return useMutation({
mutationFn: (eventKey: AchievementEventKey) =>
apiClient<ReportEventResponse>(API.gamification.reportEvent, {
method: 'POST',
body: JSON.stringify({ eventKey }),
}),
apiClient<ReportEventResponse>(
API.gamification.reportEvent,
{
method: 'POST',
body: JSON.stringify({ eventKey }),
},
reportEventResponseSchema,
),
onSuccess: (response) => {
for (const achievement of response.granted) {
enqueueCelebration('achievement', {
Expand Down
10 changes: 6 additions & 4 deletions apps/mobile/hooks/use-habit-suggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ export function useHabitSuggestion() {
const queryClient = useQueryClient()

return useMutation<HabitSetupSuggestion, Error, HabitSetupSuggestionRequest>({
mutationFn: async (data) =>
habitSetupSuggestionSchema.parse(
await apiClient<HabitSetupSuggestion>(API.habits.suggestSetup, {
mutationFn: (data) =>
apiClient<HabitSetupSuggestion>(
API.habits.suggestSetup,
{
method: 'POST',
body: JSON.stringify(data),
}),
},
habitSetupSuggestionSchema,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.status() })
Expand Down
34 changes: 29 additions & 5 deletions apps/mobile/lib/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { getToken, clearAllTokens } from './secure-store'
import { buildClientTimeZoneHeaders, createApiClientError } from '@orbit/shared'
import { ApiClientError, buildClientTimeZoneHeaders, createApiClientError } from '@orbit/shared'
import { API } from '@orbit/shared/api'
import { buildAppVersionHeaders } from './app-version'
import { consumePendingIdempotencyKey } from './idempotency-key'
import type { ZodType } from 'zod'

const API_BASE = process.env.EXPO_PUBLIC_API_BASE ?? 'https://api.useorbit.org'

Expand Down Expand Up @@ -129,9 +130,25 @@ async function handleUpgradeRequired<T>(
)
}

function validateResponseSchema<T>(body: T, schema: ZodType<T> | undefined, path: string): T {
if (!schema) return body

const parsed = schema.safeParse(body)
if (!parsed.success) {
throw new ApiClientError(502, `Unexpected API response shape for ${path}`, {
code: 'INVALID_RESPONSE_SCHEMA',
data: parsed.error.issues,
})
}

return parsed.data
}

async function parseApiResponse<T>(
response: Response,
requestId: string | null,
path: string,
schema?: ZodType<T>,
): Promise<T> {
if (!response.ok) {
const error = attachRequestIdToPayload(
Expand All @@ -152,17 +169,24 @@ async function parseApiResponse<T>(
return undefined as T
}

return JSON.parse(text) as T
return validateResponseSchema(JSON.parse(text) as T, schema, path)
}

async function redirectToLogin(): Promise<void> {
const { router } = await import('expo-router')
router.replace('/login')
}

/**
* Authenticated fetch for the mobile app. Attaches the bearer token, time-zone and app-version
* headers, transparently refreshes/retries on a 401, and surfaces upgrade-required (426) gating.
* When a Zod `schema` is supplied the response body is validated at the trust boundary and a typed
* `ApiClientError` (502, `INVALID_RESPONSE_SCHEMA`) is thrown if it does not match the contract.
*/
export async function apiClient<T = unknown>(
path: string,
options: ApiRequestOptions = {},
schema?: ZodType<T>,
): Promise<T> {
const idempotencyKey = options.idempotencyKey ?? consumePendingIdempotencyKey() ?? undefined
const effectiveOptions: ApiRequestOptions =
Expand All @@ -179,7 +203,7 @@ export async function apiClient<T = unknown>(
if (latestToken && latestToken !== tokenUsed) {
const retryWithLatest = await executeRequest(path, effectiveOptions, latestToken)
if (retryWithLatest.response.status !== 401) {
return parseApiResponse<T>(retryWithLatest.response, retryWithLatest.requestId)
return parseApiResponse<T>(retryWithLatest.response, retryWithLatest.requestId, path, schema)
}
}

Expand All @@ -190,7 +214,7 @@ export async function apiClient<T = unknown>(
if (refreshOutcome.status === 'refreshed') {
const retry = await executeRequest(path, effectiveOptions, refreshOutcome.token)
if (retry.response.status !== 401) {
return parseApiResponse<T>(retry.response, retry.requestId)
return parseApiResponse<T>(retry.response, retry.requestId, path, schema)
}

if (!isAuthTransitionInFlight()) {
Expand All @@ -212,5 +236,5 @@ export async function apiClient<T = unknown>(
throw toUnauthorizedError(requestId)
}

return parseApiResponse<T>(response, requestId)
return parseApiResponse<T>(response, requestId, path, schema)
}
10 changes: 5 additions & 5 deletions apps/web/__tests__/actions/habits-extended.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('habit server actions (extended)', () => {
it('sends POST to /api/habits/bulk/log with items', async () => {
mockApiResponse({
results: [
{ habitId: 'h-1', status: 'Success', logId: 'log-1', error: null },
{ index: 0, habitId: 'h-1', status: 'Success', logId: 'log-1', error: null },
],
})

Expand All @@ -66,8 +66,8 @@ describe('habit server actions (extended)', () => {
it('sends multiple items', async () => {
mockApiResponse({
results: [
{ habitId: 'h-1', status: 'Success', logId: 'log-1', error: null },
{ habitId: 'h-2', status: 'Success', logId: 'log-2', error: null },
{ index: 0, habitId: 'h-1', status: 'Success', logId: 'log-1', error: null },
{ index: 1, habitId: 'h-2', status: 'Success', logId: 'log-2', error: null },
],
})

Expand Down Expand Up @@ -95,7 +95,7 @@ describe('habit server actions (extended)', () => {
describe('bulkSkipHabits', () => {
it('sends POST to /api/habits/bulk/skip with items', async () => {
mockApiResponse({
results: [{ habitId: 'h-1', status: 'Success', error: null }],
results: [{ index: 0, habitId: 'h-1', status: 'Success', error: null }],
})

const result = await bulkSkipHabits([{ habitId: 'h-1' }])
Expand All @@ -109,7 +109,7 @@ describe('habit server actions (extended)', () => {

it('sends date per item when provided', async () => {
mockApiResponse({
results: [{ habitId: 'h-1', status: 'Success', error: null }],
results: [{ index: 0, habitId: 'h-1', status: 'Success', error: null }],
})

await bulkSkipHabits([{ habitId: 'h-1', date: '2025-01-15' }])
Expand Down
Loading
Loading