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
197 changes: 197 additions & 0 deletions frontend/taskdeck-web/src/tests/store/archiveApi.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* archiveApi integration tests — verifies the archive API module boundary.
*
* No archiveStore exists; the archiveApi is consumed directly by ArchiveView.
* These tests exercise the archiveApi → http chain including error handling,
* query parameter construction, and response shape validation.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import http from '../../api/http'
import { archiveApi } from '../../api/archiveApi'
import type { ArchiveItem, RestoreArchiveResult } from '../../types/archive'

vi.mock('../../api/http', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}))

function makeArchiveItem(overrides: Partial<ArchiveItem> = {}): ArchiveItem {
return {
id: 'arch-1',
entityType: 'card',
entityId: 'card-1',
boardId: 'board-1',
name: 'Archived Card',
archivedByUserId: 'user-1',
archivedAt: '2026-01-01T00:00:00Z',
reason: null,
restoreStatus: 'Available',
restoredAt: null,
restoredByUserId: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
}
}

describe('archiveApi — integration (mocked HTTP)', () => {
beforeEach(() => {
vi.clearAllMocks()
})

// ── getItems ──────────────────────────────────────────────────────────────

describe('getItems', () => {
it('calls GET /archive/items and returns the response array', async () => {
const items = [makeArchiveItem(), makeArchiveItem({ id: 'arch-2', name: 'Second' })]
vi.mocked(http.get).mockResolvedValue({ data: items })

const result = await archiveApi.getItems()

expect(result).toHaveLength(2)
expect(result[0].id).toBe('arch-1')
expect(result[1].id).toBe('arch-2')
expect(http.get).toHaveBeenCalledWith('/archive/items')
})

it('appends entityType filter to the query string', async () => {
vi.mocked(http.get).mockResolvedValue({ data: [] })

await archiveApi.getItems({ entityType: 'card' })

expect(http.get).toHaveBeenCalledWith(expect.stringContaining('entityType=card'))
})

it('appends boardId filter to the query string', async () => {
vi.mocked(http.get).mockResolvedValue({ data: [] })

await archiveApi.getItems({ boardId: 'board-xyz' })

expect(http.get).toHaveBeenCalledWith(expect.stringContaining('boardId=board-xyz'))
})

it('appends status filter to the query string', async () => {
vi.mocked(http.get).mockResolvedValue({ data: [] })

await archiveApi.getItems({ status: 'Available' })

expect(http.get).toHaveBeenCalledWith(expect.stringContaining('status=Available'))
})

it('combines multiple filters in the query string', async () => {
vi.mocked(http.get).mockResolvedValue({ data: [] })

await archiveApi.getItems({ entityType: 'card', boardId: 'board-1', limit: 50 })

expect(http.get).toHaveBeenCalledWith(expect.stringContaining('entityType=card'))
expect(http.get).toHaveBeenCalledWith(expect.stringContaining('boardId=board-1'))
expect(http.get).toHaveBeenCalledWith(expect.stringContaining('limit=50'))
})

it('propagates errors from the HTTP layer', async () => {
vi.mocked(http.get).mockRejectedValue(new Error('Network Error'))

await expect(archiveApi.getItems()).rejects.toThrow('Network Error')
})
})

// ── restoreItem ───────────────────────────────────────────────────────────

describe('restoreItem', () => {
it('posts to /archive/:entityType/:entityId/restore and returns the result', async () => {
const result: RestoreArchiveResult = {
success: true,
restoredEntityId: 'card-restored',
errorMessage: null,
resolvedName: 'My Card',
}
vi.mocked(http.post).mockResolvedValue({ data: result })

const response = await archiveApi.restoreItem('card', 'card-1', {
targetBoardId: 'board-1',
restoreMode: 0,
conflictStrategy: 0,
})

expect(response.success).toBe(true)
expect(response.restoredEntityId).toBe('card-restored')
expect(http.post).toHaveBeenCalledWith(
'/archive/card/card-1/restore',
expect.objectContaining({ targetBoardId: 'board-1' }),
)
})

it('URL-encodes special characters in entityType and entityId', async () => {
vi.mocked(http.post).mockResolvedValue({
data: { success: true, restoredEntityId: null, errorMessage: null, resolvedName: null },
})

await archiveApi.restoreItem('card/type', 'id+special', {
targetBoardId: null,
restoreMode: 0,
conflictStrategy: 0,
})

expect(http.post).toHaveBeenCalledWith(
expect.stringContaining('card%2Ftype'),
expect.any(Object),
)
expect(http.post).toHaveBeenCalledWith(
expect.stringContaining('id%2Bspecial'),
expect.any(Object),
)
})

it('returns failure result when the backend rejects the restore', async () => {
const failResult: RestoreArchiveResult = {
success: false,
restoredEntityId: null,
errorMessage: 'Board no longer exists',
resolvedName: null,
}
vi.mocked(http.post).mockResolvedValue({ data: failResult })

const response = await archiveApi.restoreItem('card', 'card-1', {
targetBoardId: 'deleted-board',
restoreMode: 0,
conflictStrategy: 0,
})

expect(response.success).toBe(false)
expect(response.errorMessage).toBe('Board no longer exists')
})

it('propagates HTTP errors from the restore endpoint', async () => {
vi.mocked(http.post).mockRejectedValue({
response: { status: 404, data: { message: 'Archive item not found' } },
})

await expect(
archiveApi.restoreItem('card', 'missing', {
targetBoardId: null,
restoreMode: 0,
conflictStrategy: 0,
}),
).rejects.toBeDefined()
})

it('propagates 409 Conflict when restoring with name collision', async () => {
vi.mocked(http.post).mockRejectedValue({
response: { status: 409, data: { message: 'Name conflict' } },
})

await expect(
archiveApi.restoreItem('card', 'card-1', {
targetBoardId: 'board-1',
restoreMode: 0,
conflictStrategy: 0,
}),
).rejects.toBeDefined()
})
})
})
108 changes: 108 additions & 0 deletions frontend/taskdeck-web/src/tests/store/boardStore.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,23 @@ describe('boardStore — integration (real API module, mocked HTTP)', () => {

expect(store.loading).toBe(false)
})

