Skip to content

Commit 20586f3

Browse files
authored
Merge pull request #816 from Chris0Jeky/test/frontend-store-integration
TST-44: Frontend store integration tests
2 parents 473f1b4 + 10fcb34 commit 20586f3

File tree

6 files changed

+629
-1
lines changed

6 files changed

+629
-1
lines changed
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* archiveApi integration tests — verifies the archive API module boundary.
3+
*
4+
* No archiveStore exists; the archiveApi is consumed directly by ArchiveView.
5+
* These tests exercise the archiveApi → http chain including error handling,
6+
* query parameter construction, and response shape validation.
7+
*/
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
import http from '../../api/http'
10+
import { archiveApi } from '../../api/archiveApi'
11+
import type { ArchiveItem, RestoreArchiveResult } from '../../types/archive'
12+
13+
vi.mock('../../api/http', () => ({
14+
default: {
15+
get: vi.fn(),
16+
post: vi.fn(),
17+
put: vi.fn(),
18+
patch: vi.fn(),
19+
delete: vi.fn(),
20+
},
21+
}))
22+
23+
function makeArchiveItem(overrides: Partial<ArchiveItem> = {}): ArchiveItem {
24+
return {
25+
id: 'arch-1',
26+
entityType: 'card',
27+
entityId: 'card-1',
28+
boardId: 'board-1',
29+
name: 'Archived Card',
30+
archivedByUserId: 'user-1',
31+
archivedAt: '2026-01-01T00:00:00Z',
32+
reason: null,
33+
restoreStatus: 'Available',
34+
restoredAt: null,
35+
restoredByUserId: null,
36+
createdAt: '2026-01-01T00:00:00Z',
37+
updatedAt: '2026-01-01T00:00:00Z',
38+
...overrides,
39+
}
40+
}
41+
42+
describe('archiveApi — integration (mocked HTTP)', () => {
43+
beforeEach(() => {
44+
vi.clearAllMocks()
45+
})
46+
47+
// ── getItems ──────────────────────────────────────────────────────────────
48+
49+
describe('getItems', () => {
50+
it('calls GET /archive/items and returns the response array', async () => {
51+
const items = [makeArchiveItem(), makeArchiveItem({ id: 'arch-2', name: 'Second' })]
52+
vi.mocked(http.get).mockResolvedValue({ data: items })
53+
54+
const result = await archiveApi.getItems()
55+
56+
expect(result).toHaveLength(2)
57+
expect(result[0].id).toBe('arch-1')
58+
expect(result[1].id).toBe('arch-2')
59+
expect(http.get).toHaveBeenCalledWith('/archive/items')
60+
})
61+
62+
it('appends entityType filter to the query string', async () => {
63+
vi.mocked(http.get).mockResolvedValue({ data: [] })
64+
65+
await archiveApi.getItems({ entityType: 'card' })
66+
67+
expect(http.get).toHaveBeenCalledWith(expect.stringContaining('entityType=card'))
68+
})
69+
70+
it('appends boardId filter to the query string', async () => {
71+
vi.mocked(http.get).mockResolvedValue({ data: [] })
72+
73+
await archiveApi.getItems({ boardId: 'board-xyz' })
74+
75+
expect(http.get).toHaveBeenCalledWith(expect.stringContaining('boardId=board-xyz'))
76+
})
77+
78+
it('appends status filter to the query string', async () => {
79+
vi.mocked(http.get).mockResolvedValue({ data: [] })
80+
81+
await archiveApi.getItems({ status: 'Available' })
82+
83+
expect(http.get).toHaveBeenCalledWith(expect.stringContaining('status=Available'))
84+
})
85+
86+
it('combines multiple filters in the query string', async () => {
87+
vi.mocked(http.get).mockResolvedValue({ data: [] })
88+
89+
await archiveApi.getItems({ entityType: 'card', boardId: 'board-1', limit: 50 })
90+
91+
expect(http.get).toHaveBeenCalledWith(expect.stringContaining('entityType=card'))
92+
expect(http.get).toHaveBeenCalledWith(expect.stringContaining('boardId=board-1'))
93+
expect(http.get).toHaveBeenCalledWith(expect.stringContaining('limit=50'))
94+
})
95+
96+
it('propagates errors from the HTTP layer', async () => {
97+
vi.mocked(http.get).mockRejectedValue(new Error('Network Error'))
98+
99+
await expect(archiveApi.getItems()).rejects.toThrow('Network Error')
100+
})
101+
})
102+
103+
// ── restoreItem ───────────────────────────────────────────────────────────
104+
105+
describe('restoreItem', () => {
106+
it('posts to /archive/:entityType/:entityId/restore and returns the result', async () => {
107+
const result: RestoreArchiveResult = {
108+
success: true,
109+
restoredEntityId: 'card-restored',
110+
errorMessage: null,
111+
resolvedName: 'My Card',
112+
}
113+
vi.mocked(http.post).mockResolvedValue({ data: result })
114+
115+
const response = await archiveApi.restoreItem('card', 'card-1', {
116+
targetBoardId: 'board-1',
117+
restoreMode: 0,
118+
conflictStrategy: 0,
119+
})
120+
121+
expect(response.success).toBe(true)
122+
expect(response.restoredEntityId).toBe('card-restored')
123+
expect(http.post).toHaveBeenCalledWith(
124+
'/archive/card/card-1/restore',
125+
expect.objectContaining({ targetBoardId: 'board-1' }),
126+
)
127+
})
128+
129+
it('URL-encodes special characters in entityType and entityId', async () => {
130+
vi.mocked(http.post).mockResolvedValue({
131+
data: { success: true, restoredEntityId: null, errorMessage: null, resolvedName: null },
132+
})
133+
134+
await archiveApi.restoreItem('card/type', 'id+special', {
135+
targetBoardId: null,
136+
restoreMode: 0,
137+
conflictStrategy: 0,
138+
})
139+
140+
expect(http.post).toHaveBeenCalledWith(
141+
expect.stringContaining('card%2Ftype'),
142+
expect.any(Object),
143+
)
144+
expect(http.post).toHaveBeenCalledWith(
145+
expect.stringContaining('id%2Bspecial'),
146+
expect.any(Object),
147+
)
148+
})
149+
150+
it('returns failure result when the backend rejects the restore', async () => {
151+
const failResult: RestoreArchiveResult = {
152+
success: false,
153+
restoredEntityId: null,
154+
errorMessage: 'Board no longer exists',
155+
resolvedName: null,
156+
}
157+
vi.mocked(http.post).mockResolvedValue({ data: failResult })
158+
159+
const response = await archiveApi.restoreItem('card', 'card-1', {
160+
targetBoardId: 'deleted-board',
161+
restoreMode: 0,
162+
conflictStrategy: 0,
163+
})
164+
165+
expect(response.success).toBe(false)
166+
expect(response.errorMessage).toBe('Board no longer exists')
167+
})
168+
169+
it('propagates HTTP errors from the restore endpoint', async () => {
170+
vi.mocked(http.post).mockRejectedValue({
171+
response: { status: 404, data: { message: 'Archive item not found' } },
172+
})
173+
174+
await expect(
175+
archiveApi.restoreItem('card', 'missing', {
176+
targetBoardId: null,
177+
restoreMode: 0,
178+
conflictStrategy: 0,
179+
}),
180+
).rejects.toBeDefined()
181+
})
182+
183+
it('propagates 409 Conflict when restoring with name collision', async () => {
184+
vi.mocked(http.post).mockRejectedValue({
185+
response: { status: 409, data: { message: 'Name conflict' } },
186+
})
187+
188+
await expect(
189+
archiveApi.restoreItem('card', 'card-1', {
190+
targetBoardId: 'board-1',
191+
restoreMode: 0,
192+
conflictStrategy: 0,
193+
}),
194+
).rejects.toBeDefined()
195+
})
196+
})
197+
})

