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
98 changes: 98 additions & 0 deletions apps/mobile/__tests__/lib/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { getTokenMock, clearAllTokensMock, fetchMock } = vi.hoisted(() => ({
getTokenMock: vi.fn(),
clearAllTokensMock: vi.fn(),
fetchMock: vi.fn(),
}))

vi.mock('@/lib/secure-store', () => ({
getToken: getTokenMock,
clearAllTokens: clearAllTokensMock,
}))

vi.stubGlobal('fetch', fetchMock)

import { apiClient } from '@/lib/api-client'

describe('mobile apiClient', () => {
beforeEach(() => {
getTokenMock.mockReset()
clearAllTokensMock.mockReset()
fetchMock.mockReset()
})

it('adds auth and JSON headers for standard requests', async () => {
getTokenMock.mockResolvedValue('token-123')
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ ok: true }),
})

await expect(apiClient('/api/test', { method: 'POST', body: '{"a":1}' })).resolves.toEqual({ ok: true })

expect(fetchMock).toHaveBeenCalledWith(
'https://api.useorbit.org/api/test',
expect.objectContaining({
method: 'POST',
body: '{"a":1}',
headers: {
Authorization: 'Bearer token-123',
'Content-Type': 'application/json',
},
}),
)
})

it('does not force JSON headers for FormData bodies', async () => {
getTokenMock.mockResolvedValue(null)
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ uploaded: true }),
})

const body = new FormData()
body.append('file', 'content')

await apiClient('/upload', { method: 'POST', body })

expect(fetchMock).toHaveBeenCalledWith(
'https://api.useorbit.org/upload',
expect.objectContaining({
headers: {},
}),
)
})

it('clears tokens and throws on unauthorized responses', async () => {
getTokenMock.mockResolvedValue('token-123')
fetchMock.mockResolvedValue({ ok: false, status: 401 })

await expect(apiClient('/secure')).rejects.toThrow('Unauthorized')
expect(clearAllTokensMock).toHaveBeenCalledTimes(1)
})

it('prefers backend error payload text when available', async () => {
getTokenMock.mockResolvedValue(null)
fetchMock.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: 'Validation failed' }),
})

await expect(apiClient('/broken')).rejects.toThrow('Validation failed')
})

it('returns undefined for 204 responses', async () => {
getTokenMock.mockResolvedValue(null)
fetchMock.mockResolvedValue({
ok: true,
status: 204,
json: () => Promise.resolve({}),
})

await expect(apiClient('/empty')).resolves.toBeUndefined()
})
})
88 changes: 88 additions & 0 deletions apps/mobile/__tests__/lib/auth-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { getItemMock, setItemMock, removeItemMock } = vi.hoisted(() => ({
getItemMock: vi.fn(),
setItemMock: vi.fn(),
removeItemMock: vi.fn(),
}))

vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: getItemMock,
setItem: setItemMock,
removeItem: removeItemMock,
},
}))

import {
clearStoredAuthReturnUrl,
clearStoredReferralCode,
consumeStoredAuthReturnUrl,
getSafeReturnUrl,
getStoredAuthReturnUrl,
getStoredReferralCode,
isSafeReturnUrl,
isValidReferralCode,
isValidVerificationCode,
markReferralApplied,
storeAuthReturnUrl,
storeReferralCode,
} from '@/lib/auth-flow'

