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
57 changes: 56 additions & 1 deletion apps/mobile/__tests__/hooks/use-goals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { goalKeys, habitKeys } from '@orbit/shared/query'
import type { CreateGoalRequest, Goal, GoalDetailWithMetrics } from '@orbit/shared/types/goal'
import type { HabitScheduleItem } from '@orbit/shared/types/habit'

import { useCreateGoal , useLinkHabitsToGoal, useUpdateGoalProgress } from '@/hooks/use-goals'
import { API } from '@orbit/shared/api'
import { useCreateGoal , useDeleteGoal, useLinkHabitsToGoal, useRestoreGoal, useUpdateGoalProgress } from '@/hooks/use-goals'


const mocks = vi.hoisted(() => {
Expand Down Expand Up @@ -103,6 +104,9 @@ const mocks = vi.hoisted(() => {
queuedMutationId: mutationId,
})),
invalidateGoalQueries: vi.fn(async () => {}),
showSuccess: vi.fn(),
showError: vi.fn(),
showUndoToast: vi.fn(),
}
})

Expand Down Expand Up @@ -144,6 +148,20 @@ vi.mock('@/lib/goal-mutation-helpers', async () => {
}
})

vi.mock('@/hooks/use-app-toast', () => ({
useAppToast: () => ({
showSuccess: mocks.showSuccess,
showError: mocks.showError,
showQueued: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
}),
}))

vi.mock('@/hooks/use-undo-toast', () => ({
useUndoToast: () => mocks.showUndoToast,
}))

type MutationConfig<TResult, TVariables, TContext> = {
mutationFn: (variables: TVariables) => Promise<TResult>
onMutate?: (variables: TVariables) => Promise<TContext> | TContext
Expand Down Expand Up @@ -239,6 +257,9 @@ describe('mobile goal hooks', () => {
mocks.queueOrExecute.mockReset()
mocks.withQueuedMarker.mockClear()
mocks.invalidateGoalQueries.mockClear()
mocks.showSuccess.mockClear()
mocks.showError.mockClear()
mocks.showUndoToast.mockClear()
})

it('inserts an optimistic temp goal and skips invalidation when the create is queued', async () => {
Expand Down Expand Up @@ -349,4 +370,38 @@ describe('mobile goal hooks', () => {
expect(mocks.state.lists[0]?.value[0]?.linkedHabits).toBeUndefined()
expect(mocks.state.details.get(JSON.stringify(goalKeys.detail('goal-1')))?.goal.linkedHabits).toBeUndefined()
})

it('shows the undo snackbar when a goal delete succeeds', () => {
const mutation = useDeleteGoal() as unknown as MutationConfig<unknown, string, undefined>

mutation.onSuccess?.(undefined, 'goal-1', undefined)

expect(mocks.showUndoToast).toHaveBeenCalledWith('undo.goalDeleted', expect.any(Function))
})

it('restores a goal through the queued path, targets the restore endpoint, and confirms', async () => {
const mutation = useRestoreGoal() as unknown as MutationConfig<unknown, string, undefined>

mocks.queueOrExecute.mockResolvedValue(undefined)

const result = await mutation.mutationFn('goal-1')
mutation.onSuccess?.(result, 'goal-1', undefined)
mutation.onSettled?.(result, null, 'goal-1', undefined)

expect(mocks.buildQueuedMutation).toHaveBeenCalledWith(expect.objectContaining({
type: 'restoreGoal',
endpoint: API.goals.restore('goal-1'),
method: 'POST',
}))
expect(mocks.showSuccess).toHaveBeenCalledWith('undo.restored')
expect(mocks.invalidateGoalQueries).toHaveBeenCalledTimes(1)
})

it('surfaces an error toast when a goal restore fails', () => {
const mutation = useRestoreGoal() as unknown as MutationConfig<unknown, string, undefined>

mutation.onError?.(new Error('boom'), 'goal-1', undefined)

expect(mocks.showError).toHaveBeenCalledWith('undo.restoreFailed')
})
})
59 changes: 59 additions & 0 deletions apps/mobile/__tests__/hooks/use-habits.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API } from '@orbit/shared/api'
import { habitKeys, goalKeys, tagKeys } from '@orbit/shared/query'
import type { ChecklistItem, CreateHabitRequest, HabitScheduleChild, HabitScheduleItem } from '@orbit/shared/types/habit'