it('retains original column and position when move API rejects with 409', async () => {
const store = useBoardStore()
const card = makeCardPayload({ id: 'card-snap', columnId: 'col-1', position: 0 })
store.currentBoardCards = [card]

vi.mocked(http.post).mockRejectedValue({ response: { status: 409, data: { message: 'Stale position' } } })

await expect(store.moveCard('board-1', 'card-snap', 'col-2', 3)).rejects.toBeDefined()

// moveCard calls the API before updating local state (no optimistic mutation),
// so on rejection the card is never modified — verify it remains intact
const storedCard = store.currentBoardCards.find(c => c.id === 'card-snap')
expect(storedCard).toBeDefined()
expect(storedCard?.columnId).toBe('col-1')
expect(storedCard?.position).toBe(0)
})
})

describe('updateCard', () => {
Expand Down Expand Up @@ -325,6 +342,97 @@ describe('boardStore — integration (real API module, mocked HTTP)', () => {
})
})

// ── deleteCard ─────────────────────────────────────────────────────────

describe('deleteCard', () => {
it('calls DELETE /boards/:id/cards/:id and removes the card from local state', async () => {
const store = useBoardStore()
store.currentBoardCards = [
makeCardPayload({ id: 'card-del', columnId: 'col-1' }),
makeCardPayload({ id: 'card-keep', columnId: 'col-1' }),
]

vi.mocked(http.delete).mockResolvedValue({ data: undefined })
await store.deleteCard('board-1', 'card-del')

expect(store.currentBoardCards.some(c => c.id === 'card-del')).toBe(false)
expect(store.currentBoardCards.some(c => c.id === 'card-keep')).toBe(true)
expect(http.delete).toHaveBeenCalledWith(expect.stringContaining('/boards/board-1/cards/card-del'))
})

it('does not remove a card when DELETE fails', async () => {
const store = useBoardStore()
store.currentBoardCards = [makeCardPayload({ id: 'card-fail' })]

vi.mocked(http.delete).mockRejectedValue({ response: { status: 500, data: { message: 'Server error' } } })
await expect(store.deleteCard('board-1', 'card-fail')).rejects.toBeDefined()

expect(store.currentBoardCards.some(c => c.id === 'card-fail')).toBe(true)
})
})

// ── column CRUD ───────────────────────────────────────────────────────────

describe('createColumn', () => {
it('posts to /boards/:id/columns and appends the returned column to currentBoard', async () => {
const store = useBoardStore()
store.currentBoard = {
id: 'board-1',
name: 'My Board',
description: '',
isArchived: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
columns: [],
}

const newColumn = { id: 'col-new', name: 'Done', position: 0, wipLimit: null, cardCount: 0 }
vi.mocked(http.post).mockResolvedValue({ data: newColumn })

await store.createColumn('board-1', { name: 'Done', position: 0 })

expect(store.currentBoard?.columns).toHaveLength(1)
expect(store.currentBoard?.columns[0].name).toBe('Done')
expect(http.post).toHaveBeenCalledWith(
expect.stringContaining('/boards/board-1/columns'),
expect.objectContaining({ name: 'Done' }),
)
})
})

describe('deleteColumn', () => {
it('removes the column and its cards from local state', async () => {
const store = useBoardStore()
store.currentBoard = {
id: 'board-1',
name: 'My Board',
description: '',
isArchived: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
columns: [
{ id: 'col-1', name: 'Todo', position: 0, wipLimit: null, cardCount: 2 },
{ id: 'col-2', name: 'Done', position: 1, wipLimit: null, cardCount: 0 },
],
}
store.currentBoardCards = [
makeCardPayload({ id: 'card-a', columnId: 'col-1' }),
makeCardPayload({ id: 'card-b', columnId: 'col-1' }),
makeCardPayload({ id: 'card-c', columnId: 'col-2' }),
]

vi.mocked(http.delete).mockResolvedValue({ data: undefined })
await store.deleteColumn('board-1', 'col-1')

// Column removed
expect(store.currentBoard?.columns).toHaveLength(1)
expect(store.currentBoard?.columns[0].id).toBe('col-2')
// Cards in deleted column removed
expect(store.currentBoardCards).toHaveLength(1)
expect(store.currentBoardCards[0].id).toBe('card-c')
})
})

// ── cardsByColumn getter ───────────────────────────────────────────────────

describe('cardsByColumn getter', () => {
Expand Down
Loading
Loading