Files
root-org/src/lib/api/kanban.test.ts

327 lines
12 KiB
TypeScript

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' });
});
});