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
2 changes: 2 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ XP/gamification is **Free**, enabled by a feature flag (migration `EnableGamific

| Feature | Description | Gating | Platform | Locale notes |
|---|---|---|---|---|
| Onboarding before auth | Pre-auth setup (habits, first log, optional goal, week-start/color prefs) buffered locally; signup is the final "save your plan" step, applied server-side exactly once | Free | Both | en + pt-BR |
| Import from another app | One-time post-login prompt to hand an existing routine to Astra | Free | Both | en + pt-BR |
| Email code login | Passwordless email verification code (max 3 attempts/15 min) | Free | Both | — |
| Google sign-in | OAuth via Google | Free | Both | — |
| Session — web | httpOnly, sameSite-strict, secure cookie via BFF | — | Web | — |
Expand Down
147 changes: 147 additions & 0 deletions apps/mobile/__tests__/components/onboarding-actions-context.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OnboardingActions } from '@/components/onboarding/onboarding-actions-context'
import {
useBufferOnboardingActions,
useLiveOnboardingActions,
} from '@/components/onboarding/onboarding-actions-context'

const TestRenderer = require('react-test-renderer')

const mocks = vi.hoisted(() => ({
replace: vi.fn(),
draftState: {
bufferHabit: vi.fn(() => 2),
bufferFirstLog: vi.fn(),
bufferGoal: vi.fn(),
bufferWeekStartDay: vi.fn(),
bufferColorScheme: vi.fn(),
markOnboardingLocallyDone: vi.fn(),
},
createHabitMutateAsync: vi.fn(async () => ({ id: 'server-id' })),
bulkCreateHabitsMutateAsync: vi.fn(async () => ({ results: [] })),
logHabitMutateAsync: vi.fn(async () => undefined),
createGoalMutateAsync: vi.fn(async () => ({ id: 'goal-id' })),
performQueuedApiMutation: vi.fn(async () => undefined),
patchProfile: vi.fn(),
applyScheme: vi.fn(),
setItem: vi.fn(async () => undefined),
setQueryData: vi.fn(),
}))

vi.mock('expo-router', () => ({
useRouter: () => ({ replace: mocks.replace }),
}))

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))

vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ setQueryData: mocks.setQueryData }),
}))

vi.mock('@react-native-async-storage/async-storage', () => ({
default: { setItem: mocks.setItem },
}))

vi.mock('@/stores/onboarding-draft-store', () => {
const store = (selector: (state: typeof mocks.draftState) => unknown) =>
selector(mocks.draftState)
store.getState = () => mocks.draftState
return { useOnboardingDraftStore: store }
})

vi.mock('@/hooks/use-habits', () => ({
useCreateHabit: () => ({ mutateAsync: mocks.createHabitMutateAsync }),
useBulkCreateHabits: () => ({ mutateAsync: mocks.bulkCreateHabitsMutateAsync }),
useLogHabit: () => ({ mutateAsync: mocks.logHabitMutateAsync }),
}))

vi.mock('@/hooks/use-goals', () => ({
useCreateGoal: () => ({ mutateAsync: mocks.createGoalMutateAsync }),
}))

vi.mock('@/hooks/use-profile', () => ({
useProfile: () => ({ patchProfile: mocks.patchProfile }),
}))

vi.mock('@/lib/use-app-theme', () => ({
useAppTheme: () => ({ applyScheme: mocks.applyScheme }),
}))

vi.mock('@/lib/queued-api-mutation', () => ({
performQueuedApiMutation: mocks.performQueuedApiMutation,
}))

function captureActions(useActions: () => OnboardingActions): OnboardingActions {
const captured: { current: OnboardingActions | null } = { current: null }

function Harness() {
captured.current = useActions()
return null
}

TestRenderer.act(() => {
TestRenderer.create(<Harness />)
})

if (!captured.current) throw new Error('actions not captured')
return captured.current
}

describe('onboarding action provider factories', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('buffers answers and omits onImport in pre-auth mode', async () => {
const actions = captureActions(useBufferOnboardingActions)

const created = await actions.createHabit({ title: 'Read' })
expect(created).toEqual({ id: '2', title: 'Read' })
expect(mocks.draftState.bufferHabit).toHaveBeenCalledWith({ title: 'Read' })

await actions.createHabitsBulk([
{ title: 'Walk', emoji: '🚶', isGeneral: false, tags: ['movement'] },
{ title: 'Read' },
])
expect(mocks.draftState.bufferHabit).toHaveBeenCalledWith({
title: 'Walk',
emoji: '🚶',
isGeneral: false,
})
expect(mocks.draftState.bufferHabit).toHaveBeenCalledWith({ title: 'Read' })

await actions.logHabit('2')
expect(mocks.draftState.bufferFirstLog).toHaveBeenCalledWith(2, expect.any(String))

await actions.setWeekStartDay(1)
expect(mocks.draftState.bufferWeekStartDay).toHaveBeenCalledWith(1)

await actions.finishOnboarding()
expect(mocks.draftState.markOnboardingLocallyDone).toHaveBeenCalledTimes(1)
expect(mocks.replace).toHaveBeenCalledWith('/login?from=onboarding')

expect(actions.onImport).toBeUndefined()
})

it('writes through live server state and exposes onImport in post-auth mode', async () => {
const actions = captureActions(useLiveOnboardingActions)

const created = await actions.createHabit({ title: 'Run' })
expect(mocks.createHabitMutateAsync).toHaveBeenCalledWith({ title: 'Run' })
expect(created).toEqual({ id: 'server-id', title: 'Run' })

await actions.createHabitsBulk([
{ title: 'Walk', tags: ['movement'] },
])
expect(mocks.bulkCreateHabitsMutateAsync).toHaveBeenCalledWith({
habits: [{ title: 'Walk', tags: ['movement'] }],
})

await actions.setColorScheme('blue')
expect(mocks.applyScheme).toHaveBeenCalledWith('blue')

expect(actions.onImport).toBeTypeOf('function')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,54 @@ import {
shouldHideOnboardingFooter,
} from '@orbit/shared/utils'

const { routerMock, pathnameState, performQueuedApiMutationMock, captured } =
vi.hoisted(() => {
const capturedState: {
beginPress?: () => void
importPress?: () => void | Promise<void>
welcomeRendered: boolean
} = { welcomeRendered: false }
return {
routerMock: { replace: vi.fn(), push: vi.fn(), navigate: vi.fn() },
pathnameState: { value: '/' },
performQueuedApiMutationMock: vi.fn(),
captured: capturedState,
}
})
const { routerMock, pathnameState, actionsMock, captured } = vi.hoisted(() => {
const router = { replace: vi.fn(), push: vi.fn(), navigate: vi.fn() }
const capturedState: {
beginPress?: () => void
importPress?: () => void | Promise<void>
welcomeRendered: boolean
} = { welcomeRendered: false }
return {
routerMock: router,
pathnameState: { value: '/' },
actionsMock: {
createHabit: vi.fn(async (input: { title: string }) => ({
id: '0',
title: input.title,
})),
createHabitsBulk: vi.fn(async () => undefined),
logHabit: vi.fn(async () => undefined),
createGoal: vi.fn(async () => undefined),
setWeekStartDay: vi.fn(async () => undefined),
setColorScheme: vi.fn(async () => undefined),
finishOnboarding: vi.fn(async () => undefined),
onImport: () => router.replace('/chat'),
},
captured: capturedState,
}
})

vi.mock('expo-router', () => ({
useRouter: () => routerMock,
usePathname: () => pathnameState.value,
}))

vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ setQueryData: vi.fn() }),
}))

vi.mock('@/hooks/use-profile', () => ({
useHasProAccess: () => true,
}))

vi.mock('@/lib/queued-api-mutation', () => ({
performQueuedApiMutation: performQueuedApiMutationMock,
vi.mock('@/stores/auth-store', () => ({
useAuthStore: (selector: (state: { isAuthenticated: boolean }) => unknown) =>
selector({ isAuthenticated: false }),
}))

vi.mock('@/components/onboarding/onboarding-actions-context', () => ({
OnboardingActionsProvider: ({
children,
}: Readonly<{ children?: React.ReactNode }>) => children,
useOnboardingActions: () => actionsMock,
useOnboardingHasProAccess: () => true,
useOnboardingIsLive: () => false,
}))

vi.mock('@/components/ui/gradient-top', () => ({
Expand Down Expand Up @@ -131,7 +149,7 @@ describe('OnboardingFlow import handoff + resume', () => {
beforeEach(() => {
routerMock.replace.mockClear()
routerMock.push.mockClear()
performQueuedApiMutationMock.mockClear()
actionsMock.finishOnboarding.mockClear()
captured.beginPress = undefined
captured.importPress = undefined
captured.welcomeRendered = false
Expand All @@ -152,7 +170,7 @@ describe('OnboardingFlow import handoff + resume', () => {
})

expect(routerMock.replace).toHaveBeenCalledWith('/chat')
expect(performQueuedApiMutationMock).not.toHaveBeenCalled()
expect(actionsMock.finishOnboarding).not.toHaveBeenCalled()
})

it('hides the overlay while on the chat route and restores it after leaving chat', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { PillButton } from '@/components/ui/pill-button'

const TestRenderer = require('react-test-renderer')

const mutate = vi.fn((_vars: unknown, opts?: { onSuccess?: () => void }) => opts?.onSuccess?.())
const createHabitsBulk = vi.fn(async () => {})
const onCreated = vi.fn()
const onCreateOwn = vi.fn()
const onSkip = vi.fn()
Expand Down Expand Up @@ -48,8 +48,8 @@ vi.mock('@/lib/theme', async (importOriginal) => {
}
})

vi.mock('@/hooks/use-habits', () => ({
useBulkCreateHabits: () => ({ mutate, isPending: false }),
vi.mock('@/components/onboarding/onboarding-actions-context', () => ({
useOnboardingActions: () => ({ createHabitsBulk }),
}))

vi.mock('@/hooks/use-app-toast', () => ({
Expand Down Expand Up @@ -80,7 +80,7 @@ function pressByLabel(tree: ReturnType<typeof TestRenderer.create>, label: strin

describe('OnboardingTemplatePacks (mobile)', () => {
beforeEach(() => {
mutate.mockClear()
createHabitsBulk.mockClear()
onCreated.mockClear()
onCreateOwn.mockClear()
onSkip.mockClear()
Expand All @@ -103,7 +103,7 @@ describe('OnboardingTemplatePacks (mobile)', () => {
expect(onCreateOwn).toHaveBeenCalledTimes(1)
})

it('selects a pack, drops a toggled-off habit, and bulk-creates with tags', () => {
it('selects a pack, drops a toggled-off habit, and bulk-creates the rest with tags', async () => {
const pack = TEMPLATE_PACKS[0]
if (!pack) throw new Error('expected a template pack')
const firstHabit = pack.habits[0]
Expand All @@ -115,26 +115,30 @@ describe('OnboardingTemplatePacks (mobile)', () => {
pressByLabel(tree, templatePackHabitTitleKey(pack.id, firstHabit.key))

const cta = tree.root.findByType(PillButton)
TestRenderer.act(() => {
;(cta.props.onPress as () => void)()
await TestRenderer.act(async () => {
await (cta.props.onPress as () => Promise<void>)()
})

expect(mutate).toHaveBeenCalledTimes(1)
const call = mutate.mock.calls[0]
if (!call) throw new Error('expected a bulk-create call')
const payload = call[0] as {
habits: Array<{ title: string; isGeneral: boolean; tags: string[]; emoji: string }>
}
expect(payload.habits).toHaveLength(pack.habits.length - 1)
expect(payload.habits.map((habit) => habit.title)).not.toContain(
expect(createHabitsBulk).toHaveBeenCalledTimes(1)
const items = createHabitsBulk.mock.calls[0]![0] as Array<{
title: string
emoji?: string | null
tags?: string[] | null
}>
expect(items).toHaveLength(pack.habits.length - 1)

const titles = items.map((item) => item.title)
expect(titles).not.toContain(
templatePackHabitTitleKey(pack.id, firstHabit.key),
)

const secondItem = payload.habits.find(
(habit) => habit.title === templatePackHabitTitleKey(pack.id, secondHabit.key),
const secondItem = items.find(
(item) => item.title === templatePackHabitTitleKey(pack.id, secondHabit.key),
)
expect(secondItem?.emoji).toBe(secondHabit.emoji)
expect(secondItem?.tags).toEqual(secondHabit.tags.map((slug) => templatePackTagKey(slug)))
expect(secondItem?.tags).toEqual(
secondHabit.tags.map((slug) => templatePackTagKey(slug)),
)

expect(onCreated).toHaveBeenCalledTimes(1)
})
Expand Down
Loading