Add unit tests: auth store, API layer (tasks, habits, savings, profile), vitest config
All checks were successful
CI / ci (push) Successful in 33s

This commit is contained in:
Cosmo
2026-03-01 02:33:04 +00:00
parent fd2b4fdff7
commit c9047177ee
6 changed files with 2170 additions and 15 deletions

1739
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,31 +11,36 @@
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5",
"clsx": "^2.1.0",
"date-fns": "^3.3.1",
"framer-motion": "^11.0.3",
"lucide-react": "^0.312.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.22.0", "react-router-dom": "^6.22.0",
"@tanstack/react-query": "^5.17.0", "recharts": "^2.12.0",
"axios": "^1.6.5", "zustand": "^4.5.0"
"zustand": "^4.5.0",
"date-fns": "^3.3.1",
"lucide-react": "^0.312.0",
"clsx": "^2.1.0",
"framer-motion": "^11.0.3",
"recharts": "^2.12.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-essentials": "^8.5.0",
"@storybook/addon-themes": "^8.5.0",
"@storybook/blocks": "^8.5.0",
"@storybook/react": "^8.5.0",
"@storybook/react-vite": "^8.5.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"jsdom": "^28.1.0",
"postcss": "^8.4.33", "postcss": "^8.4.33",
"storybook": "^8.5.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"vite": "^5.0.12", "vite": "^5.0.12",
"@storybook/react": "^8.5.0", "vitest": "^4.0.18"
"@storybook/react-vite": "^8.5.0",
"@storybook/addon-essentials": "^8.5.0",
"@storybook/addon-themes": "^8.5.0",
"@storybook/blocks": "^8.5.0",
"storybook": "^8.5.0"
} }
} }

267
src/__tests__/api.test.js Normal file
View File

