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
142 changes: 142 additions & 0 deletions apps/mobile/__tests__/components/calendar-picker-section.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { UserCalendar } from '@orbit/shared/types/calendar'

import { CalendarPickerSection } from '@/app/calendar-picker-section'
import { createStyles } from '@/app/calendar-sync-styles'

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

const mocks = vi.hoisted(() => ({
calendars: undefined as UserCalendar[] | undefined,
isLoading: false,
isError: false,
mutate: vi.fn(),
showError: vi.fn(),
}))

vi.mock('@/lib/use-app-theme', () => ({
useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }),
}))

vi.mock('@/lib/theme', () => ({
createTokensV2: () => new Proxy({}, { get: () => '#111111' }),
easings: { smooth: [0.2, 0, 0, 1] },
tintFromPrimary: () => 'rgba(127,70,247,0.1)',
}))

vi.mock('@/lib/motion', () => ({
toAnimatedEasing: () => (value: number) => value,
}))

vi.mock('@/hooks/use-calendars', () => ({
useCalendars: () => ({
data: mocks.calendars,
isLoading: mocks.isLoading,
isError: mocks.isError,
}),
useSetSelectedCalendars: () => ({ mutate: mocks.mutate }),
}))

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

const tokens = new Proxy({}, { get: () => '#111111' }) as never
const styles = createStyles()
const t = ((key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key) as never

function buildCalendar(overrides: Partial<UserCalendar> = {}): UserCalendar {
return {
id: 'cal-1',
name: 'Personal',
accessRole: 'owner',
primary: true,
backgroundColor: '#7f46f7',
isSynced: true,
...overrides,
}
}

type TestNode = { props: Record<string, unknown>; type?: unknown }

function render(enabled: boolean) {
let tree: {
root: {
findAll: (predicate: (node: TestNode) => boolean) => TestNode[]
}
} | null = null
TestRenderer.act(() => {
tree = TestRenderer.create(
React.createElement(CalendarPickerSection, { styles, tokens, t, enabled }),
)
})
return tree!
}

function switches(tree: ReturnType<typeof render>) {
return tree.root.findAll(
(node) => node.props.accessibilityRole === 'switch' && typeof node.type === 'string',
)
}

beforeEach(() => {
mocks.calendars = undefined
mocks.isLoading = false
mocks.isError = false
mocks.mutate.mockReset()
mocks.showError.mockReset()
})

describe('mobile CalendarPickerSection', () => {
it('renders nothing when disabled', () => {
mocks.calendars = [buildCalendar()]
const tree = render(false)
const hostNodes = tree.root.findAll((node) => typeof node.type === 'string')
expect(hostNodes.length).toBe(0)

Check warning on line 97 in apps/mobile/__tests__/components/calendar-picker-section.test.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer a more specific assertion instead of this generic one, e.g. "expect(hostNodes).toHaveLength(0)".

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-ui-mobile&issues=AZ79NosxEFZOPMNa_Cw0&open=AZ79NosxEFZOPMNa_Cw0&pullRequest=306
})

it('renders a switch per calendar reflecting its synced state', () => {
mocks.calendars = [
buildCalendar({ id: 'cal-1', isSynced: true }),
buildCalendar({ id: 'cal-2', name: 'Work', primary: false, isSynced: false }),
]
const found = switches(render(true))
expect(found).toHaveLength(2)
expect((found[0]!.props.accessibilityState as { checked: boolean }).checked).toBe(true)
expect((found[1]!.props.accessibilityState as { checked: boolean }).checked).toBe(false)
})

it('persists the flipped synced value on toggle', () => {
mocks.calendars = [buildCalendar({ id: 'cal-1', isSynced: true })]
const found = switches(render(true))

TestRenderer.act(() => {
;(found[0]!.props.onPress as () => void)()
})

expect(mocks.mutate).toHaveBeenCalledWith(
{ id: 'cal-1', isSynced: false },
expect.anything(),
)
})

it('renders the empty state when no calendars are returned', () => {
mocks.calendars = []
const tree = render(true)
const texts = tree.root.findAll(
(node) => node.props.children === 'calendar.calendars.empty',
)
expect(texts.length).toBeGreaterThan(0)
})

it('renders the error state', () => {
mocks.isError = true
const tree = render(true)
const texts = tree.root.findAll(
(node) => node.props.children === 'calendar.calendars.error',
)
expect(texts.length).toBeGreaterThan(0)
})
})
43 changes: 41 additions & 2 deletions apps/mobile/__tests__/components/ui/app-time-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ import { AppTimePicker } from '@/components/ui/app-time-picker'

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

let mockUses24HourClock = true

vi.mock('@/hooks/use-profile', () => ({
useProfile: () => ({
profile: {
uses24HourClock: mockUses24HourClock,
timeZone: 'America/Sao_Paulo',
},
}),
}))

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, unknown>) =>
Expand All @@ -22,9 +33,10 @@ vi.mock('react-i18next', () => ({
describe('AppTimePicker', () => {
beforeEach(() => {
resetDateTimePickerMock()
mockUses24HourClock = true
})

it('uses the active locale for display text and Android 24-hour picker mode', async () => {
it('uses the active locale for display text and Android 24-hour picker mode when uses24HourClock is true', async () => {
const onChange = vi.fn()
let tree: any

Expand All @@ -37,7 +49,13 @@ describe('AppTimePicker', () => {
const [textTrigger] = tree.root.findAllByType(Pressable)
const label = tree.root.findByType('Text')

expect(label.props.children).toBe(formatLocaleTime('14:30', 'pt-BR'))
expect(label.props.children).toBe(
formatLocaleTime('14:30', 'pt-BR', {
hour: 'numeric',
minute: '2-digit',
hour12: false,
}),
)

await TestRenderer.act(async () => {
textTrigger.props.onPress()
Expand All @@ -47,6 +65,27 @@ describe('AppTimePicker', () => {
expect(dateTimePickerOpenCalls[0]?.is24Hour).toBe(true)
})

it('opens the Android picker in 12-hour mode when uses24HourClock is false', async () => {
mockUses24HourClock = false
const onChange = vi.fn()
let tree: any

await TestRenderer.act(async () => {
tree = TestRenderer.create(
<AppTimePicker value="14:30" onChange={onChange} placeholder="HH:MM" />,
)
})

const [textTrigger] = tree.root.findAllByType(Pressable)

await TestRenderer.act(async () => {
textTrigger.props.onPress()
})

expect(dateTimePickerOpenCalls).toHaveLength(1)
expect(dateTimePickerOpenCalls[0]?.is24Hour).toBe(false)
})

it('renders a clear button when value is set and onClear is provided', async () => {
const onClear = vi.fn()
let tree: any
Expand Down
158 changes: 158 additions & 0 deletions apps/mobile/__tests__/hooks/use-calendars.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { calendarKeys } from '@orbit/shared/query'
import type { UserCalendar } from '@orbit/shared/types/calendar'

import { useCalendars, useSetSelectedCalendars } from '@/hooks/use-calendars'

const mocks = vi.hoisted(() => {
const store: { calendars: UserCalendar[] | undefined } = { calendars: undefined }

const queryClient = {
cancelQueries: vi.fn(async () => {}),
invalidateQueries: vi.fn(async () => {}),
getQueryData: vi.fn(() => store.calendars),
setQueryData: vi.fn(
(
_queryKey: readonly unknown[],
updater: UserCalendar[] | undefined | ((old: unknown) => unknown),
) => {
store.calendars =
typeof updater === 'function'
? (updater as (old: unknown) => UserCalendar[] | undefined)(store.calendars)
: updater
},
),
}

return {
store,
queryClient,
useQuery: vi.fn(),
useQueryClient: vi.fn(() => queryClient),
useMutation: vi.fn((config: unknown) => config),
apiClient: vi.fn(),
}
})

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

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

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

function buildCalendar(overrides: Partial<UserCalendar> = {}): UserCalendar {
return {
id: 'cal-1',
name: 'Personal',
accessRole: 'owner',
primary: true,
backgroundColor: '#7f46f7',
isSynced: true,
...overrides,
}
}

describe('mobile calendar picker hooks', () => {
beforeEach(() => {
mocks.store.calendars = undefined
mocks.apiClient.mockReset()
mocks.useQuery.mockReset()
mocks.useMutation.mockClear()
mocks.queryClient.getQueryData.mockClear()
mocks.queryClient.setQueryData.mockClear()
mocks.queryClient.invalidateQueries.mockClear()
})

it('useCalendars loads and parses calendars from the api', async () => {
let capturedFn: (() => Promise<UserCalendar[]>) | null = null
mocks.useQuery.mockImplementation(
(config: { queryKey: readonly unknown[]; queryFn: () => Promise<UserCalendar[]> }) => {
capturedFn = config.queryFn
return { data: undefined }
},
)

const calendars = [buildCalendar(), buildCalendar({ id: 'cal-2', name: 'Work', primary: false })]
mocks.apiClient.mockResolvedValue(calendars)

useCalendars()

expect(mocks.useQuery).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: calendarKeys.calendars() }),
)

const result = await capturedFn!()
expect(result).toEqual(calendars)
expect(mocks.apiClient).toHaveBeenCalledWith('/api/calendar/calendars')
})

it('useSetSelectedCalendars sends the ids of every synced calendar after the toggle', async () => {
const mutation = useSetSelectedCalendars() as unknown as MutationConfig<
void,
{ id: string; isSynced: boolean },
{ previous: UserCalendar[] | undefined }
>

mocks.store.calendars = [
buildCalendar({ id: 'cal-1', isSynced: true }),
buildCalendar({ id: 'cal-2', isSynced: false }),
]
mocks.apiClient.mockResolvedValue(undefined)

await mutation.mutationFn({ id: 'cal-2', isSynced: true })

expect(mocks.apiClient).toHaveBeenCalledWith(
'/api/calendar/selected-calendars',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ calendarIds: ['cal-1', 'cal-2'] }),
}),
)
})

it('useSetSelectedCalendars optimistically flips isSynced for the toggled calendar', async () => {
const mutation = useSetSelectedCalendars() as unknown as MutationConfig<
void,
{ id: string; isSynced: boolean },
{ previous: UserCalendar[] | undefined }
>

mocks.store.calendars = [buildCalendar({ id: 'cal-1', isSynced: true })]

await mutation.onMutate?.({ id: 'cal-1', isSynced: false })

expect(mocks.store.calendars?.[0]?.isSynced).toBe(false)
})

it('useSetSelectedCalendars rolls back the optimistic update when the api fails', async () => {
const mutation = useSetSelectedCalendars() as unknown as MutationConfig<
void,
{ id: string; isSynced: boolean },
{ previous: UserCalendar[] | undefined }
>

const initial = [buildCalendar({ id: 'cal-1', isSynced: true })]
mocks.store.calendars = [...initial]
mocks.apiClient.mockRejectedValue(new Error('Save failed'))

const context = await mutation.onMutate?.({ id: 'cal-1', isSynced: false })
expect(mocks.store.calendars?.[0]?.isSynced).toBe(false)

await expect(mutation.mutationFn({ id: 'cal-1', isSynced: false })).rejects.toThrow(
'Save failed',
)
mutation.onError?.(new Error('Save failed'), { id: 'cal-1', isSynced: false }, context)

expect(mocks.store.calendars?.[0]?.isSynced).toBe(true)
})
})
Loading