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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Thumbs.db

# Coverage
coverage/
coverage-*/

# Mutation testing (StrykerJS)
.stryker-tmp/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FreshStartModal } from '@/app/(tabs)/profile/_components/fresh-start-modal'

const replace = vi.fn()
const queryClientClear = vi.fn()

vi.mock('lucide-react-native', () => {
const icon = (name: string) => (props: Record<string, unknown>) =>
React.createElement(name, props)
return { Check: icon('Check'), RotateCcw: icon('RotateCcw'), X: icon('X') }
})

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

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

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

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

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

vi.mock('@/lib/checklist-template-storage', () => ({
clearChecklistTemplates: vi.fn(async () => undefined),
}))

vi.mock('@/lib/offline-mutations', () => ({
buildQueuedMutation: vi.fn((mutation: Record<string, unknown>) => ({ id: 'reset-1', ...mutation })),
createQueuedAck: vi.fn((id: string) => ({ queued: true, queuedMutationId: id })),
isQueuedResult: vi.fn((result: { queued?: boolean }) => result?.queued === true),
queueOrExecute: vi.fn(
async ({ execute, mutation }: { execute: (mutation: unknown) => Promise<unknown>; mutation: unknown }) =>
execute(mutation),
),
}))

vi.mock('@/lib/offline-queue', () => ({
clear: vi.fn(),
enqueue: vi.fn(),
}))

vi.mock('@/lib/query-client', () => ({
clearPersistedQueryCache: vi.fn(async () => undefined),
}))

vi.mock('@/components/bottom-sheet-modal', () => ({
BottomSheetModal: ({ open, children, title }: { open: boolean; children: React.ReactNode; title?: string }) =>
open ? React.createElement('BottomSheetModal', { title }, children) : null,
}))

vi.mock('@/components/ui/app-text-input', () => ({
AppTextInput: (props: Record<string, unknown>) => React.createElement('TextInput', props),
}))

vi.mock('@/components/ui/fresh-start-animation', () => ({
FreshStartAnimation: (props: Record<string, unknown>) => React.createElement('FreshStartAnimation', props),
}))

interface TestNode {
type: unknown
props: Record<string, unknown>
findAll(predicate: (node: TestNode) => boolean): TestNode[]
}
interface TestTree {
root: TestNode
}
interface TestRendererApi {
create(element: React.ReactNode): TestTree
act(callback: () => void | Promise<void>): Promise<void>
}
const TestRenderer: TestRendererApi = require('react-test-renderer')

async function render(element: React.ReactNode): Promise<TestTree> {
let tree!: TestTree
await TestRenderer.act(async () => {
tree = TestRenderer.create(element)
})
return tree
}

function buttonWithLabel(tree: TestTree, label: string): TestNode | undefined {
return tree.root.findAll(
(node) => node.props?.accessibilityRole === 'button' && node.props?.accessibilityLabel === label,
)[0]
}

function input(tree: TestTree): TestNode {
return tree.root.findAll((node) => node.type === 'TextInput')[0]!
}

async function press(node: TestNode) {
await TestRenderer.act(async () => {
;(node.props as { onPress: () => void }).onPress()
await new Promise((resolve) => setTimeout(resolve, 0))
})
}

async function confirmReset(tree: TestTree) {
await press(buttonWithLabel(tree, 'common.continue')!)
await TestRenderer.act(async () => {
;(input(tree).props as { onChangeText: (value: string) => void }).onChangeText('orbit')
})
await press(buttonWithLabel(tree, 'profile.freshStart.confirmButton')!)
}

