Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
124 changes: 124 additions & 0 deletions apps/web/__tests__/lib/server-fetch.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'

const resolveServerSessionMock = vi.fn()

Expand All @@ -13,6 +14,18 @@ vi.mock('@orbit/shared', () => ({
;(err as Error & { name: string }).name = 'ApiClientError'
return err
}),
ApiClientError: class ApiClientError extends Error {
status: number
code?: string
data?: unknown
constructor(status: number, message: string, options?: { code?: string; data?: unknown }) {
super(message)
this.name = 'ApiClientError'
this.status = status
this.code = options?.code
this.data = options?.data
}
},
}))

const mockFetch = vi.fn()
Expand Down Expand Up @@ -172,4 +185,115 @@ describe('serverAuthFetch', () => {
expect((options.headers as Record<string, string>)['X-App-Version']).toBeUndefined()
process.env.APP_VERSION = previous
})

it('validates and returns the parsed body when a schema is supplied', async () => {
resolveServerSessionMock.mockResolvedValue({
token: 'test-token',
expiresAt: Date.now() + 3600000,
refreshed: false,
})
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({ id: 'h-1', extra: 'stripped' })),
})

const { serverAuthFetch } = await import('@/lib/server-fetch')
const schema = z.object({ id: z.string() })
const result = await serverAuthFetch('/api/habits/h-1', {}, schema)

expect(result).toEqual({ id: 'h-1' })
})

it('rejects a malformed body with a typed 502 ApiClientError', async () => {
resolveServerSessionMock.mockResolvedValue({
token: 'test-token',
expiresAt: Date.now() + 3600000,
refreshed: false,
})
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({ id: 123 })),
})

const { serverAuthFetch } = await import('@/lib/server-fetch')
const schema = z.object({ id: z.string() })

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

it('skips schema validation for empty (204) responses', async () => {
resolveServerSessionMock.mockResolvedValue({
token: 'test-token',
expiresAt: Date.now() + 3600000,
refreshed: false,
})
mockFetch.mockResolvedValue({
ok: true,
status: 204,
text: () => Promise.resolve(''),
})

const { serverAuthFetch } = await import('@/lib/server-fetch')
const schema = z.object({ id: z.string() })
const result = await serverAuthFetch('/api/habits/h-1', { method: 'DELETE' }, schema)

expect(result).toBeNull()
})
})

describe('serverPublicFetch', () => {
beforeEach(() => {
mockFetch.mockReset()
})

it('validates and returns the parsed body when a schema is supplied', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({ slug: 'ada', extra: 'stripped' })),
})

const { serverPublicFetch } = await import('@/lib/server-fetch')
const schema = z.object({ slug: z.string() })
const result = await serverPublicFetch('/api/u/ada', {}, schema)

expect(result).toEqual({ slug: 'ada' })
})

it('rejects a malformed body with a typed 502 ApiClientError', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({ slug: null })),
})

const { serverPublicFetch } = await import('@/lib/server-fetch')
const schema = z.object({ slug: z.string() })

await expect(serverPublicFetch('/api/u/ada', {}, schema)).rejects.toMatchObject({
name: 'ApiClientError',
status: 502,
code: 'INVALID_RESPONSE_SCHEMA',
})
})

it('returns null for a 404 without invoking the schema', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve(null),
})

const { serverPublicFetch } = await import('@/lib/server-fetch')
const schema = z.object({ slug: z.string() })
const result = await serverPublicFetch('/api/u/missing', {}, schema)

expect(result).toBeNull()
})
})
13 changes: 9 additions & 4 deletions apps/web/app/actions/gamification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import type {
AchievementEventKey,
ReportEventResponse,
} from '@orbit/shared/types/gamification'
import { reportEventResponseSchema } from '@orbit/shared/types/gamification'
import { serverAuthFetch } from '@/lib/server-fetch'

export async function reportAchievementEvent(
eventKey: AchievementEventKey,
): Promise<ReportEventResponse> {
return serverAuthFetch<ReportEventResponse>(API.gamification.reportEvent, {
method: 'POST',
body: JSON.stringify({ eventKey }),
})
return serverAuthFetch(
API.gamification.reportEvent,
{
method: 'POST',
body: JSON.stringify({ eventKey }),
},
reportEventResponseSchema,
)
}
67 changes: 47 additions & 20 deletions apps/web/app/actions/habits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ import type {
MoveHabitParentRequest,
ChecklistItem,
} from '@orbit/shared'
import {
habitSetupSuggestionSchema,
bulkCreateResponseSchema,
bulkDeleteResponseSchema,
bulkLogResultSchema,
bulkSkipResultSchema,
} from '@orbit/shared'
import { API } from '@orbit/shared/api'
import { serverAuthFetch } from '@/lib/server-fetch'

