import { describe, it, expect, vi } from 'vitest'; import { fetchBoards, fetchBoardWithColumns, createBoard, updateBoard, deleteBoard, createColumn, updateColumn, deleteColumn, createCard, updateCard, deleteCard, moveCard, subscribeToBoard, } from './kanban'; // ── Supabase mock builder ──────────────────────────────────────────────────── function mockChain(resolvedValue: { data: any; error: any }) { const chain: any = {}; const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single']; for (const m of methods) { chain[m] = vi.fn(() => chain); } chain.single = vi.fn(() => Promise.resolve(resolvedValue)); chain.then = (resolve: any) => resolve(resolvedValue); return chain; } function mockSupabase(resolvedValue: { data: any; error: any }) { const chain = mockChain(resolvedValue); return { from: vi.fn(() => chain), _chain: chain } as any; } // ── Boards ─────────────────────────────────────────────────────────────────── describe('fetchBoards', () => { it('returns boards for an org', async () => { const boards = [{ id: 'b1', name: 'Board 1', org_id: 'o1' }]; const sb = mockSupabase({ data: boards, error: null }); expect(await fetchBoards(sb, 'o1')).toEqual(boards); }); it('returns empty array when null', async () => { const sb = mockSupabase({ data: null, error: null }); expect(await fetchBoards(sb, 'o1')).toEqual([]); }); it('throws on error', async () => { const sb = mockSupabase({ data: null, error: { message: 'fail' } }); await expect(fetchBoards(sb, 'o1')).rejects.toEqual({ message: 'fail' }); }); }); describe('fetchBoardWithColumns', () => { it('returns board with columns and cards', async () => { const board = { id: 'b1', name: 'Board', org_id: 'o1' }; const columns = [{ id: 'c1', board_id: 'b1', name: 'To Do', position: 0 }]; const cards = [{ id: 'k1', column_id: 'c1', title: 'Task', position: 0, assignee_id: null }]; const chain: any = {}; const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; for (const m of methods) { chain[m] = vi.fn(() => chain); } // Track calls: board.single, columns.order (thenable), cards.order (thenable), then tags/checklists/profiles let singleIdx = 0; chain.single = vi.fn(() => { singleIdx++; if (singleIdx === 1) return Promise.resolve({ data: board, error: null }); return Promise.resolve({ data: null, error: null }); }); let thenIdx = 0; chain.then = (resolve: any) => { thenIdx++; if (thenIdx === 1) return resolve({ data: columns, error: null }); // columns if (thenIdx === 2) return resolve({ data: cards, error: null }); // cards return resolve({ data: [], error: null }); // tags, checklists, profiles }; const sb = { from: vi.fn(() => chain) } as any; const result = await fetchBoardWithColumns(sb, 'b1'); expect(result).not.toBeNull(); expect(result!.id).toBe('b1'); expect(result!.columns).toHaveLength(1); expect(result!.columns[0].cards).toHaveLength(1); }); it('returns board with empty columns when no columns exist', async () => { const board = { id: 'b1', name: 'Board', org_id: 'o1' }; const chain: any = {}; const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; for (const m of methods) { chain[m] = vi.fn(() => chain); } let singleIdx = 0; chain.single = vi.fn(() => { singleIdx++; if (singleIdx === 1) return Promise.resolve({ data: board, error: null }); return Promise.resolve({ data: null, error: null }); }); chain.then = (resolve: any) => resolve({ data: [], error: null }); // empty columns const sb = { from: vi.fn(() => chain) } as any; const result = await fetchBoardWithColumns(sb, 'b1'); expect(result).not.toBeNull(); expect(result!.columns).toEqual([]); }); it('returns null when board not found', async () => { const chain: any = {}; const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; for (const m of methods) { chain[m] = vi.fn(() => chain); } chain.single = vi.fn(() => Promise.resolve({ data: null, error: null })); chain.then = (resolve: any) => resolve({ data: [], error: null }); const sb = { from: vi.fn(() => chain) } as any; const result = await fetchBoardWithColumns(sb, 'b1'); expect(result).toBeNull(); }); it('throws when board fetch fails', async () => { const chain: any = {}; const methods = ['from', 'select', 'eq', 'order', 'single', 'in']; for (const m of methods) { chain[m] = vi.fn(() => chain); } chain.single = vi.fn(() => { return Promise.resolve({ data: null, error: { message: 'board fail' } }); }); chain.then = (resolve: any) => resolve({ data: [], error: null }); const sb = { from: vi.fn(() => chain) } as any; await expect(fetchBoardWithColumns(sb, 'b1')).rejects.toEqual({ message: 'board fail' }); }); it('throws when columns fetch fails', async () => { const board = { id: 'b1', name: 'Board' }; const chain: any = {}; const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; for (const m of methods) { chain[m] = vi.fn(() => chain); } chain.single = vi.fn(() => Promise.resolve({ data: board, error: null })); chain.then = (resolve: any) => resolve({ data: null, error: { message: 'col fail' } }); const sb = { from: vi.fn(() => chain) } as any; await expect(fetchBoardWithColumns(sb, 'b1')).rejects.toEqual({ message: 'col fail' }); }); }); describe('subscribeToBoard', () => { it('sets up realtime subscription and returns channel', () => { const channel: any = {}; channel.on = vi.fn(() => channel); channel.subscribe = vi.fn(() => channel); const sb = { channel: vi.fn(() => channel) } as any; const result = subscribeToBoard(sb, 'b1', ['c1'], vi.fn(), vi.fn()); expect(sb.channel).toHaveBeenCalledWith('kanban:b1'); expect(channel.on).toHaveBeenCalledTimes(2); expect(channel.subscribe).toHaveBeenCalledOnce(); expect(result).toBe(channel); }); }); describe('createBoard', () => { it('creates board and default columns', async () => { const board = { id: 'b1', name: 'New Board', org_id: 'o1' }; const sb = mockSupabase({ data: board, error: null }); const result = await createBoard(sb, 'o1', 'New Board'); expect(result).toEqual(board); // Should have called from('kanban_columns') for default columns expect(sb.from).toHaveBeenCalledWith('kanban_columns'); }); it('throws on error', async () => { const sb = mockSupabase({ data: null, error: { message: 'fail' } }); await expect(createBoard(sb, 'o1', 'X')).rejects.toEqual({ message: 'fail' }); }); }); describe('updateBoard', () => { it('updates without error', async () => { const sb = mockSupabase({ data: null, error: null }); await expect(updateBoard(sb, 'b1', 'Renamed')).resolves.toBeUndefined(); }); it('throws on error', async () => { const sb = mockSupabase({ data: null, error: { message: 'fail' } }); await expect(updateBoard(sb, 'b1', 'X')).rejects.toEqual({ message: 'fail' }); }); }); describe('deleteBoard', () => { it('deletes without error', async () => { const sb = mockSupabase({ data: null, error: null }); await expect(deleteBoard(sb, 'b1')).resolves.toBeUndefined(); }); it('throws on error', async () => { const sb = mockSupabase({ data: null, error: { message: 'fail' } }); await expect(deleteBoard(sb, 'b1')).rejects.toEqual({ message: 'fail' }); }); }); // ── Columns ────────────────────────────────────────────────────────────────── describe('createColumn', () => { it('creates and returns column', async () => { const col = { id: 'c1', name: 'To Do', board_id: 'b1', position: 0 }; const sb = mockSupabase({ data: col, error: null }); expect(await createColumn(sb, 'b1', 'To Do', 0)).toEqual(col); }); it('throws on error', async () => { const sb = mockSupabase({ data: null, error: { message: 'fail' } }); await expect(createColumn(sb, 'b1', 'X', 0)).rejects.toEqual({ message: 'fail' }); }); }); describe('updateColumn', () => { it('updates without error', async () => { const sb = mockSupabase({ data: null, error: null }); await expect(updateColumn(sb, 'c1', { name: 'Done' })).resolves.toBeUndefined(); }); it('throws on error', async () => { const sb = mockSupabase({ data: null, error: { message: 'fail' } }); await expect(updateColumn(sb, 'c1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); }); }); describe('deleteColumn', () => { it('deletes without error', async () => { const sb = mockSupabase({ data: null, error: null }); await expect(deleteColumn(sb, 'c1')).resolves.toBeUndefined(); }); it('throws on error', async () => { const sb = mockSupabase({ data: null, error: { message: 'fail' } }); await expect(deleteColumn(sb, 'c1')).rejects.toEqual({ message: 'fail' }); }); }); // ── Cards ──────────────────────────────────────────────────────────────────── describe('createCard', () => { it('creates and returns card', async () => { const card = { id: 'k1', title: 'Task', column_id: 'c1', position: 0 }; const sb = mockSupabase({ data: card, error: null }); expect(await createCard(sb, 'c1', 'Task', 0, 'user1')).toEqual(card); }); it('throws on error', async () => { const sb = mockSupabase({ data: null, error: { message: 'fail' } }); await expect(createCard(sb, 'c1', 'X', 0, 'user1')).rejects.toEqual({ message: 'fail' }); }); }); describe('updateCard', () => { it('updates without error', async () => { const sb = mockSupabase({ data: null, error: null }); await expect(updateCard(sb, 'k1', { title: 'Updated' })).resolves.toBeUndefined(); }); it('throws on error', async () => { const sb = mockSupabase({ data: null, error: { message: 'fail' } }); await expect(updateCard(sb, 'k1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); }); }); describe('deleteCard', () => { it('deletes without error', async () => { const sb = mockSupabase({ data: null, error: null }); await expect(deleteCard(sb, 'k1')).resolves.toBeUndefined(); }); it('throws on error', async () => { const sb = mockSupabase({ data: null, error: { message: 'fail' } }); await expect(deleteCard(sb, 'k1')).rejects.toEqual({ message: 'fail' }); }); }); // ── moveCard ───────────────────────────────────────────────────────────────── describe('moveCard', () => { it('reorders cards in target column', async () => { const targetCards = [ { id: 'k1', position: 0 }, { id: 'k2', position: 1 }, ]; const chain: any = {}; const methods = ['from', 'select', 'update', 'eq', 'in', 'order', 'single']; for (const m of methods) { chain[m] = vi.fn(() => chain); } chain.then = (resolve: any) => resolve({ data: targetCards, error: null }); const sb = { from: vi.fn(() => chain) } as any; await expect(moveCard(sb, 'k3', 'col1', 1)).resolves.toBeUndefined(); }); it('throws when fetch fails', async () => { const chain: any = {}; const methods = ['from', 'select', 'eq', 'in', 'order']; for (const m of methods) { chain[m] = vi.fn(() => chain); } chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fetch fail' } }); const sb = { from: vi.fn(() => chain) } as any; await expect(moveCard(sb, 'k1', 'col1', 0)).rejects.toEqual({ message: 'fetch fail' }); }); });