describe('FreshStartModal', () => {
beforeEach(() => {
replace.mockClear()
queryClientClear.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})

it('renders the info step heading when opened', async () => {
const tree = await render(<FreshStartModal open onClose={vi.fn()} />)
const modal = tree.root.findAll((node) => node.type === 'BottomSheetModal')[0]!
expect(modal.props.title).toBe('profile.freshStart.heading')
})

it('advances from info to the confirm step', async () => {
const tree = await render(<FreshStartModal open onClose={vi.fn()} />)
await press(buttonWithLabel(tree, 'common.continue')!)
const modal = tree.root.findAll((node) => node.type === 'BottomSheetModal')[0]!
expect(modal.props.title).toBe('profile.freshStart.confirmHeading')
})

it('keeps the confirm button disabled until ORBIT is typed', async () => {
const tree = await render(<FreshStartModal open onClose={vi.fn()} />)
await press(buttonWithLabel(tree, 'common.continue')!)
expect(buttonWithLabel(tree, 'profile.freshStart.confirmButton')!.props.disabled).toBe(true)
await TestRenderer.act(async () => {
;(input(tree).props as { onChangeText: (value: string) => void }).onChangeText('orbit')
})
expect(buttonWithLabel(tree, 'profile.freshStart.confirmButton')!.props.disabled).toBe(false)
})

it('resets the account online, clears caches and plays the animation', async () => {
const onClose = vi.fn()
const { apiClient } = await import('@/lib/api-client')
const offlineQueue = await import('@/lib/offline-queue')
const tree = await render(<FreshStartModal open onClose={onClose} />)
await confirmReset(tree)

expect(vi.mocked(apiClient)).toHaveBeenCalledTimes(1)
expect(vi.mocked(offlineQueue.clear)).toHaveBeenCalledTimes(1)
expect(vi.mocked(offlineQueue.enqueue)).not.toHaveBeenCalled()
expect(queryClientClear).toHaveBeenCalled()
expect(onClose).toHaveBeenCalledTimes(1)

const animation = tree.root.findAll((node) => node.type === 'FreshStartAnimation')[0]!
expect(animation).toBeTruthy()
await TestRenderer.act(async () => {
;(animation.props as { onComplete: () => void }).onComplete()
})
expect(replace).toHaveBeenCalledWith('/')
})

it('enqueues the reset when it is queued offline', async () => {
const offlineMutations = await import('@/lib/offline-mutations')
const offlineQueue = await import('@/lib/offline-queue')
vi.mocked(offlineMutations.queueOrExecute).mockResolvedValueOnce({
queued: true,
queuedMutationId: 'reset-1',
})
const tree = await render(<FreshStartModal open onClose={vi.fn()} />)
await confirmReset(tree)
expect(vi.mocked(offlineQueue.enqueue)).toHaveBeenCalledTimes(1)
})

it('surfaces a friendly error and keeps the modal open on failure', async () => {
const onClose = vi.fn()
const offlineMutations = await import('@/lib/offline-mutations')
vi.mocked(offlineMutations.queueOrExecute).mockRejectedValueOnce(new Error('offline'))
const tree = await render(<FreshStartModal open onClose={onClose} />)
await confirmReset(tree)
expect(onClose).not.toHaveBeenCalled()
expect(tree.root.findAll((node) => node.type === 'FreshStartAnimation')).toHaveLength(0)
const errorText = tree.root
.findAll((node) => node.type === 'Text')
.map((node) => node.props.children)
.find((value) => typeof value === 'string' && value.toLowerCase().includes('error'))
expect(errorText).toBeTruthy()
})
})
175 changes: 175 additions & 0 deletions apps/mobile/__tests__/app/social/_components/friend-row.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import React from 'react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { FriendSummary } from '@orbit/shared/types/social'

const mocks = vi.hoisted(() => ({
removeMutateAsync: vi.fn(),
blockMutateAsync: vi.fn(),
reportMutateAsync: vi.fn(),
reportPending: false,
showSuccess: vi.fn(),
showError: vi.fn(),
}))

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

vi.mock('@/hooks/use-friends', () => ({
useRemoveFriend: () => ({ mutateAsync: mocks.removeMutateAsync, isPending: false }),
useBlockUser: () => ({ mutateAsync: mocks.blockMutateAsync, isPending: false }),
useReportUser: () => ({ mutateAsync: mocks.reportMutateAsync, isPending: mocks.reportPending }),
}))

vi.mock('@/components/ui/user-avatar', () => ({
UserAvatar: ({ name }: { name: string }) => React.createElement('Text', null, name),
}))

vi.mock('@/components/ui/settings-group', () => ({
SettingsGroup: ({ children }: { children: React.ReactNode }) => React.createElement('View', null, children),
SettingsGroupRow: ({ label, onPress }: { label: string; onPress: () => void }) =>
React.createElement(
'Pressable',
{ accessibilityRole: 'button', accessibilityLabel: label, onPress },
React.createElement('Text', null, label),
),
}))

vi.mock('@/components/ui/pill-button', () => ({
PillButton: ({ children, onPress, disabled }: { children: React.ReactNode; onPress: () => void; disabled?: boolean }) =>
React.createElement(
'Pressable',
{ accessibilityRole: 'button', accessibilityLabel: 'submit-report', onPress, disabled },
children,
),
}))

vi.mock('@/components/ui/confirm-dialog', () => ({
ConfirmDialog: ({ open, onConfirm, confirmLabel }: { open: boolean; onConfirm: () => void; confirmLabel: string }) =>
open
? React.createElement('Pressable', {
accessibilityRole: 'button',
accessibilityLabel: `confirm:${confirmLabel}`,
onPress: onConfirm,
})
: null,
}))