@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
},
}))
import api from '../api/client'
import { tasksApi } from '../api/tasks'
import { habitsApi } from '../api/habits'
import { savingsApi } from '../api/savings'
import { profileApi } from '../api/profile'
describe('tasksApi', () => {
beforeEach(() => vi.clearAllMocks())
it('should list all tasks', async () => {
api.get.mockResolvedValueOnce({ data: [{ id: 1, title: 'Test' }] })
const result = await tasksApi.list()
expect(api.get).toHaveBeenCalledWith('tasks')
expect(result).toEqual([{ id: 1, title: 'Test' }])
})
it('should list completed tasks', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await tasksApi.list(true)
expect(api.get).toHaveBeenCalledWith('tasks?completed=true')
})
it('should list incomplete tasks', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await tasksApi.list(false)
expect(api.get).toHaveBeenCalledWith('tasks?completed=false')
})
it('should get today tasks', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await tasksApi.today()
expect(api.get).toHaveBeenCalledWith('tasks/today')
})
it('should create a task', async () => {
const taskData = { title: 'New Task', priority: 1 }
api.post.mockResolvedValueOnce({ data: { id: 1, ...taskData } })
const result = await tasksApi.create(taskData)
expect(api.post).toHaveBeenCalledWith('tasks', taskData)
expect(result.title).toBe('New Task')
})
it('should update a task', async () => {
api.put.mockResolvedValueOnce({ data: { id: 1, title: 'Updated' } })
await tasksApi.update(1, { title: 'Updated' })
expect(api.put).toHaveBeenCalledWith('tasks/1', { title: 'Updated' })
})
it('should delete a task', async () => {
api.delete.mockResolvedValueOnce({})
await tasksApi.delete(1)
expect(api.delete).toHaveBeenCalledWith('tasks/1')
})
it('should complete a task', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1, completed: true } })
await tasksApi.complete(1)
expect(api.post).toHaveBeenCalledWith('tasks/1/complete')
})
it('should uncomplete a task', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1, completed: false } })
await tasksApi.uncomplete(1)
expect(api.post).toHaveBeenCalledWith('tasks/1/uncomplete')
})
it('should get a single task', async () => {
api.get.mockResolvedValueOnce({ data: { id: 5, title: 'Task 5' } })
const result = await tasksApi.get(5)
expect(api.get).toHaveBeenCalledWith('tasks/5')
expect(result.id).toBe(5)
})
})
describe('habitsApi', () => {
beforeEach(() => vi.clearAllMocks())
it('should list habits', async () => {
api.get.mockResolvedValueOnce({ data: [{ id: 1, name: 'Exercise' }] })
const result = await habitsApi.list()
expect(api.get).toHaveBeenCalledWith('/habits')
expect(result).toEqual([{ id: 1, name: 'Exercise' }])
})
it('should create a habit', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1, name: 'Read' } })
await habitsApi.create({ name: 'Read' })
expect(api.post).toHaveBeenCalledWith('/habits', { name: 'Read' })
})
it('should update a habit', async () => {
api.put.mockResolvedValueOnce({ data: { id: 1, name: 'Updated' } })
await habitsApi.update(1, { name: 'Updated' })
expect(api.put).toHaveBeenCalledWith('/habits/1', { name: 'Updated' })
})
it('should log a habit', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1 } })
await habitsApi.log(5, { date: '2025-03-01' })
expect(api.post).toHaveBeenCalledWith('/habits/5/log', { date: '2025-03-01' })
})
it('should log habit with empty data', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1 } })
await habitsApi.log(5)
expect(api.post).toHaveBeenCalledWith('/habits/5/log', {})
})
it('should get logs with custom days', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await habitsApi.getLogs(5, 60)
expect(api.get).toHaveBeenCalledWith('/habits/5/logs?days=60')
})
it('should get overall stats', async () => {
api.get.mockResolvedValueOnce({ data: { total_habits: 5 } })
await habitsApi.getStats()
expect(api.get).toHaveBeenCalledWith('/habits/stats')
})
it('should get single habit stats', async () => {
api.get.mockResolvedValueOnce({ data: { habit_id: 3 } })
await habitsApi.getHabitStats(3)
expect(api.get).toHaveBeenCalledWith('/habits/3/stats')
})
it('should get freezes', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await habitsApi.getFreezes(3)
expect(api.get).toHaveBeenCalledWith('/habits/3/freezes')
})
it('should add freeze', async () => {
const freezeData = { start_date: '2025-03-01', end_date: '2025-03-07' }
api.post.mockResolvedValueOnce({ data: { id: 1 } })
await habitsApi.addFreeze(3, freezeData)
expect(api.post).toHaveBeenCalledWith('/habits/3/freezes', freezeData)
})
it('should delete freeze', async () => {
api.delete.mockResolvedValueOnce({})
await habitsApi.deleteFreeze(3, 10)
expect(api.delete).toHaveBeenCalledWith('/habits/3/freezes/10')
})
it('should delete a habit', async () => {
api.delete.mockResolvedValueOnce({})
await habitsApi.delete(5)
expect(api.delete).toHaveBeenCalledWith('/habits/5')
})
it('should delete a log', async () => {
api.delete.mockResolvedValueOnce({})
await habitsApi.deleteLog(3, 7)
expect(api.delete).toHaveBeenCalledWith('/habits/3/logs/7')
})
})
describe('savingsApi', () => {
beforeEach(() => vi.clearAllMocks())
it('should list categories', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await savingsApi.listCategories()
expect(api.get).toHaveBeenCalledWith('/savings/categories')
})
it('should get single category', async () => {
api.get.mockResolvedValueOnce({ data: { id: 1 } })
await savingsApi.getCategory(1)
expect(api.get).toHaveBeenCalledWith('/savings/categories/1')
})
it('should create category', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1 } })
await savingsApi.createCategory({ name: 'Savings' })
expect(api.post).toHaveBeenCalledWith('/savings/categories', { name: 'Savings' })
})
it('should update category', async () => {
api.put.mockResolvedValueOnce({ data: { id: 1 } })
await savingsApi.updateCategory(1, { name: 'Updated' })
expect(api.put).toHaveBeenCalledWith('/savings/categories/1', { name: 'Updated' })
})
it('should delete category', async () => {
api.delete.mockResolvedValueOnce({})
await savingsApi.deleteCategory(1)
expect(api.delete).toHaveBeenCalledWith('/savings/categories/1')
})
it('should create transaction', async () => {
const data = { category_id: 1, amount: 1000, type: 'deposit', date: '2025-03-01' }
api.post.mockResolvedValueOnce({ data: { id: 1 } })
await savingsApi.createTransaction(data)
expect(api.post).toHaveBeenCalledWith('/savings/transactions', data)
})
it('should list transactions with category filter', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await savingsApi.listTransactions(5, 50, 10)
expect(api.get).toHaveBeenCalledWith('/savings/transactions?limit=50&offset=10&category_id=5')
})
it('should list transactions without category filter', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await savingsApi.listTransactions(null)
expect(api.get).toHaveBeenCalledWith('/savings/transactions?limit=100&offset=0')
})
it('should get stats', async () => {
api.get.mockResolvedValueOnce({ data: { total_balance: 5000 } })
await savingsApi.getStats()
expect(api.get).toHaveBeenCalledWith('/savings/stats')
})
it('should get members', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await savingsApi.getMembers(1)
expect(api.get).toHaveBeenCalledWith('/savings/categories/1/members')
})
it('should add member', async () => {
api.post.mockResolvedValueOnce({ data: [] })
await savingsApi.addMember(1, 42)
expect(api.post).toHaveBeenCalledWith('/savings/categories/1/members', { user_id: 42 })
})
it('should get recurring plans', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await savingsApi.getRecurringPlans(1)
expect(api.get).toHaveBeenCalledWith('/savings/categories/1/recurring-plans')
})
})
describe('profileApi', () => {
beforeEach(() => vi.clearAllMocks())
it('should get profile', async () => {
api.get.mockResolvedValueOnce({ data: { username: 'test' } })
const result = await profileApi.get()
expect(api.get).toHaveBeenCalledWith('/profile')
expect(result.username).toBe('test')
})
it('should update profile', async () => {
api.put.mockResolvedValueOnce({ data: { username: 'updated' } })
const result = await profileApi.update({ username: 'updated' })
expect(api.put).toHaveBeenCalledWith('/profile', { username: 'updated' })
expect(result.username).toBe('updated')
})
})

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
},
}))
import api from '../api/client'
import { useAuthStore } from '../store/auth'
describe('useAuthStore', () => {
beforeEach(() => {
useAuthStore.setState({
user: null,
isLoading: true,
isAuthenticated: false,
})
localStorage.clear()
vi.clearAllMocks()
})
it('should have correct initial state', () => {
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.isLoading).toBe(true)
expect(state.isAuthenticated).toBe(false)
})
it('should login successfully', async () => {
const mockResponse = {
data: {
user: { id: 1, email: 'test@test.com', username: 'test' },
access_token: 'access-123',
refresh_token: 'refresh-123',
},
}
api.post.mockResolvedValueOnce(mockResponse)
await useAuthStore.getState().login('test@test.com', 'password')
expect(api.post).toHaveBeenCalledWith('/auth/login', {
email: 'test@test.com',
password: 'password',
})
expect(localStorage.getItem('access_token')).toBe('access-123')
expect(localStorage.getItem('refresh_token')).toBe('refresh-123')
expect(useAuthStore.getState().isAuthenticated).toBe(true)
expect(useAuthStore.getState().user).toEqual(mockResponse.data.user)
})
it('should register successfully', async () => {
const mockResponse = {
data: {
user: { id: 2, email: 'new@test.com', username: 'newuser' },
access_token: 'access-456',
refresh_token: 'refresh-456',
},
}
api.post.mockResolvedValueOnce(mockResponse)
await useAuthStore.getState().register('new@test.com', 'newuser', 'password123')
expect(api.post).toHaveBeenCalledWith('/auth/register', {
email: 'new@test.com',
username: 'newuser',
password: 'password123',
})
expect(useAuthStore.getState().isAuthenticated).toBe(true)
expect(localStorage.getItem('access_token')).toBe('access-456')
})
it('should logout and clear tokens', () => {
localStorage.setItem('access_token', 'old-token')
localStorage.setItem('refresh_token', 'old-refresh')
useAuthStore.setState({ user: { id: 1 }, isAuthenticated: true })
useAuthStore.getState().logout()
expect(localStorage.getItem('access_token')).toBeNull()
expect(localStorage.getItem('refresh_token')).toBeNull()
expect(useAuthStore.getState().user).toBeNull()
expect(useAuthStore.getState().isAuthenticated).toBe(false)
})
it('should initialize with no token', async () => {
await useAuthStore.getState().initialize()
expect(useAuthStore.getState().isLoading).toBe(false)
expect(useAuthStore.getState().isAuthenticated).toBe(false)
})
it('should initialize with valid token', async () => {
localStorage.setItem('access_token', 'valid-token')
api.get.mockResolvedValueOnce({
data: { id: 1, email: 'test@test.com', username: 'test' },
})
await useAuthStore.getState().initialize()
expect(api.get).toHaveBeenCalledWith('/auth/me')
expect(useAuthStore.getState().isAuthenticated).toBe(true)
expect(useAuthStore.getState().isLoading).toBe(false)
expect(useAuthStore.getState().user.email).toBe('test@test.com')
})
it('should handle invalid token on initialize', async () => {
localStorage.setItem('access_token', 'invalid-token')
api.get.mockRejectedValueOnce(new Error('401'))
await useAuthStore.getState().initialize()
expect(useAuthStore.getState().isAuthenticated).toBe(false)
expect(localStorage.getItem('access_token')).toBeNull()
expect(localStorage.getItem('refresh_token')).toBeNull()
})
it('should handle login error', async () => {
api.post.mockRejectedValueOnce(new Error('Invalid credentials'))
await expect(
useAuthStore.getState().login('bad@test.com', 'wrong')
).rejects.toThrow('Invalid credentials')
expect(useAuthStore.getState().isAuthenticated).toBe(false)
})
})

1
src/__tests__/setup.js Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

11
vitest.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/__tests__/setup.js',
},
})