frontend/taskdeck-web/src/tests/store/boardStore.integration.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,23 @@ describe('boardStore — integration (real API module, mocked HTTP)', () => {
297297

298298
expect(store.loading).toBe(false)
299299
})
300+
301+
it('retains original column and position when move API rejects with 409', async () => {
302+
const store = useBoardStore()
303+
const card = makeCardPayload({ id: 'card-snap', columnId: 'col-1', position: 0 })
304+
store.currentBoardCards = [card]
305+
306+
vi.mocked(http.post).mockRejectedValue({ response: { status: 409, data: { message: 'Stale position' } } })
307+
308+
await expect(store.moveCard('board-1', 'card-snap', 'col-2', 3)).rejects.toBeDefined()
309+
310+
// moveCard calls the API before updating local state (no optimistic mutation),
311+
// so on rejection the card is never modified — verify it remains intact
312+
const storedCard = store.currentBoardCards.find(c => c.id === 'card-snap')
313+
expect(storedCard).toBeDefined()
314+
expect(storedCard?.columnId).toBe('col-1')
315+
expect(storedCard?.position).toBe(0)
316+
})
300317
})
301318

302319
describe('updateCard', () => {
@@ -325,6 +342,97 @@ describe('boardStore — integration (real API module, mocked HTTP)', () => {
325342
})
326343
})
327344