import { FriendRow } from '@/app/social/_components/friend-row'

interface TestNode {
type: unknown
props: Record<string, unknown>
findAll(predicate: (node: TestNode) => boolean): TestNode[]
}
interface TestTree {
root: TestNode
}
interface TestRendererApi {
create(element: React.ReactNode): TestTree
act(callback: () => void | Promise<void>): Promise<void> | void
}
const TestRenderer: TestRendererApi = require('react-test-renderer')

const friend: FriendSummary = {
userId: 'u-1',
handle: 'ada',
displayName: 'Ada Lovelace',
currentStreak: 9,
}

function byLabel(tree: TestTree, label: string): TestNode | undefined {
return tree.root.findAll(
(node) => node.props?.accessibilityRole === 'button' && node.props.accessibilityLabel === label,
)[0]
}

function pressLabel(tree: TestTree, label: string) {
const node = byLabel(tree, label)
if (!node) throw new Error(`no button ${label}`)
TestRenderer.act(() => {
;(node.props as { onPress: () => void }).onPress()
})
}

function renderRow(overrides?: { onCheer?: (target: unknown) => void; onOpenProfile?: (target: unknown) => void }) {
const onCheer = overrides?.onCheer ?? vi.fn()
const onOpenProfile = overrides?.onOpenProfile ?? vi.fn()
let tree!: TestTree
TestRenderer.act(() => {
tree = TestRenderer.create(
<FriendRow friend={friend} onCheer={onCheer} onOpenProfile={onOpenProfile} />,
)
})
return { tree, onCheer, onOpenProfile }
}

describe('FriendRow', () => {
beforeEach(() => {
mocks.removeMutateAsync.mockReset().mockResolvedValue(undefined)
mocks.blockMutateAsync.mockReset().mockResolvedValue(undefined)
mocks.reportMutateAsync.mockReset().mockResolvedValue(undefined)
mocks.showSuccess.mockReset()
mocks.showError.mockReset()
mocks.reportPending = false
})

it('renders the friend identity and streak', () => {
const { tree } = renderRow()
const rendered = tree.root.findAll((node) => node.type === 'Text').map((node) => node.props.children)
expect(rendered).toContain('Ada Lovelace')
expect(rendered).toContain('social.friends.streakLabel:{"count":9}')
})

it('cheers and opens the profile through the identity and cheer buttons', () => {
const { tree, onCheer, onOpenProfile } = renderRow()
pressLabel(tree, 'social.friends.viewProfile')
expect(onOpenProfile).toHaveBeenCalledWith({ userId: 'u-1', displayName: 'Ada Lovelace' })
pressLabel(tree, 'social.friends.cheer')
expect(onCheer).toHaveBeenCalledWith({ recipientId: 'u-1', displayName: 'Ada Lovelace' })
})

it('removes a friend after confirming and surfaces errors from the mutation', async () => {
mocks.removeMutateAsync.mockRejectedValueOnce(new Error('offline'))
const { tree } = renderRow()
pressLabel(tree, 'social.friends.moreActions')
pressLabel(tree, 'social.friends.remove')
await TestRenderer.act(async () => {
pressLabel(tree, 'confirm:social.friends.remove')
})
expect(mocks.removeMutateAsync).toHaveBeenCalledWith('u-1')
expect(mocks.showError).toHaveBeenCalledTimes(1)
expect(mocks.showSuccess).not.toHaveBeenCalled()
})

it('blocks a user and reports success', async () => {
const { tree } = renderRow()
pressLabel(tree, 'social.friends.moreActions')
pressLabel(tree, 'social.friends.block')
await TestRenderer.act(async () => {
pressLabel(tree, 'confirm:social.friends.block')
})
expect(mocks.blockMutateAsync).toHaveBeenCalledWith('u-1')
expect(mocks.showSuccess).toHaveBeenCalledWith('social.block.success')
})

it('submits a report with the trimmed details and reports success', async () => {
const { tree } = renderRow()
pressLabel(tree, 'social.friends.moreActions')
pressLabel(tree, 'social.friends.report')
await TestRenderer.act(async () => {
pressLabel(tree, 'submit-report')
})
expect(mocks.reportMutateAsync).toHaveBeenCalledWith({
reportedUserId: 'u-1',
reason: 'Spam',
details: undefined,
})
expect(mocks.showSuccess).toHaveBeenCalledWith('social.report.success')
})
})
Loading
Loading