describe('mobile auth flow helpers', () => {
beforeEach(() => {
getItemMock.mockReset()
setItemMock.mockReset()
removeItemMock.mockReset()
})

it('validates referral codes and verification codes', () => {
expect(isValidReferralCode('orbit_123')).toBe(true)
expect(isValidReferralCode('../bad')).toBe(false)
expect(isValidVerificationCode('123456')).toBe(true)
expect(isValidVerificationCode('12345')).toBe(false)
})

it('accepts only safe return URLs', () => {
expect(isSafeReturnUrl('/profile')).toBe(true)
expect(isSafeReturnUrl('//evil.com')).toBe(false)
expect(getSafeReturnUrl('//evil.com', '/login')).toBe('/login')
})

it('stores and retrieves valid referral codes', async () => {
getItemMock.mockResolvedValue('invite_abc')

await storeReferralCode('invite_abc')
await expect(getStoredReferralCode()).resolves.toBe('invite_abc')
expect(setItemMock).toHaveBeenCalledWith('referral_code', 'invite_abc')
})

it('ignores invalid referral codes', async () => {
getItemMock.mockResolvedValue('bad value')

await storeReferralCode('bad value')
await expect(getStoredReferralCode()).resolves.toBeNull()
expect(setItemMock).not.toHaveBeenCalled()
})

it('stores, consumes, and clears safe return URLs', async () => {
getItemMock.mockResolvedValueOnce('/dashboard')
getItemMock.mockResolvedValueOnce('/dashboard')

await storeAuthReturnUrl('/dashboard')
await expect(getStoredAuthReturnUrl()).resolves.toBe('/dashboard')
await expect(consumeStoredAuthReturnUrl()).resolves.toBe('/dashboard')
await clearStoredAuthReturnUrl()

expect(setItemMock).toHaveBeenCalledWith('auth_return_url', '/dashboard')
expect(removeItemMock).toHaveBeenCalledWith('auth_return_url')
})

it('clears the stored referral code and marks referral applied', async () => {
await clearStoredReferralCode()
await markReferralApplied()

expect(removeItemMock).toHaveBeenCalledWith('referral_code')
expect(setItemMock).toHaveBeenCalledWith('orbit_referral_applied', '1')
})
})
92 changes: 92 additions & 0 deletions apps/mobile/__tests__/lib/habit-optimistic-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import {
optimisticToggleCompletion,
optimisticUpdateChecklist,
} from '@/lib/habit-optimistic-helpers'
import type { ChecklistItem, HabitScheduleChild, HabitScheduleItem } from '@orbit/shared/types/habit'

function makeHabit(overrides: Partial<HabitScheduleItem> = {}): HabitScheduleItem {
return {
id: 'habit-1',
title: 'Test Habit',
description: '',
frequencyUnit: null,
frequencyQuantity: null,
isBadHabit: false,
isCompleted: false,
isGeneral: false,
isFlexible: false,
days: [],
dueDate: '2025-01-15',
dueTime: '',
dueEndTime: '',
endDate: '',
position: 0,
checklistItems: [],
createdAtUtc: '2025-01-01T00:00:00Z',
parentId: null,
scheduledDates: [],
isOverdue: false,
reminderEnabled: false,
reminderTimes: [],
scheduledReminders: [],
slipAlertEnabled: false,
tags: [],
hasSubHabits: false,
flexibleTarget: null,
flexibleCompleted: 0,
isLoggedInRange: false,
linkedGoals: [],
instances: [],
children: [],
searchMatches: null,
...overrides,
} as unknown as HabitScheduleItem
}