import {
useCreateHabit,
useCreateSubHabit,
useDeleteHabit,
useLogHabit,
useMoveHabitParent,
useRestoreHabit,
useSkipHabit,
useUpdateChecklist,
} from '@/hooks/use-habits'
Expand Down Expand Up @@ -117,6 +120,9 @@ const mocks = vi.hoisted(() => {
syncWidgetData: vi.fn(async () => {}),
setLastCreatedHabitId: vi.fn(),
invalidateHabitMutationQueries: vi.fn(async () => {}),
showSuccess: vi.fn(),
showError: vi.fn(),
showUndoToast: vi.fn(),
}
})

Expand Down Expand Up @@ -178,6 +184,20 @@ vi.mock('@/lib/habit-mutation-helpers', async () => {
}
})

vi.mock('@/hooks/use-app-toast', () => ({
useAppToast: () => ({
showSuccess: mocks.showSuccess,
showError: mocks.showError,
showQueued: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
}),
}))

vi.mock('@/hooks/use-undo-toast', () => ({
useUndoToast: () => mocks.showUndoToast,
}))

type MutationConfig<TResult, TVariables, TContext> = {
mutationFn: (variables: TVariables) => Promise<TResult>
onMutate?: (variables: TVariables) => Promise<TContext> | TContext
Expand Down Expand Up @@ -296,6 +316,9 @@ describe('mobile habit hooks', () => {
mocks.syncWidgetData.mockClear()
mocks.setLastCreatedHabitId.mockClear()
mocks.invalidateHabitMutationQueries.mockClear()
mocks.showSuccess.mockClear()
mocks.showError.mockClear()
mocks.showUndoToast.mockClear()
})

it('optimistically completes before query cancellation resolves', () => {
Expand Down Expand Up @@ -620,4 +643,40 @@ describe('mobile habit hooks', () => {
expect(list[0]?.hasSubHabits).toBe(false)
expect(list[0]?.children).toEqual([])
})

it('shows the undo snackbar when a habit delete succeeds', () => {
const mutation = useDeleteHabit() as unknown as MutationConfig<unknown, string, undefined>

mutation.onSuccess?.(undefined, 'habit-1', undefined)

expect(mocks.showUndoToast).toHaveBeenCalledWith('undo.habitDeleted', expect.any(Function))
})

it('restores a habit through the queued path, targets the restore endpoint, and confirms', async () => {
mocks.runQueuedMutation.mockResolvedValueOnce({})

const mutation = useRestoreHabit() as unknown as MutationConfig<unknown, string, undefined>

const result = await mutation.mutationFn('habit-1')
mutation.onSuccess?.(result, 'habit-1', undefined)
await mutation.onSettled?.(result, null, 'habit-1', undefined)

expect(mocks.runQueuedMutation).toHaveBeenCalledWith(expect.objectContaining({
mutation: expect.objectContaining({
type: 'restoreHabit',
endpoint: API.habits.restore('habit-1'),
method: 'POST',
}),
}))
expect(mocks.showSuccess).toHaveBeenCalledWith('undo.restored')
expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: habitKeys.lists() })
})

it('surfaces an error toast when a habit restore fails', () => {
const mutation = useRestoreHabit() as unknown as MutationConfig<unknown, string, undefined>

mutation.onError?.(new Error('boom'), 'habit-1', undefined)

expect(mocks.showError).toHaveBeenCalledWith('undo.restoreFailed')
})
})
128 changes: 128 additions & 0 deletions apps/mobile/__tests__/hooks/use-tags-restore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API } from '@orbit/shared/api'
import { habitKeys, tagKeys } from '@orbit/shared/query'

import { useDeleteTag, useRestoreTag } from '@/hooks/use-tags'

const mocks = vi.hoisted(() => ({
queryClient: {
invalidateQueries: vi.fn(async () => {}),
cancelQueries: vi.fn(async () => {}),
getQueriesData: vi.fn(() => []),
setQueriesData: vi.fn(),
},
apiClient: vi.fn(async () => undefined),
buildQueuedMutation: vi.fn((options: Record<string, unknown>) => ({ id: 'mutation-1', ...options })),
createQueuedAck: vi.fn((id: string) => ({ queued: true as const, queuedMutationId: id })),
isQueuedResult: vi.fn((value: unknown) => (
typeof value === 'object' &&
value !== null &&
'queued' in value &&
(value as { queued?: boolean }).queued === true
)),
queueOrExecute: vi.fn(async ({ execute, mutation }: {
execute: (mutation: unknown) => Promise<unknown>
mutation: unknown
}) => execute(mutation)),
showSuccess: vi.fn(),
showError: vi.fn(),
showUndoToast: vi.fn(),
}))