Expand All @@ -32,10 +39,14 @@ export async function createHabit(data: CreateHabitRequest): Promise<{ id: strin
export async function suggestHabitSetup(
data: HabitSetupSuggestionRequest,
): Promise<HabitSetupSuggestion> {
return serverAuthFetch(API.habits.suggestSetup, {
method: 'POST',
body: JSON.stringify(data),
})
return serverAuthFetch(
API.habits.suggestSetup,
{
method: 'POST',
body: JSON.stringify(data),
},
habitSetupSuggestionSchema,
)
}

export async function updateHabit(habitId: string, data: UpdateHabitRequest): Promise<void> {
Expand Down Expand Up @@ -78,31 +89,47 @@ export async function skipHabit(
}

export async function bulkCreateHabits(data: BulkCreateRequest): Promise<BulkCreateResponse> {
return serverAuthFetch(API.habits.bulk, {
method: 'POST',
body: JSON.stringify(data),
})
return serverAuthFetch(
API.habits.bulk,
{
method: 'POST',
body: JSON.stringify(data),
},
bulkCreateResponseSchema,
)
}

export async function bulkDeleteHabits(habitIds: string[]): Promise<BulkDeleteResponse> {
return serverAuthFetch(API.habits.bulk, {
method: 'DELETE',
body: JSON.stringify({ habitIds }),
})
return serverAuthFetch(
API.habits.bulk,
{
method: 'DELETE',
body: JSON.stringify({ habitIds }),
},
bulkDeleteResponseSchema,
)
}

export async function bulkLogHabits(items: BulkLogItemRequest[]): Promise<BulkLogResult> {
return serverAuthFetch(API.habits.bulkLog, {
method: 'POST',
body: JSON.stringify({ items }),
})
return serverAuthFetch(
API.habits.bulkLog,
{
method: 'POST',
body: JSON.stringify({ items }),
},
bulkLogResultSchema,
)
}

export async function bulkSkipHabits(items: BulkSkipItemRequest[]): Promise<BulkSkipResult> {
return serverAuthFetch(API.habits.bulkSkip, {
method: 'POST',
body: JSON.stringify({ items }),
})
return serverAuthFetch(
API.habits.bulkSkip,
{
method: 'POST',
body: JSON.stringify({ items }),
},
bulkSkipResultSchema,
)
}

export async function reorderHabits(data: ReorderHabitsRequest): Promise<void> {
Expand Down
41 changes: 34 additions & 7 deletions apps/web/lib/server-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import { resolveServerSession } from '@/lib/auth-api'
import { createApiClientError } from '@orbit/shared'
import { ApiClientError, createApiClientError } from '@orbit/shared'
import { APP_VERSION_HEADER } from '@orbit/shared/utils'
import type { ZodType } from 'zod'

const API_BASE = process.env.API_BASE ?? 'http://localhost:5000'

function parseResponseBody<T>(text: string, schema: ZodType<T> | undefined, path: string): T {
const body: unknown = JSON.parse(text)
if (!schema) return body as T

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
}

/**
* Shared authenticated fetch for Server Actions.
* Resolves the current session, forwards it as Bearer to the .NET API,
* and throws a structured ApiClientError on failure.
* and throws a structured ApiClientError on failure. When a Zod `schema`
* is supplied, the response body is validated at the trust boundary and a
* typed ApiClientError (502) is thrown if it does not match the contract.
*/
export async function serverAuthFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
export async function serverAuthFetch<T = unknown>(
path: string,
init: RequestInit = {},
schema?: ZodType<T>,
): Promise<T> {
const appVersion = process.env.APP_VERSION
const buildHeaders = (token: string): Record<string, string> => ({
Authorization: `Bearer ${token}`,
Expand Down Expand Up @@ -45,15 +66,21 @@ export async function serverAuthFetch<T = unknown>(path: string, init: RequestIn
if (res.status === 204) return null as T
const text = await res.text()
if (!text) return null as T
return JSON.parse(text) as T
return parseResponseBody(text, schema, path)
}

/**
* Unauthenticated server fetch for public, no-auth API routes (e.g. public profiles).
* Sends no Bearer token, forwards the app version, and returns null on 404 so callers
* can render a not-found page. Throws an ApiClientError on other non-OK statuses.
* can render a not-found page. Throws an ApiClientError on other non-OK statuses. When a
* Zod `schema` is supplied, the response body is validated at the trust boundary and a
* typed ApiClientError (502) is thrown if it does not match the contract.
*/
export async function serverPublicFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T | null> {
export async function serverPublicFetch<T = unknown>(
path: string,
init: RequestInit = {},
schema?: ZodType<T>,
): Promise<T | null> {
const appVersion = process.env.APP_VERSION
const res = await fetch(`${API_BASE}${path}`, {
...init,
Expand All @@ -71,5 +98,5 @@ export async function serverPublicFetch<T = unknown>(path: string, init: Request
}
const text = await res.text()
if (!text) return null
return JSON.parse(text) as T
return parseResponseBody(text, schema, path)
}
Loading