describe('mobile optimistic habit helpers', () => {
it('toggles top-level completion and resets recurring checklist items', () => {
const result = optimisticToggleCompletion(
[
makeHabit({
frequencyUnit: 'Day',
checklistItems: [
{ text: 'A', isChecked: true },
{ text: 'B', isChecked: true },
],
}),
],
'habit-1',
)

expect(result[0]?.isCompleted).toBe(true)
expect(result[0]?.checklistItems.map((item) => item.isChecked)).toEqual([false, false])
})

it('toggles child completion', () => {
const child = makeHabit({ id: 'child-1' })
const parent = makeHabit({
id: 'parent-1',
children: [child] as unknown as HabitScheduleChild[],
})

const result = optimisticToggleCompletion([parent], 'child-1')

expect(result[0]?.children[0]?.isCompleted).toBe(true)
})

it('updates checklist items for parent and child habits', () => {
const newItems: ChecklistItem[] = [{ text: 'Done', isChecked: true }]
const child = makeHabit({ id: 'child-1' })
const parent = makeHabit({
id: 'parent-1',
children: [child] as unknown as HabitScheduleChild[],
})

const topLevelResult = optimisticUpdateChecklist([makeHabit()], 'habit-1', newItems)
const childResult = optimisticUpdateChecklist([parent], 'child-1', newItems)

expect(topLevelResult[0]?.checklistItems).toEqual(newItems)
expect(childResult[0]?.children[0]?.checklistItems).toEqual(newItems)
})
})
107 changes: 107 additions & 0 deletions apps/mobile/__tests__/lib/habit-request-builders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import {
buildCreateHabitRequest,
buildSubHabitRequest,
buildUpdateHabitRequest,
type HabitFormData,
} from '@/lib/habit-request-builders'

function makeFormData(overrides: Partial<HabitFormData> = {}): HabitFormData {
return {
title: 'Test Habit',
description: '',
isGeneral: false,
isFlexible: false,
frequencyUnit: null,
frequencyQuantity: null,
days: [],
dueDate: '2025-06-15',
dueTime: '',
dueEndTime: '',
endDate: '',
isBadHabit: false,
slipAlertEnabled: false,
reminderEnabled: false,
scheduledReminders: [],
checklistItems: [],
...overrides,
}
}

describe('mobile habit request builders', () => {
it('builds create payloads with recurring fields, reminders, tags, goals, and sub-habits', () => {
const data = makeFormData({
description: 'Description',
frequencyUnit: 'Week',
frequencyQuantity: 1,
days: ['Monday', 'Wednesday'],
dueTime: '09:00',
dueEndTime: '10:00',
reminderEnabled: true,
checklistItems: [{ text: 'Step 1', isChecked: false }],
})

const result = buildCreateHabitRequest(data, [15, 30], ['tag-1'], ['goal-1'], ['Sub 1', '', 'Sub 2'])

expect(result).toMatchObject({
title: 'Test Habit',
description: 'Description',
frequencyUnit: 'Week',
frequencyQuantity: 1,
days: ['Monday', 'Wednesday'],
dueTime: '09:00',
dueEndTime: '10:00',
reminderEnabled: true,
reminderTimes: [15, 30],
tagIds: ['tag-1'],
goalIds: ['goal-1'],
subHabits: ['Sub 1', 'Sub 2'],
checklistItems: [{ text: 'Step 1', isChecked: false }],
})
})

it('builds general create payloads without schedule fields', () => {
const result = buildCreateHabitRequest(makeFormData({ isGeneral: true }), [], [], [], [])
expect(result.isGeneral).toBe(true)
expect(result.frequencyUnit).toBeUndefined()
})

it('builds sub-habit payloads with schedule and tags', () => {
const result = buildSubHabitRequest(
makeFormData({ frequencyUnit: 'Day', frequencyQuantity: 2 }),
[10],
['tag-1'],
)

expect(result).toMatchObject({
title: 'Test Habit',
frequencyUnit: 'Day',
frequencyQuantity: 2,
tagIds: ['tag-1'],
})
})

it('builds update payloads including clearEndDate and scheduled reminders', () => {
const result = buildUpdateHabitRequest(
makeFormData({
frequencyUnit: 'Day',
frequencyQuantity: 1,
reminderEnabled: true,
scheduledReminders: [{ when: 'same_day', time: '08:00' }],
}),
false,
'2025-06-01',
[],
['goal-1', 'goal-2'],
)

expect(result).toMatchObject({
frequencyUnit: 'Day',
frequencyQuantity: 1,
clearEndDate: true,
reminderEnabled: true,
scheduledReminders: [{ when: 'same_day', time: '08:00' }],
goalIds: ['goal-1', 'goal-2'],
})
})
})
Loading
Loading