345+
// ── deleteCard ─────────────────────────────────────────────────────────
346+
347+
describe('deleteCard', () => {
348+
it('calls DELETE /boards/:id/cards/:id and removes the card from local state', async () => {
349+
const store = useBoardStore()
350+
store.currentBoardCards = [
351+
makeCardPayload({ id: 'card-del', columnId: 'col-1' }),
352+
makeCardPayload({ id: 'card-keep', columnId: 'col-1' }),
353+
]
354+
355+
vi.mocked(http.delete).mockResolvedValue({ data: undefined })
356+
await store.deleteCard('board-1', 'card-del')
357+
358+
expect(store.currentBoardCards.some(c => c.id === 'card-del')).toBe(false)
359+
expect(store.currentBoardCards.some(c => c.id === 'card-keep')).toBe(true)
360+
expect(http.delete).toHaveBeenCalledWith(expect.stringContaining('/boards/board-1/cards/card-del'))
361+
})
362+
363+
it('does not remove a card when DELETE fails', async () => {
364+
const store = useBoardStore()
365+
store.currentBoardCards = [makeCardPayload({ id: 'card-fail' })]
366+
367+
vi.mocked(http.delete).mockRejectedValue({ response: { status: 500, data: { message: 'Server error' } } })
368+
await expect(store.deleteCard('board-1', 'card-fail')).rejects.toBeDefined()
369+
370+
expect(store.currentBoardCards.some(c => c.id === 'card-fail')).toBe(true)
371+
})
372+
})
373+
374+
// ── column CRUD ───────────────────────────────────────────────────────────
375+
376+
describe('createColumn', () => {
377+
it('posts to /boards/:id/columns and appends the returned column to currentBoard', async () => {
378+
const store = useBoardStore()
379+
store.currentBoard = {
380+
id: 'board-1',
381+
name: 'My Board',
382+
description: '',
383+
isArchived: false,
384+
createdAt: '2026-01-01T00:00:00Z',
385+
updatedAt: '2026-01-01T00:00:00Z',
386+
columns: [],
387+
}
388+
389+
const newColumn = { id: 'col-new', name: 'Done', position: 0, wipLimit: null, cardCount: 0 }
390+
vi.mocked(http.post).mockResolvedValue({ data: newColumn })
391+
392+
await store.createColumn('board-1', { name: 'Done', position: 0 })
393+
394+
expect(store.currentBoard?.columns).toHaveLength(1)
395+
expect(store.currentBoard?.columns[0].name).toBe('Done')
396+
expect(http.post).toHaveBeenCalledWith(
397+
expect.stringContaining('/boards/board-1/columns'),
398+
expect.objectContaining({ name: 'Done' }),
399+
)
400+
})
401+
})
402+
403+
describe('deleteColumn', () => {
404+
it('removes the column and its cards from local state', async () => {
405+
const store = useBoardStore()
406+
store.currentBoard = {
407+
id: 'board-1',
408+
name: 'My Board',
409+
description: '',
410+
isArchived: false,
411+
createdAt: '2026-01-01T00:00:00Z',
412+
updatedAt: '2026-01-01T00:00:00Z',
413+
columns: [
414+
{ id: 'col-1', name: 'Todo', position: 0, wipLimit: null, cardCount: 2 },
415+
{ id: 'col-2', name: 'Done', position: 1, wipLimit: null, cardCount: 0 },
416+
],
417+
}
418+
store.currentBoardCards = [
419+
makeCardPayload({ id: 'card-a', columnId: 'col-1' }),
420+
makeCardPayload({ id: 'card-b', columnId: 'col-1' }),
421+
makeCardPayload({ id: 'card-c', columnId: 'col-2' }),
422+
]
423+
424+
vi.mocked(http.delete).mockResolvedValue({ data: undefined })
425+
await store.deleteColumn('board-1', 'col-1')
426+
427+
// Column removed
428+
expect(store.currentBoard?.columns).toHaveLength(1)
429+
expect(store.currentBoard?.columns[0].id).toBe('col-2')
430+
// Cards in deleted column removed
431+
expect(store.currentBoardCards).toHaveLength(1)
432+
expect(store.currentBoardCards[0].id).toBe('card-c')
433+
})
434+
})
435+
328436
// ── cardsByColumn getter ───────────────────────────────────────────────────
329437

330438
describe('cardsByColumn getter', () => {

0 commit comments

Comments
 (0)