vi.mock('@tanstack/react-query', () => ({
useMutation: (config: unknown) => config,
useQueryClient: () => mocks.queryClient,
useQuery: vi.fn(),
}))

vi.mock('@/lib/api-client', () => ({ apiClient: mocks.apiClient }))

vi.mock('@/lib/offline-mutations', () => ({
buildQueuedMutation: mocks.buildQueuedMutation,
createQueuedAck: mocks.createQueuedAck,
createTempEntityId: vi.fn(() => 'offline-tag-1'),
isQueuedResult: mocks.isQueuedResult,
queueOrExecute: mocks.queueOrExecute,
withQueuedMarker: vi.fn((value: Record<string, unknown>, id: string) => ({
...value,
queued: true as const,
queuedMutationId: id,
})),
}))

vi.mock('@/hooks/use-app-toast', () => ({
useAppToast: () => ({
showSuccess: mocks.showSuccess,
showError: mocks.showError,
showQueued: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
}),
}))

vi.mock('@/hooks/use-undo-toast', () => ({
useUndoToast: () => mocks.showUndoToast,
}))

type MutationConfig<TResult, TVariables, TContext> = {
mutationFn: (variables: TVariables) => Promise<TResult>
onSuccess?: (data: TResult, variables: TVariables, context: TContext | undefined) => void
onError?: (error: Error, variables: TVariables, context: TContext | undefined) => void
onSettled?: (
data: TResult | undefined,
error: Error | null,
variables: TVariables,
context: TContext | undefined,
) => void | Promise<void>
}

describe('mobile tag undo + restore', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.apiClient.mockResolvedValue(undefined)
mocks.queueOrExecute.mockImplementation(async ({ execute, mutation }) => execute(mutation))
})

it('shows the undo snackbar when a delete succeeds', () => {
const mutation = useDeleteTag() as unknown as MutationConfig<unknown, string, unknown>

mutation.onSuccess?.(undefined, 'tag-1', undefined)

expect(mocks.showUndoToast).toHaveBeenCalledWith('undo.tagDeleted', expect.any(Function))
})

it('restores a tag through the queued path, hits the restore endpoint, and invalidates', async () => {
const mutation = useRestoreTag() as unknown as MutationConfig<unknown, string, unknown>

const result = await mutation.mutationFn('tag-1')
mutation.onSuccess?.(result, 'tag-1', undefined)
await mutation.onSettled?.(result, null, 'tag-1', undefined)

expect(mocks.buildQueuedMutation).toHaveBeenCalledWith(
expect.objectContaining({ type: 'restoreTag', endpoint: API.tags.restore('tag-1'), method: 'POST' }),
)
expect(mocks.apiClient).toHaveBeenCalledWith(API.tags.restore('tag-1'), { method: 'POST' })
expect(mocks.showSuccess).toHaveBeenCalledWith('undo.restored')
expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: tagKeys.all })
expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: habitKeys.lists() })
})

it('skips invalidation when the restore is queued offline', async () => {
const mutation = useRestoreTag() as unknown as MutationConfig<unknown, string, unknown>

mocks.queueOrExecute.mockResolvedValueOnce({ queued: true, queuedMutationId: 'mutation-1' })

const result = await mutation.mutationFn('tag-1')
await mutation.onSettled?.(result, null, 'tag-1', undefined)

expect(mocks.queryClient.invalidateQueries).not.toHaveBeenCalled()
})

it('surfaces an error toast when restore fails', () => {
const mutation = useRestoreTag() as unknown as MutationConfig<unknown, string, unknown>

mutation.onError?.(new Error('boom'), 'tag-1', undefined)

expect(mocks.showError).toHaveBeenCalledWith('undo.restoreFailed')
})
})
14 changes: 14 additions & 0 deletions apps/mobile/__tests__/hooks/use-tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,20 @@ vi.mock('@/lib/offline-mutations', () => ({
withQueuedMarker: mocks.withQueuedMarker,
}))

vi.mock('@/hooks/use-app-toast', () => ({
useAppToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showQueued: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
}),
}))

vi.mock('@/hooks/use-undo-toast', () => ({
useUndoToast: () => vi.fn(),
}))

type MutationConfig<TResult, TVariables, TContext> = {
mutationFn: (variables: TVariables) => Promise<TResult>
onMutate?: (variables: TVariables) => Promise<TContext> | TContext
Expand Down
Loading