feat: initial sketch with AI

This commit is contained in:
2026-08-21 17:09:32 +02:00
parent d3fe34cb22
commit 10aefdd655
40 changed files with 13491 additions and 459 deletions
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { loginApi, refreshApi, logoutApi, logoutAllApi, API_BASE_URL } from '../auth'
describe('auth API', () => {
const originalFetch = global.fetch
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
global.fetch = originalFetch
})
describe('loginApi', () => {
it('sends POST request to /login and returns token pair on success', async () => {
const mockResponse = {
access_token: 'access-123',
refresh_token: 'refresh-456',
}
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
} as unknown as Response)
const payload = { email: 'user@example.com', password: 'secretpassword' }
const result = await loginApi(payload)
expect(global.fetch).toHaveBeenCalledWith(`${API_BASE_URL}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
expect(result).toEqual(mockResponse)
})
it('throws error with message on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Invalid email or password' }),
} as unknown as Response)
await expect(
loginApi({ email: 'user@example.com', password: 'wrong' }),
).rejects.toThrow('Invalid email or password')
})
})
describe('refreshApi', () => {
it('sends POST request to /login/refresh and returns token pair on success', async () => {
const mockResponse = {
access_token: 'new-access-123',
refresh_token: 'new-refresh-456',
}
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
} as unknown as Response)
const payload = { refresh_token: 'refresh-456' }
const result = await refreshApi(payload)
expect(global.fetch).toHaveBeenCalledWith(`${API_BASE_URL}/login/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
expect(result).toEqual(mockResponse)
})
it('throws error on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized',
json: async () => {
throw new Error('Not JSON')
},
} as unknown as Response)
await expect(
refreshApi({ refresh_token: 'invalid-token' }),
).rejects.toThrow('Unauthorized')
})
})
describe('logoutApi', () => {
it('sends POST request to /logout on success', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
json: async () => null,
} as unknown as Response)
const payload = { refresh_token: 'refresh-456' }
await logoutApi(payload)
expect(global.fetch).toHaveBeenCalledWith(`${API_BASE_URL}/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
})
it('throws error on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
statusText: 'Bad Request',
json: async () => ({ error: 'failed to logout' }),
} as unknown as Response)
await expect(
logoutApi({ refresh_token: 'invalid-token' }),
).rejects.toThrow('failed to logout')
})
})
describe('logoutAllApi', () => {
it('sends POST request to /logout/all with token header on success', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
json: async () => null,
} as unknown as Response)
await logoutAllApi('access-123')
expect(global.fetch).toHaveBeenCalledWith(`${API_BASE_URL}/logout/all`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer access-123',
},
body: JSON.stringify({}),
})
})
it('throws error on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized',
json: async () => ({ message: 'Unauthorized' }),
} as unknown as Response)
await expect(logoutAllApi('bad-token')).rejects.toThrow('Unauthorized')
})
})
})
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { fetchWithAuth, apiClient } from '../client'
import { useAuthStore } from '@/stores/auth'
import * as authApi from '@/api/auth'
describe('api client (fetchWithAuth)', () => {
const originalFetch = global.fetch
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.restoreAllMocks()
})
afterEach(() => {
global.fetch = originalFetch
localStorage.clear()
})
it('adds Authorization header if accessToken is available in store', async () => {
const authStore = useAuthStore()
authStore.setTokens({
access_token: 'test-token',
refresh_token: 'refresh-token',
})
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ success: true }),
} as unknown as Response)
global.fetch = fetchMock
await fetchWithAuth('/lists')
expect(fetchMock).toHaveBeenCalled()
const callArgs = fetchMock.mock.calls[0]
expect(callArgs).toBeDefined()
const headers = callArgs?.[1]?.headers as Headers
expect(headers.get('Authorization')).toBe('Bearer test-token')
})
it('omits Authorization header when skipAuth is true', async () => {
const authStore = useAuthStore()
authStore.setTokens({
access_token: 'test-token',
refresh_token: 'refresh-token',
})
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
} as unknown as Response)
global.fetch = fetchMock
await fetchWithAuth('/health', { skipAuth: true })
const callArgs = fetchMock.mock.calls[0]
expect(callArgs).toBeDefined()
const headers = callArgs?.[1]?.headers as Headers
expect(headers.has('Authorization')).toBe(false)
})
it('refreshes token and retries request on 401 response', async () => {
const authStore = useAuthStore()
authStore.setTokens({
access_token: 'expired-token',
refresh_token: 'valid-refresh-token',
})
const refreshSpy = vi.spyOn(authApi, 'refreshApi').mockResolvedValueOnce({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
})
const firstResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
}
const secondResponse = {
ok: true,
status: 200,
json: async () => ({ data: 'protected data' }),
}
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(firstResponse as unknown as Response)
.mockResolvedValueOnce(secondResponse as unknown as Response)
global.fetch = fetchMock
const res = await fetchWithAuth('/lists')
expect(refreshSpy).toHaveBeenCalledWith({ refresh_token: 'valid-refresh-token' })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(authStore.accessToken).toBe('new-access-token')
// Second call should have new access token
const secondCall = fetchMock.mock.calls[1]
expect(secondCall).toBeDefined()
const secondCallHeaders = secondCall?.[1]?.headers as Headers
expect(secondCallHeaders.get('Authorization')).toBe('Bearer new-access-token')
expect(res).toBe(secondResponse)
})
it('calls apiClient helper methods correctly', async () => {
const authStore = useAuthStore()
authStore.setTokens({
access_token: 'my-token',
refresh_token: 'my-refresh',
})
const mockResponse = {
ok: true,
status: 200,
}
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(mockResponse as unknown as Response)
global.fetch = fetchMock
await apiClient.get('/lists')
expect(fetchMock).toHaveBeenLastCalledWith(
expect.stringContaining('/lists'),
expect.objectContaining({ method: 'GET' }),
)
await apiClient.post('/lists', { name: 'My List' })
expect(fetchMock).toHaveBeenLastCalledWith(
expect.stringContaining('/lists'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'My List' }),
}),
)
await apiClient.put('/lists/item', { title: 'Updated' })
expect(fetchMock).toHaveBeenLastCalledWith(
expect.stringContaining('/lists/item'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ title: 'Updated' }),
}),
)
await apiClient.delete('/lists/user')
expect(fetchMock).toHaveBeenLastCalledWith(
expect.stringContaining('/lists/user'),
expect.objectContaining({ method: 'DELETE' }),
)
})
})
+182
View File
@@ -0,0 +1,182 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import {
getListsApi,
createListApi,
getListItemsApi,
createListItemApi,
updateListItemApi,
setListItemCompletedApi,
addUserToListApi,
removeUserFromListApi,
} from '../lists'
import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/auth'
describe('lists API', () => {
const originalFetch = global.fetch
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.restoreAllMocks()
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'token-123', refresh_token: 'refresh-123' })
})
afterEach(() => {
global.fetch = originalFetch
localStorage.clear()
})
it('getListsApi sends GET to /lists and returns the lists', async () => {
const mockLists = [{ id: 'list-1', name: 'Groceries' }]
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockLists,
} as unknown as Response)
global.fetch = fetchMock
const result = await getListsApi()
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists`,
expect.objectContaining({ method: 'GET' }),
)
expect(result).toEqual(mockLists)
})
it('getListsApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed' }),
} as unknown as Response)
await expect(getListsApi()).rejects.toThrow('Failed')
})
it('getListItemsApi sends GET to /lists/{id} and returns the items', async () => {
const mockItems = [{ id: 'item-1', list_id: 'list-1', title: 'Milk', is_completed: false }]
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockItems,
} as unknown as Response)
global.fetch = fetchMock
const result = await getListItemsApi('list-1')
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/list-1`,
expect.objectContaining({ method: 'GET' }),
)
expect(result).toEqual(mockItems)
})
it('setListItemCompletedApi sends POST to /lists/items/{id}', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await setListItemCompletedApi('item-1', { is_completed: true })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/items/item-1`,
expect.objectContaining({ method: 'POST' }),
)
})
it('createListApi sends POST to /lists and returns the created list', async () => {
const mockList = { id: 'list-1', name: 'Groceries' }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => mockList,
} as unknown as Response)
global.fetch = fetchMock
const result = await createListApi({ name: 'Groceries', user_ids: [] })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists`,
expect.objectContaining({ method: 'POST' }),
)
expect(result).toEqual(mockList)
})
it('createListApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed' }),
} as unknown as Response)
await expect(createListApi({ name: 'x' })).rejects.toThrow('Failed')
})
it('createListItemApi sends POST to /lists/item and returns the created item with its server id', async () => {
const mockItem = { id: 'item-1', list_id: '', title: 'Milk', is_completed: false }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => mockItem,
} as unknown as Response)
global.fetch = fetchMock
const result = await createListItemApi({ list_id: 'list-1', title: 'Milk' })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/item`,
expect.objectContaining({ method: 'POST' }),
)
expect(result).toEqual(mockItem)
})
it('updateListItemApi sends PUT to /lists/item', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await updateListItemApi({ list_item_id: 'item-1', is_completed: true })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/item`,
expect.objectContaining({ method: 'PUT' }),
)
})
it('addUserToListApi sends POST to /lists/user', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await addUserToListApi({ list_id: 'list-1', user_id: 'user-1' })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/user`,
expect.objectContaining({ method: 'POST' }),
)
})
it('removeUserFromListApi sends DELETE to /lists/user', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await removeUserFromListApi({ list_id: 'list-1', user_id: 'user-1' })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/user`,
expect.objectContaining({ method: 'DELETE' }),
)
})
})