feat: map shapes, image persistence, grab tool, layer rename/delete, i18n, page metadata

This commit is contained in:
AlacrisDevs
2026-02-08 23:11:09 +02:00
parent 75a2aefadb
commit f2384bceb8
125 changed files with 22605 additions and 3902 deletions

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from 'vitest';
import { logActivity } from './activity';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'insert'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
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;
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('logActivity', () => {
it('inserts activity log entry', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(
logActivity(sb, {
orgId: 'org1',
userId: 'user1',
action: 'create',
entityType: 'document',
entityId: 'doc1',
entityName: 'Test Doc',
})
).resolves.toBeUndefined();
expect(sb.from).toHaveBeenCalledWith('activity_log');
});
it('does not throw on error (fire-and-forget)', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
// logActivity should warn but not throw
await expect(
logActivity(sb, {
orgId: 'org1',
userId: 'user1',
action: 'delete',
entityType: 'folder',
})
).resolves.toBeUndefined();
});
it('passes metadata as JSON', async () => {
const sb = mockSupabase({ data: null, error: null });
await logActivity(sb, {
orgId: 'org1',
userId: 'user1',
action: 'move',
entityType: 'kanban_card',
metadata: { from: 'col1', to: 'col2' },
});
expect(sb.from).toHaveBeenCalledWith('activity_log');
});
});

View File

@@ -32,7 +32,7 @@ export async function logActivity(
});
if (error) {
// Activity logging should never block the main action just warn
// Activity logging should never block the main action - just warn
log.warn('Failed to log activity', { error: { message: error.message } });
}
}

190
src/lib/api/budget.test.ts Normal file
View File

@@ -0,0 +1,190 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchBudgetCategories,
createBudgetCategory,
updateBudgetCategory,
deleteBudgetCategory,
fetchEventBudgetCategories,
fetchEventBudgetItems,
fetchBudgetItems,
createBudgetItem,
updateBudgetItem,
deleteBudgetItem,
} from './budget';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single', 'in'];
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;
}
// ── Budget Categories ────────────────────────────────────────────────────────
describe('fetchBudgetCategories', () => {
it('returns categories for a department', async () => {
const cats = [{ id: 'c1', name: 'Travel', department_id: 'd1' }];
const sb = mockSupabase({ data: cats, error: null });
const result = await fetchBudgetCategories(sb, 'd1');
expect(result).toEqual(cats);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchBudgetCategories(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createBudgetCategory', () => {
it('creates a category with default color', async () => {
const cat = { id: 'c1', name: 'Food', department_id: 'd1', color: '#6366f1' };
const sb = mockSupabase({ data: cat, error: null });
const result = await createBudgetCategory(sb, 'd1', 'Food');
expect(result).toEqual(cat);
});
it('creates a category with custom color', async () => {
const cat = { id: 'c2', name: 'AV', department_id: 'd1', color: '#ff0000' };
const sb = mockSupabase({ data: cat, error: null });
const result = await createBudgetCategory(sb, 'd1', 'AV', '#ff0000');
expect(result).toEqual(cat);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'dup' } });
await expect(createBudgetCategory(sb, 'd1', 'X')).rejects.toEqual({ message: 'dup' });
});
});
describe('updateBudgetCategory', () => {
it('updates and returns the category', async () => {
const cat = { id: 'c1', name: 'Updated' };
const sb = mockSupabase({ data: cat, error: null });
const result = await updateBudgetCategory(sb, 'c1', { name: 'Updated' });
expect(result).toEqual(cat);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'nope' } });
await expect(updateBudgetCategory(sb, 'c1', { name: 'X' })).rejects.toEqual({ message: 'nope' });
});
});
describe('deleteBudgetCategory', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteBudgetCategory(sb, 'c1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'del fail' } });
await expect(deleteBudgetCategory(sb, 'c1')).rejects.toEqual({ message: 'del fail' });
});
});
describe('fetchEventBudgetCategories', () => {
it('returns categories stripped of join data', async () => {
const raw = [{ id: 'c1', name: 'Cat', event_departments: { event_id: 'e1' } }];
const sb = mockSupabase({ data: raw, error: null });
const result = await fetchEventBudgetCategories(sb, 'e1');
expect(result).toEqual([{ id: 'c1', name: 'Cat' }]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEventBudgetCategories(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
describe('fetchEventBudgetItems', () => {
it('returns items stripped of join data', async () => {
const raw = [{ id: 'i1', description: 'Item', event_departments: { event_id: 'e1' } }];
const sb = mockSupabase({ data: raw, error: null });
const result = await fetchEventBudgetItems(sb, 'e1');
expect(result).toEqual([{ id: 'i1', description: 'Item' }]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEventBudgetItems(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Budget Items ─────────────────────────────────────────────────────────────
describe('fetchBudgetItems', () => {
it('returns items for a department', async () => {
const items = [{ id: 'i1', description: 'Mic', department_id: 'd1' }];
const sb = mockSupabase({ data: items, error: null });
const result = await fetchBudgetItems(sb, 'd1');
expect(result).toEqual(items);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchBudgetItems(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createBudgetItem', () => {
it('creates an expense item with defaults', async () => {
const item = { id: 'i1', description: 'Mic', item_type: 'expense', planned_amount: 0, actual_amount: 0 };
const sb = mockSupabase({ data: item, error: null });
const result = await createBudgetItem(sb, 'd1', { description: 'Mic', item_type: 'expense' });
expect(result).toEqual(item);
});
it('creates an income item with amounts', async () => {
const item = { id: 'i2', description: 'Ticket', item_type: 'income', planned_amount: 100, actual_amount: 50 };
const sb = mockSupabase({ data: item, error: null });
const result = await createBudgetItem(sb, 'd1', {
description: 'Ticket',
item_type: 'income',
planned_amount: 100,
actual_amount: 50,
});
expect(result).toEqual(item);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createBudgetItem(sb, 'd1', { description: 'X', item_type: 'expense' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateBudgetItem', () => {
it('updates and returns the item', async () => {
const item = { id: 'i1', description: 'Updated' };
const sb = mockSupabase({ data: item, error: null });
const result = await updateBudgetItem(sb, 'i1', { description: 'Updated' });
expect(result).toEqual(item);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateBudgetItem(sb, 'i1', { description: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteBudgetItem', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteBudgetItem(sb, 'i1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteBudgetItem(sb, 'i1')).rejects.toEqual({ message: 'fail' });
});
});

View File

@@ -87,6 +87,52 @@ export async function deleteBudgetCategory(
}
}
/**
* Fetch all budget categories for all departments in an event.
*/
export async function fetchEventBudgetCategories(
supabase: SupabaseClient,
eventId: string
): Promise<BudgetCategory[]> {
const { data, error } = await db(supabase)
.from('budget_categories')
.select('*, event_departments!inner(event_id)')
.eq('event_departments.event_id', eventId)
.order('sort_order');
if (error) {
log.error('fetchEventBudgetCategories failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { event_departments, ...cat } = d;
return cat;
}) as BudgetCategory[];
}
/**
* Fetch all budget items for all departments in an event.
*/
export async function fetchEventBudgetItems(
supabase: SupabaseClient,
eventId: string
): Promise<BudgetItem[]> {
const { data, error } = await db(supabase)
.from('budget_items')
.select('*, event_departments!inner(event_id)')
.eq('event_departments.event_id', eventId)
.order('sort_order');
if (error) {
log.error('fetchEventBudgetItems failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { event_departments, ...item } = d;
return item;
}) as BudgetItem[];
}
// ============================================================
// Budget Items
// ============================================================
@@ -144,7 +190,7 @@ export async function createBudgetItem(
export async function updateBudgetItem(
supabase: SupabaseClient,
itemId: string,
params: Partial<Pick<BudgetItem, 'description' | 'item_type' | 'planned_amount' | 'actual_amount' | 'category_id' | 'notes' | 'sort_order'>>
params: Partial<Pick<BudgetItem, 'description' | 'item_type' | 'planned_amount' | 'actual_amount' | 'category_id' | 'notes' | 'receipt_document_id' | 'sort_order'>>
): Promise<BudgetItem> {
const { data, error } = await db(supabase)
.from('budget_items')

View File

@@ -1,5 +1,90 @@
import { describe, it, expect } from 'vitest';
import { getMonthDays, isSameDay, formatTime } from './calendar';
import { describe, it, expect, vi } from 'vitest';
import { getMonthDays, isSameDay, formatTime, fetchEvents, createEvent, updateEvent, deleteEvent } from './calendar';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'gte', 'lte', 'order', 'single', 'channel', 'on', 'subscribe'];
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;
}
// ── Calendar CRUD ────────────────────────────────────────────────────────────
describe('calendar fetchEvents', () => {
it('returns events for date range', async () => {
const events = [{ id: 'ce1', title: 'Meeting', org_id: 'o1' }];
const sb = mockSupabase({ data: events, error: null });
const result = await fetchEvents(sb, 'o1', new Date('2024-01-01'), new Date('2024-01-31'));
expect(result).toEqual(events);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchEvents(sb, 'o1', new Date(), new Date())).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEvents(sb, 'o1', new Date(), new Date())).rejects.toEqual({ message: 'fail' });
});
});
describe('calendar createEvent', () => {
it('creates and returns event', async () => {
const event = { id: 'ce1', title: 'New Meeting' };
const sb = mockSupabase({ data: event, error: null });
const result = await createEvent(sb, 'o1', {
title: 'New Meeting',
start_time: '2024-01-15T10:00:00Z',
end_time: '2024-01-15T11:00:00Z',
}, 'user1');
expect(result).toEqual(event);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createEvent(sb, 'o1', {
title: 'X',
start_time: '2024-01-15T10:00:00Z',
end_time: '2024-01-15T11:00:00Z',
}, 'user1')).rejects.toEqual({ message: 'fail' });
});
});
describe('calendar updateEvent', () => {
it('updates without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(updateEvent(sb, 'ce1', { title: 'Updated' })).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateEvent(sb, 'ce1', { title: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('calendar deleteEvent', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteEvent(sb, 'ce1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteEvent(sb, 'ce1')).rejects.toEqual({ message: 'fail' });
});
});
describe('getMonthDays', () => {
it('returns exactly 42 days (6 weeks grid)', () => {

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchContacts,
createContact,
updateContact,
deleteContact,
CONTACT_CATEGORIES,
CATEGORY_LABELS,
CATEGORY_ICONS,
} from './contacts';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', '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;
}
// ── Constants ────────────────────────────────────────────────────────────────
describe('contacts constants', () => {
it('CONTACT_CATEGORIES has expected entries', () => {
expect(CONTACT_CATEGORIES).toContain('general');
expect(CONTACT_CATEGORIES).toContain('vendor');
expect(CONTACT_CATEGORIES).toContain('speaker');
expect(CONTACT_CATEGORIES).toContain('media');
expect(CONTACT_CATEGORIES.length).toBe(10);
});
it('CATEGORY_LABELS has a label for every category', () => {
for (const cat of CONTACT_CATEGORIES) {
expect(CATEGORY_LABELS[cat]).toBeDefined();
expect(typeof CATEGORY_LABELS[cat]).toBe('string');
}
});
it('CATEGORY_ICONS has an icon for every category', () => {
for (const cat of CONTACT_CATEGORIES) {
expect(CATEGORY_ICONS[cat]).toBeDefined();
}
});
});
// ── CRUD ─────────────────────────────────────────────────────────────────────
describe('fetchContacts', () => {
it('returns contacts for a department', async () => {
const contacts = [{ id: 'ct1', name: 'Alice', department_id: 'd1' }];
const sb = mockSupabase({ data: contacts, error: null });
const result = await fetchContacts(sb, 'd1');
expect(result).toEqual(contacts);
});
it('returns empty array when data is null', async () => {
const sb = mockSupabase({ data: null, error: null });
const result = await fetchContacts(sb, 'd1');
expect(result).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchContacts(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createContact', () => {
it('creates a contact with minimal params', async () => {
const contact = { id: 'ct1', name: 'Bob', department_id: 'd1', category: 'general' };
const sb = mockSupabase({ data: contact, error: null });
const result = await createContact(sb, 'd1', { name: 'Bob' });
expect(result).toEqual(contact);
});
it('creates a contact with all params', async () => {
const contact = { id: 'ct2', name: 'Eve', department_id: 'd1', category: 'vendor', email: 'eve@test.com' };
const sb = mockSupabase({ data: contact, error: null });
const result = await createContact(sb, 'd1', {
name: 'Eve',
category: 'vendor',
email: 'eve@test.com',
phone: '+1234',
company: 'Acme',
role: 'Manager',
website: 'https://acme.com',
notes: 'VIP',
}, 'user1');
expect(result).toEqual(contact);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createContact(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateContact', () => {
it('updates and returns the contact', async () => {
const contact = { id: 'ct1', name: 'Updated' };
const sb = mockSupabase({ data: contact, error: null });
const result = await updateContact(sb, 'ct1', { name: 'Updated' });
expect(result).toEqual(contact);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateContact(sb, 'ct1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteContact', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteContact(sb, 'ct1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteContact(sb, 'ct1')).rejects.toEqual({ message: 'fail' });
});
});

View File

@@ -0,0 +1,311 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchDashboard,
updateDashboardLayout,
addPanel,
updatePanel,
removePanel,
fetchChecklists,
createChecklist,
deleteChecklist,
renameChecklist,
addChecklistItem,
updateChecklistItem,
deleteChecklistItem,
toggleChecklistItem,
fetchNotes,
createNote,
updateNote,
deleteNote,
} from './department-dashboard';
// ── 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;
}
// ── Dashboard ────────────────────────────────────────────────────────────────
describe('fetchDashboard', () => {
it('returns dashboard with sorted panels', async () => {
const dash = {
id: 'd1',
department_id: 'dept1',
panels: [
{ id: 'p2', position: 1 },
{ id: 'p1', position: 0 },
],
};
const sb = mockSupabase({ data: dash, error: null });
const result = await fetchDashboard(sb, 'dept1');
expect(result).not.toBeNull();
expect(result!.panels[0].id).toBe('p1');
expect(result!.panels[1].id).toBe('p2');
});
it('returns null when not found (PGRST116)', async () => {
const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } });
const result = await fetchDashboard(sb, 'dept1');
expect(result).toBeNull();
});
it('throws on other errors', async () => {
const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } });
await expect(fetchDashboard(sb, 'dept1')).rejects.toEqual({ code: '42000', message: 'fail' });
});
});
describe('updateDashboardLayout', () => {
it('updates and returns dashboard', async () => {
const dash = { id: 'd1', layout: 'grid' };
const sb = mockSupabase({ data: dash, error: null });
expect(await updateDashboardLayout(sb, 'd1', 'grid' as any)).toEqual(dash);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateDashboardLayout(sb, 'd1', 'grid' as any)).rejects.toEqual({ message: 'fail' });
});
});
// ── Panels ───────────────────────────────────────────────────────────────────
describe('addPanel', () => {
it('adds and returns panel', async () => {
const panel = { id: 'p1', dashboard_id: 'd1', module: 'checklist', position: 0 };
const sb = mockSupabase({ data: panel, error: null });
expect(await addPanel(sb, 'd1', 'checklist' as any, 0)).toEqual(panel);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(addPanel(sb, 'd1', 'checklist' as any, 0)).rejects.toEqual({ message: 'fail' });
});
});
describe('updatePanel', () => {
it('updates and returns panel', async () => {
const panel = { id: 'p1', width: 'full' };
const sb = mockSupabase({ data: panel, error: null });
expect(await updatePanel(sb, 'p1', { width: 'full' } as any)).toEqual(panel);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updatePanel(sb, 'p1', { width: 'full' } as any)).rejects.toEqual({ message: 'fail' });
});
});
describe('removePanel', () => {
it('removes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(removePanel(sb, 'p1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(removePanel(sb, 'p1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Checklists ───────────────────────────────────────────────────────────────
describe('fetchChecklists', () => {
it('returns checklists with items grouped', async () => {
const checklists = [{ id: 'cl1', department_id: 'dept1', title: 'Pre-event' }];
const items = [{ id: 'i1', checklist_id: 'cl1', content: 'Book venue', is_completed: false }];
const chain: any = {};
const methods = ['from', 'select', 'eq', 'in', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
if (callIdx === 1) return resolve({ data: checklists, error: null });
return resolve({ data: items, error: null });
};
const sb = { from: vi.fn(() => chain) } as any;
const result = await fetchChecklists(sb, 'dept1');
expect(result).toHaveLength(1);
expect(result[0].items).toHaveLength(1);
expect(result[0].items[0].content).toBe('Book venue');
});
it('returns empty array when no checklists', async () => {
const sb = mockSupabase({ data: [], error: null });
const result = await fetchChecklists(sb, 'dept1');
expect(result).toEqual([]);
});
it('throws on error', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(fetchChecklists(sb, 'dept1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createChecklist', () => {
it('creates and returns checklist', async () => {
const cl = { id: 'cl1', title: 'Setup', department_id: 'dept1' };
const sb = mockSupabase({ data: cl, error: null });
expect(await createChecklist(sb, 'dept1', 'Setup', 'user1')).toEqual(cl);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createChecklist(sb, 'dept1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteChecklist', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteChecklist(sb, 'cl1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteChecklist(sb, 'cl1')).rejects.toEqual({ message: 'fail' });
});
});
describe('renameChecklist', () => {
it('renames and returns checklist', async () => {
const cl = { id: 'cl1', title: 'Renamed' };
const sb = mockSupabase({ data: cl, error: null });
expect(await renameChecklist(sb, 'cl1', 'Renamed')).toEqual(cl);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(renameChecklist(sb, 'cl1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
// ── Checklist Items ──────────────────────────────────────────────────────────
describe('addChecklistItem', () => {
it('adds and returns item', async () => {
const item = { id: 'i1', checklist_id: 'cl1', content: 'Task', sort_order: 0 };
const sb = mockSupabase({ data: item, error: null });
expect(await addChecklistItem(sb, 'cl1', 'Task')).toEqual(item);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(addChecklistItem(sb, 'cl1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateChecklistItem', () => {
it('updates and returns item', async () => {
const item = { id: 'i1', content: 'Updated' };
const sb = mockSupabase({ data: item, error: null });
expect(await updateChecklistItem(sb, 'i1', { content: 'Updated' })).toEqual(item);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateChecklistItem(sb, 'i1', { content: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteChecklistItem', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteChecklistItem(sb, 'i1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteChecklistItem(sb, 'i1')).rejects.toEqual({ message: 'fail' });
});
});
describe('toggleChecklistItem', () => {
it('toggles completion via updateChecklistItem', async () => {
const item = { id: 'i1', is_completed: true };
const sb = mockSupabase({ data: item, error: null });
expect(await toggleChecklistItem(sb, 'i1', true)).toEqual(item);
});
});
// ── Notes ────────────────────────────────────────────────────────────────────
describe('fetchNotes', () => {
it('returns notes for a department', async () => {
const notes = [{ id: 'n1', title: 'Meeting Notes', department_id: 'dept1' }];
const sb = mockSupabase({ data: notes, error: null });
expect(await fetchNotes(sb, 'dept1')).toEqual(notes);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchNotes(sb, 'dept1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchNotes(sb, 'dept1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createNote', () => {
it('creates and returns note', async () => {
const note = { id: 'n1', title: 'New Note', department_id: 'dept1' };
const sb = mockSupabase({ data: note, error: null });
expect(await createNote(sb, 'dept1', 'New Note', 'user1')).toEqual(note);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createNote(sb, 'dept1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateNote', () => {
it('updates and returns note', async () => {
const note = { id: 'n1', title: 'Updated' };
const sb = mockSupabase({ data: note, error: null });
expect(await updateNote(sb, 'n1', { title: 'Updated' })).toEqual(note);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateNote(sb, 'n1', { title: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteNote', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteNote(sb, 'n1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteNote(sb, 'n1')).rejects.toEqual({ message: 'fail' });
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi } from 'vitest';
import {
getLockInfo,
acquireLock,
heartbeatLock,
releaseLock,
startHeartbeat,
} from './document-locks';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'gt', 'lt', '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;
}
// ── getLockInfo ──────────────────────────────────────────────────────────────
describe('getLockInfo', () => {
it('returns unlocked when no lock exists', async () => {
const sb = mockSupabase({ data: null, error: null });
const info = await getLockInfo(sb, 'doc1', 'user1');
expect(info).toEqual({
isLocked: false,
lockedBy: null,
lockedByName: null,
isOwnLock: false,
});
});
it('returns locked with own lock', async () => {
// First call returns lock, second returns profile
const lockData = { id: 'l1', document_id: 'doc1', user_id: 'user1', locked_at: new Date().toISOString(), last_heartbeat: new Date().toISOString() };
const profileData = { full_name: 'Alice', email: 'alice@test.com' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'gt', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callCount = 0;
chain.single = vi.fn(() => {
callCount++;
if (callCount === 1) return Promise.resolve({ data: lockData, error: null });
return Promise.resolve({ data: profileData, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const info = await getLockInfo(sb, 'doc1', 'user1');
expect(info.isLocked).toBe(true);
expect(info.isOwnLock).toBe(true);
expect(info.lockedByName).toBe('Alice');
});
it('returns locked with other user lock', async () => {
const lockData = { id: 'l1', document_id: 'doc1', user_id: 'user2', locked_at: new Date().toISOString(), last_heartbeat: new Date().toISOString() };
const profileData = { full_name: 'Bob', email: 'bob@test.com' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'gt', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callCount = 0;
chain.single = vi.fn(() => {
callCount++;
if (callCount === 1) return Promise.resolve({ data: lockData, error: null });
return Promise.resolve({ data: profileData, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const info = await getLockInfo(sb, 'doc1', 'user1');
expect(info.isLocked).toBe(true);
expect(info.isOwnLock).toBe(false);
expect(info.lockedByName).toBe('Bob');
});
});
// ── acquireLock ──────────────────────────────────────────────────────────────
describe('acquireLock', () => {
it('returns true when lock acquired', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await acquireLock(sb, 'doc1', 'user1')).toBe(true);
});
it('returns false on unique constraint violation', async () => {
const sb = mockSupabase({ data: null, error: { code: '23505', message: 'unique' } });
expect(await acquireLock(sb, 'doc1', 'user1')).toBe(false);
});
it('returns false on other errors', async () => {
const sb = mockSupabase({ data: null, error: { code: '42000', message: 'other' } });
expect(await acquireLock(sb, 'doc1', 'user1')).toBe(false);
});
});
// ── heartbeatLock ────────────────────────────────────────────────────────────
describe('heartbeatLock', () => {
it('returns true on success', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await heartbeatLock(sb, 'doc1', 'user1')).toBe(true);
});
it('returns false on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
expect(await heartbeatLock(sb, 'doc1', 'user1')).toBe(false);
});
});
// ── releaseLock ──────────────────────────────────────────────────────────────
describe('releaseLock', () => {
it('releases without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(releaseLock(sb, 'doc1', 'user1')).resolves.toBeUndefined();
});
it('does not throw on error (just logs)', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(releaseLock(sb, 'doc1', 'user1')).resolves.toBeUndefined();
});
});
// ── startHeartbeat ───────────────────────────────────────────────────────────
describe('startHeartbeat', () => {
it('returns a cleanup function', () => {
const sb = mockSupabase({ data: null, error: null });
const cleanup = startHeartbeat(sb, 'doc1', 'user1');
expect(typeof cleanup).toBe('function');
cleanup(); // should not throw
});
});

View File

@@ -36,7 +36,7 @@ export async function getLockInfo(
return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false };
}
// Fetch profile separately document_locks.user_id FK points to auth.users, not profiles
// Fetch profile separately - document_locks.user_id FK points to auth.users, not profiles
let lockedByName = 'Someone';
if (lock.user_id) {
const { data: profile } = await supabase
@@ -87,7 +87,7 @@ export async function acquireLock(
if (error) {
if (error.code === '23505') {
// Unique constraint violation someone else holds the lock
// Unique constraint violation - someone else holds the lock
log.debug('Lock already held', { data: { documentId } });
return false;
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments } from './documents';
import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments, fetchFolderContents, findDepartmentFolder, findFinanceFolder, ensureEventsFolder, createEventFolder, createDepartmentFolder, ensureFinanceFolder, ensureFinanceDeptFolder, uploadFile, deleteFileFromStorage, subscribeToDocuments, getFileMetadata, formatFileSize } from './documents';
// Lightweight Supabase mock builder
function mockSupabase(response: { data?: unknown; error?: unknown }) {
@@ -108,7 +108,7 @@ describe('deleteDocument', () => {
describe('fetchDocuments', () => {
it('returns documents array on success', async () => {
const docs = [fakeDoc];
// fetchDocuments calls .from().select().eq().order().order() need deeper chain
// fetchDocuments calls .from().select().eq().order().order() - need deeper chain
const orderFn2 = vi.fn().mockResolvedValue({ data: docs, error: null });
const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 });
const eqFn = vi.fn().mockReturnValue({ order: orderFn1 });
@@ -130,3 +130,538 @@ describe('fetchDocuments', () => {
await expect(fetchDocuments(sb, 'org-1')).rejects.toEqual({ message: 'fetch failed' });
});
});
// ── updateDocument ───────────────────────────────────────────────────────────
describe('updateDocument', () => {
it('updates and returns document', async () => {
const updated = { ...fakeDoc, name: 'Renamed' };
const sb = mockSupabaseSuccess(updated);
const result = await updateDocument(sb, 'doc-1', { name: 'Renamed' });
expect(result.name).toBe('Renamed');
});
it('throws on error', async () => {
const sb = mockSupabaseError('update failed');
await expect(updateDocument(sb, 'doc-1', { name: 'X' }))
.rejects.toEqual({ message: 'update failed', code: 'ERROR' });
});
});
// ── moveDocument ─────────────────────────────────────────────────────────────
describe('moveDocument', () => {
it('moves document to new parent', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(moveDocument(sb, 'doc-1', 'folder-2')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'move failed', code: 'ERROR' } });
await expect(moveDocument(sb, 'doc-1', 'folder-2'))
.rejects.toEqual({ message: 'move failed', code: 'ERROR' });
});
});
// ── fetchFolderContents ──────────────────────────────────────────────────────
describe('fetchFolderContents', () => {
it('returns folder contents', async () => {
const docs = [fakeDoc];
const orderFn2 = vi.fn().mockResolvedValue({ data: docs, error: null });
const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 });
const eqFn = vi.fn().mockReturnValue({ order: orderFn1 });
const selectFn = vi.fn().mockReturnValue({ eq: eqFn });
const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any;
const result = await fetchFolderContents(sb, 'folder-1');
expect(result).toEqual(docs);
});
it('returns empty array when null', async () => {
const orderFn2 = vi.fn().mockResolvedValue({ data: null, error: null });
const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 });
const eqFn = vi.fn().mockReturnValue({ order: orderFn1 });
const selectFn = vi.fn().mockReturnValue({ eq: eqFn });
const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any;
expect(await fetchFolderContents(sb, 'folder-1')).toEqual([]);
});
it('throws on error', async () => {
const orderFn2 = vi.fn().mockResolvedValue({ data: null, error: { message: 'fail' } });
const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 });
const eqFn = vi.fn().mockReturnValue({ order: orderFn1 });
const selectFn = vi.fn().mockReturnValue({ eq: eqFn });
const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any;
await expect(fetchFolderContents(sb, 'folder-1')).rejects.toEqual({ message: 'fail' });
});
});
// ── findDepartmentFolder ─────────────────────────────────────────────────────
describe('findDepartmentFolder', () => {
it('returns folder when found', async () => {
const folder = { id: 'f1', name: 'Dept', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: folder, error: null }));
const sb = { from: vi.fn(() => chain) } as any;
const result = await findDepartmentFolder(sb, 'dept-1');
expect(result).toEqual(folder);
});
it('returns null when not found', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: null, error: null }));
const sb = { from: vi.fn(() => chain) } as any;
expect(await findDepartmentFolder(sb, 'dept-1')).toBeNull();
});
});
// ── Pure utility functions ───────────────────────────────────────────────────
describe('getFileMetadata', () => {
it('returns metadata for file type with storage_path', () => {
const doc = { type: 'file', content: { storage_path: '/path/to/file', mime_type: 'image/png', size: 1024 } } as any;
const result = getFileMetadata(doc);
expect(result).not.toBeNull();
expect(result!.storage_path).toBe('/path/to/file');
});
it('returns null for non-file type', () => {
const doc = { type: 'folder', content: null } as any;
expect(getFileMetadata(doc)).toBeNull();
});
it('returns null when content is null', () => {
const doc = { type: 'file', content: null } as any;
expect(getFileMetadata(doc)).toBeNull();
});
it('returns null when no storage_path', () => {
const doc = { type: 'file', content: { mime_type: 'text/plain' } } as any;
expect(getFileMetadata(doc)).toBeNull();
});
});
describe('formatFileSize', () => {
it('formats bytes', () => {
expect(formatFileSize(500)).toBe('500 B');
});
it('formats kilobytes', () => {
expect(formatFileSize(2048)).toBe('2.0 KB');
});
it('formats megabytes', () => {
expect(formatFileSize(5 * 1024 * 1024)).toBe('5.0 MB');
});
it('formats gigabytes', () => {
expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe('2.0 GB');
});
});
// ── findFinanceFolder ────────────────────────────────────────────────────────
describe('findFinanceFolder', () => {
it('returns finance folder when event folder and finance folder exist', async () => {
const eventFolder = { id: 'ef1' };
const financeFolder = { id: 'ff1', name: 'Finance', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.single = vi.fn(() => {
callIdx++;
if (callIdx === 1) return Promise.resolve({ data: eventFolder, error: null });
return Promise.resolve({ data: financeFolder, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await findFinanceFolder(sb, 'event-1');
expect(result).toEqual(financeFolder);
});
it('returns null when event folder not found', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: null, error: null }));
const sb = { from: vi.fn(() => chain) } as any;
expect(await findFinanceFolder(sb, 'event-1')).toBeNull();
});
it('returns null when finance folder not found inside event folder', async () => {
const eventFolder = { id: 'ef1' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.single = vi.fn(() => {
callIdx++;
if (callIdx === 1) return Promise.resolve({ data: eventFolder, error: null });
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
expect(await findFinanceFolder(sb, 'event-1')).toBeNull();
});
});
// ── deleteFileFromStorage ────────────────────────────────────────────────────
describe('deleteFileFromStorage', () => {
it('deletes file from storage', async () => {
const removeFn = vi.fn().mockResolvedValue({ error: null });
const sb = { storage: { from: vi.fn().mockReturnValue({ remove: removeFn }) } } as any;
await expect(deleteFileFromStorage(sb, 'org/root/file.txt')).resolves.toBeUndefined();
expect(removeFn).toHaveBeenCalledWith(['org/root/file.txt']);
});
it('throws on storage error', async () => {
const removeFn = vi.fn().mockResolvedValue({ error: { message: 'storage fail' } });
const sb = { storage: { from: vi.fn().mockReturnValue({ remove: removeFn }) } } as any;
await expect(deleteFileFromStorage(sb, 'org/root/file.txt')).rejects.toEqual({ message: 'storage fail' });
});
});
// ── ensureEventsFolder ───────────────────────────────────────────────────────
describe('ensureEventsFolder', () => {
it('returns existing Events folder if found', async () => {
const folder = { id: 'ef1', name: 'Events', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: folder, error: null }));
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureEventsFolder(sb, 'org-1', 'user-1');
expect(result).toEqual(folder);
});
it('creates Events folder when not found', async () => {
const newFolder = { id: 'ef2', name: 'Events', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', '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: null, error: null }); // not found
return Promise.resolve({ data: newFolder, error: null }); // created
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureEventsFolder(sb, 'org-1', 'user-1');
expect(result).toEqual(newFolder);
});
});
// ── uploadFile ───────────────────────────────────────────────────────────────
describe('uploadFile', () => {
it('uploads file and creates document row', async () => {
const doc = { id: 'doc-1', name: 'photo.png', type: 'file' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: doc, error: null }));
const sb = {
from: vi.fn(() => chain),
storage: {
from: vi.fn().mockReturnValue({
upload: vi.fn().mockResolvedValue({ error: null }),
getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://example.com/photo.png' } }),
remove: vi.fn().mockResolvedValue({ error: null }),
}),
},
} as any;
const file = new File(['data'], 'photo.png', { type: 'image/png' });
const result = await uploadFile(sb, 'org-1', 'folder-1', 'user-1', file);
expect(result).toEqual(doc);
});
it('throws on upload error', async () => {
const sb = {
from: vi.fn(),
storage: {
from: vi.fn().mockReturnValue({
upload: vi.fn().mockResolvedValue({ error: { message: 'upload fail' } }),
}),
},
} as any;
const file = new File(['data'], 'test.txt', { type: 'text/plain' });
await expect(uploadFile(sb, 'org-1', null, 'user-1', file)).rejects.toEqual({ message: 'upload fail' });
});
it('cleans up storage on DB insert error', async () => {
const removeFn = vi.fn().mockResolvedValue({ error: null });
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: null, error: { message: 'db fail' } }));
const sb = {
from: vi.fn(() => chain),
storage: {
from: vi.fn().mockReturnValue({
upload: vi.fn().mockResolvedValue({ error: null }),
getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://example.com/f.txt' } }),
remove: removeFn,
}),
},
} as any;
const file = new File(['data'], 'test.txt', { type: 'text/plain' });
await expect(uploadFile(sb, 'org-1', null, 'user-1', file)).rejects.toEqual({ message: 'db fail' });
expect(removeFn).toHaveBeenCalled();
});
});
// ── createEventFolder ────────────────────────────────────────────────────────
describe('createEventFolder', () => {
it('returns existing event folder if found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const eventFolder = { id: 'evf1', name: 'Conf', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', '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: eventsFolder, error: null }); // ensureEventsFolder
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); // existing event folder
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await createEventFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf');
expect(result).toEqual(eventFolder);
});
it('creates event folder when not found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const newFolder = { id: 'evf2', name: 'Conf', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', '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: eventsFolder, error: null }); // ensureEventsFolder
if (singleIdx === 2) return Promise.resolve({ data: null, error: null }); // not found
return Promise.resolve({ data: newFolder, error: null }); // created
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await createEventFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf');
expect(result).toEqual(newFolder);
});
});
// ── createDepartmentFolder ───────────────────────────────────────────────────
describe('createDepartmentFolder', () => {
it('returns existing dept folder if found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const eventFolder = { id: 'evf1', name: 'Conf' };
const deptFolder = { id: 'df1', name: 'Logistics', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', '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: eventsFolder, error: null }); // ensureEventsFolder
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); // createEventFolder existing
if (singleIdx === 3) return Promise.resolve({ data: deptFolder, error: null }); // existing dept folder
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await createDepartmentFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Logistics');
expect(result).toEqual(deptFolder);
});
it('creates dept folder when not found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const eventFolder = { id: 'evf1', name: 'Conf' };
const newDeptFolder = { id: 'df2', name: 'Marketing', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', '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: eventsFolder, error: null });
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null });
if (singleIdx === 3) return Promise.resolve({ data: null, error: null }); // not found
return Promise.resolve({ data: newDeptFolder, error: null }); // created
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await createDepartmentFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing');
expect(result).toEqual(newDeptFolder);
});
});
// ── ensureFinanceFolder ──────────────────────────────────────────────────────
describe('ensureFinanceFolder', () => {
it('returns existing Finance folder if found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const eventFolder = { id: 'evf1', name: 'Conf' };
const financeFolder = { id: 'ff1', name: 'Finance', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', '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: eventsFolder, error: null });
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null });
if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null }); // existing
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureFinanceFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf');
expect(result).toEqual(financeFolder);
});
it('creates Finance folder when not found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const eventFolder = { id: 'evf1', name: 'Conf' };
const newFinance = { id: 'ff2', name: 'Finance', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', '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: eventsFolder, error: null });
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null });
if (singleIdx === 3) return Promise.resolve({ data: null, error: null }); // not found
return Promise.resolve({ data: newFinance, error: null }); // created
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureFinanceFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf');
expect(result).toEqual(newFinance);
});
});
// ── ensureFinanceDeptFolder ──────────────────────────────────────────────────
describe('ensureFinanceDeptFolder', () => {
it('returns existing finance dept folder if found', async () => {
const eventsFolder = { id: 'ef1' };
const eventFolder = { id: 'evf1' };
const financeFolder = { id: 'ff1' };
const deptFolder = { id: 'fdf1', name: 'Marketing', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', '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: eventsFolder, error: null });
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null });
if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null });
if (singleIdx === 4) return Promise.resolve({ data: deptFolder, error: null }); // existing
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureFinanceDeptFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing');
expect(result).toEqual(deptFolder);
});
it('creates finance dept folder when not found', async () => {
const eventsFolder = { id: 'ef1' };
const eventFolder = { id: 'evf1' };
const financeFolder = { id: 'ff1' };
const newDeptFolder = { id: 'fdf2', name: 'Marketing', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', '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: eventsFolder, error: null });
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null });
if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null });
if (singleIdx === 4) return Promise.resolve({ data: null, error: null }); // not found
return Promise.resolve({ data: newDeptFolder, error: null }); // created
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureFinanceDeptFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing');
expect(result).toEqual(newDeptFolder);
});
});
// ── subscribeToDocuments ─────────────────────────────────────────────────────
describe('subscribeToDocuments', () => {
it('sets up realtime subscription', () => {
const channel: any = {};
channel.on = vi.fn(() => channel);
channel.subscribe = vi.fn(() => channel);
const sb = { channel: vi.fn(() => channel) } as any;
const result = subscribeToDocuments(sb, 'org-1', vi.fn(), vi.fn(), vi.fn());
expect(sb.channel).toHaveBeenCalledWith('documents:org-1');
expect(channel.on).toHaveBeenCalledTimes(3);
expect(channel.subscribe).toHaveBeenCalledOnce();
expect(result).toBe(channel);
});
});

View File

@@ -133,6 +133,420 @@ export async function copyDocument(
return data;
}
// ============================================================
// Auto-folder helpers for Events & Departments
// ============================================================
/**
* Find or create the org-level "Events" root folder.
* Uses a name + null parent_id lookup first to avoid duplicates.
*/
export async function ensureEventsFolder(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string
): Promise<Document> {
// Look for existing "Events" folder at root level
const { data: existing } = await supabase
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('type', 'folder')
.eq('name', 'Events')
.is('parent_id', null)
.limit(1)
.single();
if (existing) return existing;
// Create it
log.info('Creating Events root folder', { data: { orgId } });
return createDocument(supabase, orgId, 'Events', 'folder', null, userId);
}
/**
* Create a folder for a specific event inside the "Events" root folder.
* Also stores event_id on the document row for reliable lookup.
*/
export async function createEventFolder(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
eventId: string,
eventName: string
): Promise<Document> {
const eventsFolder = await ensureEventsFolder(supabase, orgId, userId);
// Check if folder already exists for this event
const { data: existing } = await (supabase as any)
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('type', 'folder')
.eq('event_id', eventId)
.eq('parent_id', eventsFolder.id)
.limit(1)
.single();
if (existing) return existing;
log.info('Creating event folder', { data: { orgId, eventId, eventName } });
const { data, error } = await (supabase as any)
.from('documents')
.insert({
org_id: orgId,
name: eventName,
type: 'folder',
parent_id: eventsFolder.id,
created_by: userId,
content: null,
event_id: eventId,
})
.select()
.single();
if (error) {
log.error('createEventFolder failed', { error, data: { orgId, eventId } });
throw error;
}
return data;
}
/**
* Create a folder for a department inside its event folder.
* Stores department_id on the document row for reliable lookup.
*/
export async function createDepartmentFolder(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
eventId: string,
eventName: string,
departmentId: string,
departmentName: string
): Promise<Document> {
const eventFolder = await createEventFolder(supabase, orgId, userId, eventId, eventName);
// Check if folder already exists for this department
const { data: existing } = await (supabase as any)
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('type', 'folder')
.eq('department_id', departmentId)
.eq('parent_id', eventFolder.id)
.limit(1)
.single();
if (existing) return existing;
log.info('Creating department folder', { data: { orgId, eventId, departmentId, departmentName } });
const { data, error } = await (supabase as any)
.from('documents')
.insert({
org_id: orgId,
name: departmentName,
type: 'folder',
parent_id: eventFolder.id,
created_by: userId,
content: null,
event_id: eventId,
department_id: departmentId,
})
.select()
.single();
if (error) {
log.error('createDepartmentFolder failed', { error, data: { orgId, departmentId } });
throw error;
}
return data;
}
/**
* Fetch all documents inside a specific folder.
*/
export async function fetchFolderContents(
supabase: SupabaseClient<Database>,
folderId: string
): Promise<Document[]> {
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('parent_id', folderId)
.order('type', { ascending: false }) // folders first
.order('name');
if (error) {
log.error('fetchFolderContents failed', { error, data: { folderId } });
throw error;
}
return data ?? [];
}
/**
* Find the department folder by department_id. Returns null if not found.
*/
export async function findDepartmentFolder(
supabase: SupabaseClient<Database>,
departmentId: string
): Promise<Document | null> {
const { data } = await (supabase as any)
.from('documents')
.select('*')
.eq('type', 'folder')
.eq('department_id', departmentId)
.limit(1)
.single();
return data ?? null;
}
// ============================================================
// Finance folder helpers
// ============================================================
/**
* Find or create a "Finance" folder inside the event folder.
* Used as the root for all department finance documents (invoices, receipts).
*/
export async function ensureFinanceFolder(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
eventId: string,
eventName: string
): Promise<Document> {
const eventFolder = await createEventFolder(supabase, orgId, userId, eventId, eventName);
// Look for existing "Finance" folder inside event folder
const { data: existing } = await supabase
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('type', 'folder')
.eq('name', 'Finance')
.eq('parent_id', eventFolder.id)
.limit(1)
.single();
if (existing) return existing;
log.info('Creating Finance folder', { data: { orgId, eventId } });
const { data, error } = await (supabase as any)
.from('documents')
.insert({
org_id: orgId,
name: 'Finance',
type: 'folder',
parent_id: eventFolder.id,
created_by: userId,
content: null,
event_id: eventId,
})
.select()
.single();
if (error) {
log.error('ensureFinanceFolder failed', { error, data: { orgId, eventId } });
throw error;
}
return data;
}
/**
* Find or create a department subfolder inside the Finance folder.
* e.g. Events > [Event] > Finance > [Department Name]
*/
export async function ensureFinanceDeptFolder(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
eventId: string,
eventName: string,
departmentId: string,
departmentName: string
): Promise<Document> {
const financeFolder = await ensureFinanceFolder(supabase, orgId, userId, eventId, eventName);
// Look for existing dept subfolder inside Finance folder
const { data: existing } = await (supabase as any)
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('type', 'folder')
.eq('department_id', departmentId)
.eq('parent_id', financeFolder.id)
.limit(1)
.single();
if (existing) return existing;
log.info('Creating finance dept folder', { data: { orgId, departmentId, departmentName } });
const { data, error } = await (supabase as any)
.from('documents')
.insert({
org_id: orgId,
name: departmentName,
type: 'folder',
parent_id: financeFolder.id,
created_by: userId,
content: null,
event_id: eventId,
department_id: departmentId,
})
.select()
.single();
if (error) {
log.error('ensureFinanceDeptFolder failed', { error, data: { orgId, departmentId } });
throw error;
}
return data;
}
/**
* Find the Finance folder for an event. Returns null if not found.
*/
export async function findFinanceFolder(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<Document | null> {
// Find the event folder first
const { data: eventFolder } = await (supabase as any)
.from('documents')
.select('id')
.eq('type', 'folder')
.eq('event_id', eventId)
.is('department_id', null)
.limit(1)
.single();
if (!eventFolder) return null;
const { data } = await (supabase as any)
.from('documents')
.select('*')
.eq('type', 'folder')
.eq('name', 'Finance')
.eq('parent_id', eventFolder.id)
.limit(1)
.single();
return data ?? null;
}
// ============================================================
// File Upload helpers
// ============================================================
export interface FileMetadata {
storage_path: string;
file_name: string;
file_size: number;
mime_type: string;
public_url: string;
}
/**
* Upload a file to Supabase Storage and create a document row of type "file".
* Files are stored under: {orgId}/{parentId}/{timestamp}_{filename}
*/
export async function uploadFile(
supabase: SupabaseClient<Database>,
orgId: string,
parentId: string | null,
userId: string,
file: File
): Promise<Document> {
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const storagePath = `${orgId}/${parentId ?? 'root'}/${timestamp}_${safeName}`;
log.info('Uploading file', { data: { orgId, fileName: file.name, size: file.size, storagePath } });
const { error: uploadError } = await supabase.storage
.from('files')
.upload(storagePath, file, {
cacheControl: '3600',
upsert: false,
});
if (uploadError) {
log.error('File upload failed', { error: uploadError, data: { storagePath } });
throw uploadError;
}
// Get public URL
const { data: urlData } = supabase.storage.from('files').getPublicUrl(storagePath);
const metadata: FileMetadata = {
storage_path: storagePath,
file_name: file.name,
file_size: file.size,
mime_type: file.type || 'application/octet-stream',
public_url: urlData.publicUrl,
};
// Create document row with type "file"
const { data, error } = await supabase
.from('documents')
.insert({
org_id: orgId,
name: file.name,
type: 'file',
parent_id: parentId,
created_by: userId,
content: metadata as unknown as import('$lib/supabase/types').Json,
})
.select()
.single();
if (error) {
// Clean up storage on DB failure
await supabase.storage.from('files').remove([storagePath]);
log.error('File document creation failed', { error, data: { storagePath } });
throw error;
}
log.info('File uploaded successfully', { data: { id: data.id, name: file.name, size: file.size } });
return data;
}
/**
* Delete a file from Storage when its document row is deleted.
*/
export async function deleteFileFromStorage(
supabase: SupabaseClient<Database>,
storagePath: string
): Promise<void> {
const { error } = await supabase.storage.from('files').remove([storagePath]);
if (error) {
log.error('deleteFileFromStorage failed', { error, data: { storagePath } });
throw error;
}
}
/**
* Get the file metadata from a document's content field.
*/
export function getFileMetadata(doc: Document): FileMetadata | null {
if (doc.type !== 'file' || !doc.content) return null;
const content = doc.content as unknown as FileMetadata;
if (!content.storage_path) return null;
return content;
}
/**
* Format file size in human-readable form.
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export function subscribeToDocuments(
supabase: SupabaseClient<Database>,
orgId: string,

View File

@@ -0,0 +1,206 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchTaskColumns,
createTaskColumn,
renameTaskColumn,
deleteTaskColumn,
createTask,
updateTask,
deleteTask,
moveTask,
subscribeToEventTasks,
} from './event-tasks';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any; count?: number }) {
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);
// For count queries
if (resolvedValue.count !== undefined) {
chain.count = resolvedValue.count;
}
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any; count?: number }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Columns ──────────────────────────────────────────────────────────────────
describe('fetchTaskColumns', () => {
it('returns columns with tasks grouped', async () => {
const columns = [{ id: 'c1', name: 'To Do', event_id: 'e1', position: 0 }];
const tasks = [{ id: 't1', title: 'Task 1', event_id: 'e1', column_id: 'c1', position: 0 }];
const chain: any = {};
const methods = ['from', 'select', 'eq', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
if (callIdx === 1) return resolve({ data: columns, error: null });
return resolve({ data: tasks, error: null });
};
const sb = { from: vi.fn(() => chain) } as any;
const result = await fetchTaskColumns(sb, 'e1');
expect(result).toHaveLength(1);
expect(result[0].cards).toHaveLength(1);
expect(result[0].cards[0].title).toBe('Task 1');
});
it('throws when column fetch fails', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'col fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(fetchTaskColumns(sb, 'e1')).rejects.toEqual({ message: 'col fail' });
});
});
describe('createTaskColumn', () => {
it('creates column with explicit position', async () => {
const col = { id: 'c1', name: 'Done', event_id: 'e1', position: 2 };
const sb = mockSupabase({ data: col, error: null });
expect(await createTaskColumn(sb, 'e1', 'Done', 2)).toEqual(col);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createTaskColumn(sb, 'e1', 'X', 0)).rejects.toEqual({ message: 'fail' });
});
});
describe('renameTaskColumn', () => {
it('renames without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(renameTaskColumn(sb, 'c1', 'Renamed')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(renameTaskColumn(sb, 'c1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteTaskColumn', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteTaskColumn(sb, 'c1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteTaskColumn(sb, 'c1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Tasks ────────────────────────────────────────────────────────────────────
describe('createTask', () => {
it('creates task', async () => {
const task = { id: 't1', title: 'New Task', event_id: 'e1', column_id: 'c1', position: 0 };
// Need two calls: count query + insert
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
if (callIdx === 1) return resolve({ count: 0, error: null });
return resolve({ data: task, error: null });
};
chain.single = vi.fn(() => {
return Promise.resolve({ data: task, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await createTask(sb, 'e1', 'c1', 'New Task', 'user1');
expect(result).toEqual(task);
});
});
describe('updateTask', () => {
it('updates without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(updateTask(sb, 't1', { title: 'Updated' })).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateTask(sb, 't1', { title: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteTask', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteTask(sb, 't1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteTask(sb, 't1')).rejects.toEqual({ message: 'fail' });
});
});
describe('moveTask', () => {
it('reorders tasks in target column', async () => {
const colTasks = [
{ id: 't1', position: 0 },
{ id: 't2', position: 1 },
];
const chain: any = {};
const methods = ['from', 'select', 'update', 'eq', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: colTasks, error: null });
const sb = { from: vi.fn(() => chain) } as any;
await expect(moveTask(sb, 't3', 'c1', 1)).resolves.toBeUndefined();
});
it('throws when fetch fails', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', '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(moveTask(sb, 't1', 'c1', 0)).rejects.toEqual({ message: 'fetch fail' });
});
});
describe('subscribeToEventTasks', () => {
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 = subscribeToEventTasks(sb, 'e1', ['c1'], vi.fn(), vi.fn());
expect(sb.channel).toHaveBeenCalledWith('event-tasks:e1');
expect(channel.on).toHaveBeenCalledTimes(2);
expect(channel.subscribe).toHaveBeenCalledOnce();
expect(result).toBe(channel);
});
});

View File

@@ -1,48 +1,410 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
fetchEvents,
fetchEvent,
fetchEventBySlug,
createEvent,
updateEvent,
deleteEvent,
fetchEventMembers,
addEventMember,
removeEventMember,
fetchEventRoles,
createEventRole,
updateEventRole,
deleteEventRole,
fetchEventDepartments,
createEventDepartment,
updateEventDepartment,
updateDepartmentPlannedBudget,
deleteEventDepartment,
assignMemberDepartment,
unassignMemberDepartment,
} from './events';
// Test the slugify logic (extracted inline since it's not exported)
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60) || 'event';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'upsert', 'update', 'delete', 'eq', 'in', 'like', '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;
}
describe('events API - slugify', () => {
it('converts simple name to slug', () => {
expect(slugify('Summer Conference')).toBe('summer-conference');
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── fetchEvents ──────────────────────────────────────────────────────────────
describe('fetchEvents', () => {
it('returns events with member counts', async () => {
const raw = [{ id: 'e1', name: 'Conf', event_members: [{ count: 5 }] }];
const sb = mockSupabase({ data: raw, error: null });
const result = await fetchEvents(sb, 'o1');
expect(result).toHaveLength(1);
expect(result[0].member_count).toBe(5);
});
it('handles special characters', () => {
expect(slugify('Music & Arts Festival 2026!')).toBe('music-arts-festival-2026');
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchEvents(sb, 'o1')).toEqual([]);
});
it('collapses multiple spaces and dashes', () => {
expect(slugify('My Big Event')).toBe('my-big-event');
});
it('trims leading/trailing dashes', () => {
expect(slugify('--hello--')).toBe('hello');
});
it('truncates to 60 characters', () => {
const longName = 'A'.repeat(100);
expect(slugify(longName).length).toBeLessThanOrEqual(60);
});
it('returns "event" for empty string', () => {
expect(slugify('')).toBe('event');
});
it('handles unicode characters', () => {
const result = slugify('Ürituse Korraldamine');
expect(result).toBe('rituse-korraldamine');
});
it('handles numbers', () => {
expect(slugify('Event 2026 Q1')).toBe('event-2026-q1');
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEvents(sb, 'o1')).rejects.toEqual({ message: 'fail' });
});
});
// ── fetchEvent ───────────────────────────────────────────────────────────────
describe('fetchEvent', () => {
it('returns event by id', async () => {
const event = { id: 'e1', name: 'Conf' };
const sb = mockSupabase({ data: event, error: null });
expect(await fetchEvent(sb, 'e1')).toEqual(event);
});
it('returns null when not found', async () => {
const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } });
expect(await fetchEvent(sb, 'e1')).toBeNull();
});
it('throws on other errors', async () => {
const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } });
await expect(fetchEvent(sb, 'e1')).rejects.toEqual({ code: '42000', message: 'fail' });
});
});
// ── fetchEventBySlug ─────────────────────────────────────────────────────────
describe('fetchEventBySlug', () => {
it('returns event by slug', async () => {
const event = { id: 'e1', slug: 'conf' };
const sb = mockSupabase({ data: event, error: null });
expect(await fetchEventBySlug(sb, 'o1', 'conf')).toEqual(event);
});
it('returns null when not found', async () => {
const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } });
expect(await fetchEventBySlug(sb, 'o1', 'nope')).toBeNull();
});
it('throws on other errors', async () => {
const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } });
await expect(fetchEventBySlug(sb, 'o1', 'x')).rejects.toEqual({ code: '42000', message: 'fail' });
});
});
// ── createEvent ──────────────────────────────────────────────────────────────
describe('createEvent', () => {
it('creates event with unique slug', async () => {
const event = { id: 'e1', name: 'Conf', slug: 'conf' };
// Two calls: slug check + insert
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'like', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
if (callIdx === 1) return resolve({ data: [], error: null }); // no existing slugs
return resolve({ data: event, error: null });
};
chain.single = vi.fn(() => Promise.resolve({ data: event, error: null }));
const sb = { from: vi.fn(() => chain) } as any;
const result = await createEvent(sb, 'o1', 'u1', { name: 'Conf' });
expect(result).toEqual(event);
});
it('throws on insert error', async () => {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'like', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
if (callIdx === 1) return resolve({ data: [], error: null });
return resolve({ data: null, error: { message: 'fail' } });
};
chain.single = vi.fn(() => Promise.resolve({ data: null, error: { message: 'fail' } }));
const sb = { from: vi.fn(() => chain) } as any;
await expect(createEvent(sb, 'o1', 'u1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
// ── updateEvent ──────────────────────────────────────────────────────────────
describe('updateEvent', () => {
it('updates and returns event', async () => {
const event = { id: 'e1', name: 'Updated' };
const sb = mockSupabase({ data: event, error: null });
expect(await updateEvent(sb, 'e1', { name: 'Updated' })).toEqual(event);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateEvent(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
// ── deleteEvent ──────────────────────────────────────────────────────────────
describe('deleteEvent', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteEvent(sb, 'e1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteEvent(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
// ── fetchEventMembers ────────────────────────────────────────────────────────
describe('fetchEventMembers', () => {
it('returns empty array when no members', async () => {
const sb = mockSupabase({ data: [], error: null });
expect(await fetchEventMembers(sb, 'e1')).toEqual([]);
});
it('returns members with profiles, roles, and departments', async () => {
const members = [{ id: 'm1', event_id: 'e1', user_id: 'u1', role_id: 'r1' }];
const profiles = [{ id: 'u1', email: 'a@b.com', full_name: 'Alice' }];
const roles = [{ id: 'r1', event_id: 'e1', name: 'Lead' }];
const memberDepts = [{ event_member_id: 'm1', department_id: 'd1' }];
const departments = [{ id: 'd1', event_id: 'e1', name: 'Logistics' }];
const chain: any = {};
const methods = ['from', 'select', 'eq', 'in', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
switch (callIdx) {
case 1: return resolve({ data: members, error: null }); // members
case 2: return resolve({ data: profiles, error: null }); // profiles
case 3: return resolve({ data: roles, error: null }); // roles
case 4: return resolve({ data: memberDepts, error: null }); // member-depts
case 5: return resolve({ data: departments, error: null }); // departments
default: return resolve({ data: [], error: null });
}
};
const sb = { from: vi.fn(() => chain) } as any;
const result = await fetchEventMembers(sb, 'e1');
expect(result).toHaveLength(1);
expect(result[0].profile?.full_name).toBe('Alice');
expect(result[0].event_role?.name).toBe('Lead');
expect(result[0].departments).toHaveLength(1);
expect(result[0].departments[0].name).toBe('Logistics');
});
it('throws on error', 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: 'fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(fetchEventMembers(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
// ── addEventMember ───────────────────────────────────────────────────────────
describe('addEventMember', () => {
it('adds member', async () => {
const member = { id: 'm1', event_id: 'e1', user_id: 'u1', role: 'member' };
const sb = mockSupabase({ data: member, error: null });
expect(await addEventMember(sb, 'e1', 'u1')).toEqual(member);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(addEventMember(sb, 'e1', 'u1')).rejects.toEqual({ message: 'fail' });
});
});
// ── removeEventMember ────────────────────────────────────────────────────────
describe('removeEventMember', () => {
it('removes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(removeEventMember(sb, 'e1', 'u1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(removeEventMember(sb, 'e1', 'u1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Event Roles ──────────────────────────────────────────────────────────────
describe('fetchEventRoles', () => {
it('returns roles', async () => {
const roles = [{ id: 'r1', name: 'Lead' }];
const sb = mockSupabase({ data: roles, error: null });
expect(await fetchEventRoles(sb, 'e1')).toEqual(roles);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEventRoles(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createEventRole', () => {
it('creates role', async () => {
const role = { id: 'r1', name: 'Lead', color: '#6366f1' };
const sb = mockSupabase({ data: role, error: null });
expect(await createEventRole(sb, 'e1', { name: 'Lead' })).toEqual(role);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createEventRole(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateEventRole', () => {
it('updates role', async () => {
const role = { id: 'r1', name: 'Updated' };
const sb = mockSupabase({ data: role, error: null });
expect(await updateEventRole(sb, 'r1', { name: 'Updated' })).toEqual(role);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateEventRole(sb, 'r1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteEventRole', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteEventRole(sb, 'r1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteEventRole(sb, 'r1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Event Departments ────────────────────────────────────────────────────────
describe('fetchEventDepartments', () => {
it('returns departments', async () => {
const depts = [{ id: 'd1', name: 'Logistics' }];
const sb = mockSupabase({ data: depts, error: null });
expect(await fetchEventDepartments(sb, 'e1')).toEqual(depts);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchEventDepartments(sb, 'e1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEventDepartments(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createEventDepartment', () => {
it('creates department', async () => {
const dept = { id: 'd1', name: 'Marketing', color: '#00A3E0' };
const sb = mockSupabase({ data: dept, error: null });
expect(await createEventDepartment(sb, 'e1', { name: 'Marketing' })).toEqual(dept);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createEventDepartment(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateEventDepartment', () => {
it('updates department', async () => {
const dept = { id: 'd1', name: 'Updated' };
const sb = mockSupabase({ data: dept, error: null });
expect(await updateEventDepartment(sb, 'd1', { name: 'Updated' })).toEqual(dept);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateEventDepartment(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateDepartmentPlannedBudget', () => {
it('updates planned budget', async () => {
const dept = { id: 'd1', planned_budget: 5000 };
const sb = mockSupabase({ data: dept, error: null });
expect(await updateDepartmentPlannedBudget(sb, 'd1', 5000)).toEqual(dept);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateDepartmentPlannedBudget(sb, 'd1', 0)).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteEventDepartment', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteEventDepartment(sb, 'd1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteEventDepartment(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Member-Department Assignments ────────────────────────────────────────────
describe('assignMemberDepartment', () => {
it('assigns member to department', async () => {
const md = { id: 'md1', event_member_id: 'm1', department_id: 'd1' };
const sb = mockSupabase({ data: md, error: null });
expect(await assignMemberDepartment(sb, 'm1', 'd1')).toEqual(md);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(assignMemberDepartment(sb, 'm1', 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('unassignMemberDepartment', () => {
it('unassigns without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(unassignMemberDepartment(sb, 'm1', 'd1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(unassignMemberDepartment(sb, 'm1', 'd1')).rejects.toEqual({ message: 'fail' });
});
});

View File

@@ -48,6 +48,7 @@ export interface EventDepartment {
name: string;
color: string;
description: string | null;
planned_budget: number;
sort_order: number;
created_at: string | null;
}
@@ -492,6 +493,25 @@ export async function updateEventDepartment(
return data as unknown as EventDepartment;
}
export async function updateDepartmentPlannedBudget(
supabase: SupabaseClient<Database>,
deptId: string,
plannedBudget: number
): Promise<EventDepartment> {
const { data, error } = await (supabase as any)
.from('event_departments')
.update({ planned_budget: plannedBudget })
.eq('id', deptId)
.select()
.single();
if (error) {
log.error('updateDepartmentPlannedBudget failed', { error, data: { deptId, plannedBudget } });
throw error;
}
return data as unknown as EventDepartment;
}
export async function deleteEventDepartment(
supabase: SupabaseClient<Database>,
deptId: string

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { getServiceAccountEmail } from './google-calendar-push';
// ── getServiceAccountEmail (pure function, no network) ───────────────────────
describe('getServiceAccountEmail', () => {
it('extracts email from valid JSON key', () => {
const key = JSON.stringify({
client_email: 'test@project.iam.gserviceaccount.com',
private_key: 'fake-key',
});
expect(getServiceAccountEmail(key)).toBe('test@project.iam.gserviceaccount.com');
});
it('extracts email from base64-encoded JSON key', () => {
const json = JSON.stringify({
client_email: 'b64@project.iam.gserviceaccount.com',
private_key: 'fake-key',
});
const b64 = Buffer.from(json).toString('base64');
expect(getServiceAccountEmail(b64)).toBe('b64@project.iam.gserviceaccount.com');
});
it('returns null for invalid key', () => {
expect(getServiceAccountEmail('not-json-or-base64')).toBeNull();
});
it('returns null for empty string', () => {
expect(getServiceAccountEmail('')).toBeNull();
});
});

View File

@@ -75,7 +75,7 @@ export function getServiceAccountEmail(keyJson: string): string | null {
/**
* Fetch events from a Google Calendar using the service account.
* No need for the calendar to be public just shared with the service account.
* No need for the calendar to be public - just shared with the service account.
*/
export async function fetchCalendarEventsViaServiceAccount(
keyJson: string,
@@ -207,7 +207,7 @@ export async function deleteGoogleEvent(
}
);
// 410 Gone means already deleted treat as success
// 410 Gone means already deleted - treat as success
if (!response.ok && response.status !== 410) {
const errorText = await response.text();
log.error('Failed to delete Google Calendar event', { error: errorText, data: { calendarId, googleEventId } });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { extractCalendarId, getCalendarSubscribeUrl } from './google-calendar';
import { describe, it, expect, vi } from 'vitest';
import { extractCalendarId, getCalendarSubscribeUrl, fetchPublicCalendarEvents } from './google-calendar';
describe('extractCalendarId', () => {
it('returns null for empty input', () => {
@@ -59,3 +59,55 @@ describe('getCalendarSubscribeUrl', () => {
expect(extractCalendarId(url)).toBe(calId);
});
});
// ── fetchPublicCalendarEvents ────────────────────────────────────────────────
describe('fetchPublicCalendarEvents', () => {
it('returns events on success', async () => {
const events = [{ id: 'e1', summary: 'Meeting' }];
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: events }),
});
vi.stubGlobal('fetch', mockFetch);
const result = await fetchPublicCalendarEvents(
'cal@gmail.com', 'api-key',
new Date('2024-01-01'), new Date('2024-01-31')
);
expect(result).toEqual(events);
expect(mockFetch).toHaveBeenCalledOnce();
vi.unstubAllGlobals();
});
it('returns empty array when no items', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
}));
const result = await fetchPublicCalendarEvents(
'cal@gmail.com', 'key',
new Date(), new Date()
);
expect(result).toEqual([]);
vi.unstubAllGlobals();
});
it('throws on non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 403,
text: () => Promise.resolve('Forbidden'),
}));
await expect(fetchPublicCalendarEvents(
'cal@gmail.com', 'key',
new Date(), new Date()
)).rejects.toThrow('Failed to fetch calendar events (403)');
vi.unstubAllGlobals();
});
});

326
src/lib/api/kanban.test.ts Normal file
View File

@@ -0,0 +1,326 @@
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' });
});
});

252
src/lib/api/map.ts Normal file
View File

@@ -0,0 +1,252 @@
import type { SupabaseClient } from '@supabase/supabase-js';
export interface MapLayer {
id: string;
department_id: string;
name: string;
layer_type: 'osm' | 'image';
image_url: string | null;
image_width: number | null;
image_height: number | null;
center_lat: number;
center_lng: number;
zoom_level: number;
sort_order: number;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface MapPin {
id: string;
layer_id: string;
label: string;
description: string;
color: string;
lat: number;
lng: number;
bounds_north: number | null;
bounds_south: number | null;
bounds_east: number | null;
bounds_west: number | null;
sort_order: number;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface MapShape {
id: string;
layer_id: string;
shape_type: 'polygon' | 'rectangle';
label: string;
color: string;
fill_opacity: number;
stroke_width: number;
vertices: [number, number][];
rotation: number;
sort_order: number;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface MapLayerWithPins extends MapLayer {
pins: MapPin[];
shapes: MapShape[];
}
// ── Layers ──
export async function fetchMapLayers(supabase: SupabaseClient, departmentId: string): Promise<MapLayerWithPins[]> {
const { data: layers, error: layerErr } = await (supabase as any)
.from('map_layers')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (layerErr) throw layerErr;
if (!layers || layers.length === 0) return [];
const layerIds = layers.map((l: any) => l.id);
const [pinResult, shapeResult] = await Promise.all([
(supabase as any).from('map_pins').select('*').in('layer_id', layerIds).order('sort_order'),
(supabase as any).from('map_shapes').select('*').in('layer_id', layerIds).order('sort_order'),
]);
if (pinResult.error) throw pinResult.error;
if (shapeResult.error) throw shapeResult.error;
const pins = pinResult.data ?? [];
const shapes = shapeResult.data ?? [];
return layers.map((layer: any) => ({
...layer,
pins: pins.filter((p: any) => p.layer_id === layer.id),
shapes: shapes.filter((s: any) => s.layer_id === layer.id),
}));
}
export async function createMapLayer(
supabase: SupabaseClient,
departmentId: string,
data: {
name: string;
layer_type: 'osm' | 'image';
image_url?: string;
image_width?: number;
image_height?: number;
center_lat?: number;
center_lng?: number;
zoom_level?: number;
sort_order?: number;
},
): Promise<MapLayer> {
const { data: layer, error } = await (supabase as any)
.from('map_layers')
.insert({
department_id: departmentId,
...data,
})
.select()
.single();
if (error) throw error;
return layer;
}
export async function updateMapLayer(
supabase: SupabaseClient,
layerId: string,
data: Partial<Pick<MapLayer, 'name' | 'layer_type' | 'image_url' | 'image_width' | 'image_height' | 'center_lat' | 'center_lng' | 'zoom_level' | 'sort_order'>>,
): Promise<MapLayer> {
const { data: layer, error } = await (supabase as any)
.from('map_layers')
.update({ ...data, updated_at: new Date().toISOString() })
.eq('id', layerId)
.select()
.single();
if (error) throw error;
return layer;
}
export async function deleteMapLayer(supabase: SupabaseClient, layerId: string): Promise<void> {
const { error } = await (supabase as any)
.from('map_layers')
.delete()
.eq('id', layerId);
if (error) throw error;
}
// ── Pins ──
export async function createMapPin(
supabase: SupabaseClient,
layerId: string,
data: {
label: string;
description?: string;
color?: string;
lat: number;
lng: number;
bounds_north?: number;
bounds_south?: number;
bounds_east?: number;
bounds_west?: number;
sort_order?: number;
},
): Promise<MapPin> {
const { data: pin, error } = await (supabase as any)
.from('map_pins')
.insert({
layer_id: layerId,
...data,
})
.select()
.single();
if (error) throw error;
return pin;
}
export async function updateMapPin(
supabase: SupabaseClient,
pinId: string,
data: Partial<Pick<MapPin, 'label' | 'description' | 'color' | 'lat' | 'lng' | 'bounds_north' | 'bounds_south' | 'bounds_east' | 'bounds_west' | 'sort_order'>>,
): Promise<MapPin> {
const { data: pin, error } = await (supabase as any)
.from('map_pins')
.update({ ...data, updated_at: new Date().toISOString() })
.eq('id', pinId)
.select()
.single();
if (error) throw error;
return pin;
}
export async function deleteMapPin(supabase: SupabaseClient, pinId: string): Promise<void> {
const { error } = await (supabase as any)
.from('map_pins')
.delete()
.eq('id', pinId);
if (error) throw error;
}
// ── Shapes ──
export async function createMapShape(
supabase: SupabaseClient,
layerId: string,
data: {
shape_type: 'polygon' | 'rectangle';
label?: string;
color?: string;
fill_opacity?: number;
stroke_width?: number;
vertices: [number, number][];
rotation?: number;
sort_order?: number;
},
): Promise<MapShape> {
const { data: shape, error } = await (supabase as any)
.from('map_shapes')
.insert({
layer_id: layerId,
...data,
})
.select()
.single();
if (error) throw error;
return shape;
}
export async function updateMapShape(
supabase: SupabaseClient,
shapeId: string,
data: Partial<Pick<MapShape, 'label' | 'color' | 'fill_opacity' | 'stroke_width' | 'vertices' | 'rotation' | 'sort_order'>>,
): Promise<MapShape> {
const { data: shape, error } = await (supabase as any)
.from('map_shapes')
.update({ ...data, updated_at: new Date().toISOString() })
.eq('id', shapeId)
.select()
.single();
if (error) throw error;
return shape;
}
export async function deleteMapShape(supabase: SupabaseClient, shapeId: string): Promise<void> {
const { error } = await (supabase as any)
.from('map_shapes')
.delete()
.eq('id', shapeId);
if (error) throw error;
}

View File

@@ -0,0 +1,203 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchOrgContacts,
createOrgContact,
updateOrgContact,
deleteOrgContact,
fetchPinnedContacts,
pinContact,
unpinContact,
} from './org-contacts';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', '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;
}
function mockDeleteSupabase(resolvedValue: { data: any; error: any }) {
const chain: any = {};
for (const m of ['from', 'delete', 'eq']) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve(resolvedValue);
return { from: vi.fn(() => chain) } as any;
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('org-contacts API', () => {
describe('fetchOrgContacts', () => {
it('should return contacts ordered by name', async () => {
const contacts = [
{ id: 'c1', name: 'Alice', org_id: 'o1', category: 'vendor' },
{ id: 'c2', name: 'Bob', org_id: 'o1', category: 'general' },
];
const supabase = mockSupabase({ data: contacts, error: null });
const result = await fetchOrgContacts(supabase, 'o1');
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Alice');
expect(result[1].name).toBe('Bob');
});
it('should return empty array when data is null', async () => {
const supabase = mockSupabase({ data: null, error: null });
const result = await fetchOrgContacts(supabase, 'o1');
expect(result).toEqual([]);
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'fetch fail' } });
await expect(fetchOrgContacts(supabase, 'o1')).rejects.toEqual({ message: 'fetch fail' });
});
});
describe('createOrgContact', () => {
it('should create a contact with defaults', async () => {
const created = { id: 'c1', name: 'Test', org_id: 'o1', category: 'general', color: '#00A3E0' };
const supabase = mockSupabase({ data: created, error: null });
const result = await createOrgContact(supabase, 'o1', { name: 'Test' });
expect(result.id).toBe('c1');
expect(result.category).toBe('general');
});
it('should create a contact with all fields', async () => {
const created = {
id: 'c2', name: 'Full', org_id: 'o1', role: 'Manager', company: 'Acme',
email: 'a@b.com', phone: '+1234', website: 'https://acme.com',
notes: 'VIP', category: 'sponsor', color: '#FF0000',
};
const supabase = mockSupabase({ data: created, error: null });
const result = await createOrgContact(supabase, 'o1', {
name: 'Full', role: 'Manager', company: 'Acme',
email: 'a@b.com', phone: '+1234', website: 'https://acme.com',
notes: 'VIP', category: 'sponsor', color: '#FF0000',
});
expect(result.role).toBe('Manager');
expect(result.company).toBe('Acme');
expect(result.category).toBe('sponsor');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'create fail' } });
await expect(createOrgContact(supabase, 'o1', { name: 'X' })).rejects.toEqual({ message: 'create fail' });
});
});
describe('updateOrgContact', () => {
it('should update and return the contact', async () => {
const updated = { id: 'c1', name: 'Updated', org_id: 'o1', category: 'vendor' };
const supabase = mockSupabase({ data: updated, error: null });
const result = await updateOrgContact(supabase, 'c1', { name: 'Updated', category: 'vendor' });
expect(result.name).toBe('Updated');
expect(result.category).toBe('vendor');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'update fail' } });
await expect(updateOrgContact(supabase, 'c1', { name: 'X' })).rejects.toEqual({ message: 'update fail' });
});
});
describe('deleteOrgContact', () => {
it('should delete without error', async () => {
const supabase = mockDeleteSupabase({ data: null, error: null });
await expect(deleteOrgContact(supabase, 'c1')).resolves.toBeUndefined();
});
it('should throw on error', async () => {
const supabase = mockDeleteSupabase({ data: null, error: { message: 'delete fail' } });
await expect(deleteOrgContact(supabase, 'c1')).rejects.toEqual({ message: 'delete fail' });
});
});
describe('fetchPinnedContacts', () => {
it('should return pinned contacts for a department', async () => {
const pins = [
{ id: 'p1', department_id: 'd1', contact_id: 'c1' },
{ id: 'p2', department_id: 'd1', contact_id: 'c2' },
];
const supabase = mockSupabase({ data: pins, error: null });
const result = await fetchPinnedContacts(supabase, 'd1');
expect(result).toHaveLength(2);
expect(result[0].contact_id).toBe('c1');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'pin fetch fail' } });
await expect(fetchPinnedContacts(supabase, 'd1')).rejects.toEqual({ message: 'pin fetch fail' });
});
});
describe('pinContact', () => {
it('should pin a contact and return the record', async () => {
const pinned = { id: 'p1', department_id: 'd1', contact_id: 'c1' };
const supabase = mockSupabase({ data: pinned, error: null });
const result = await pinContact(supabase, 'd1', 'c1');
expect(result.department_id).toBe('d1');
expect(result.contact_id).toBe('c1');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'pin fail' } });
await expect(pinContact(supabase, 'd1', 'c1')).rejects.toEqual({ message: 'pin fail' });
});
});
describe('unpinContact', () => {
it('should unpin without error', async () => {
// unpinContact chains .delete().eq().eq() - no .single()
const chain: any = {};
for (const m of ['from', 'delete', 'eq']) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: null });
const supabase = { from: vi.fn(() => chain) } as any;
await expect(unpinContact(supabase, 'd1', 'c1')).resolves.toBeUndefined();
});
it('should throw on error', async () => {
const chain: any = {};
for (const m of ['from', 'delete', 'eq']) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'unpin fail' } });
const supabase = { from: vi.fn(() => chain) } as any;
await expect(unpinContact(supabase, 'd1', 'c1')).rejects.toEqual({ message: 'unpin fail' });
});
});
});

158
src/lib/api/org-contacts.ts Normal file
View File

@@ -0,0 +1,158 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { OrgContact, DepartmentPinnedContact } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.org-contacts');
function db(supabase: SupabaseClient) {
return supabase as any;
}
// ============================================================
// Org Contacts CRUD
// ============================================================
export async function fetchOrgContacts(
supabase: SupabaseClient,
orgId: string
): Promise<OrgContact[]> {
const { data, error } = await db(supabase)
.from('org_contacts')
.select('*')
.eq('org_id', orgId)
.order('name');
if (error) {
log.error('fetchOrgContacts failed', { error, data: { orgId } });
throw error;
}
return (data ?? []) as OrgContact[];
}
export async function createOrgContact(
supabase: SupabaseClient,
orgId: string,
params: {
name: string;
role?: string;
company?: string;
email?: string;
phone?: string;
website?: string;
notes?: string;
category?: string;
color?: string;
}
): Promise<OrgContact> {
const { data, error } = await db(supabase)
.from('org_contacts')
.insert({
org_id: orgId,
name: params.name,
role: params.role ?? null,
company: params.company ?? null,
email: params.email ?? null,
phone: params.phone ?? null,
website: params.website ?? null,
notes: params.notes ?? null,
category: params.category ?? 'general',
color: params.color ?? '#00A3E0',
})
.select()
.single();
if (error) {
log.error('createOrgContact failed', { error, data: { orgId, name: params.name } });
throw error;
}
return data as OrgContact;
}
export async function updateOrgContact(
supabase: SupabaseClient,
contactId: string,
params: Partial<Pick<OrgContact, 'name' | 'role' | 'company' | 'email' | 'phone' | 'website' | 'notes' | 'category' | 'color'>>
): Promise<OrgContact> {
const { data, error } = await db(supabase)
.from('org_contacts')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', contactId)
.select()
.single();
if (error) {
log.error('updateOrgContact failed', { error, data: { contactId } });
throw error;
}
return data as OrgContact;
}
export async function deleteOrgContact(
supabase: SupabaseClient,
contactId: string
): Promise<void> {
const { error } = await db(supabase)
.from('org_contacts')
.delete()
.eq('id', contactId);
if (error) {
log.error('deleteOrgContact failed', { error, data: { contactId } });
throw error;
}
}
// ============================================================
// Department Pinned Contacts
// ============================================================
export async function fetchPinnedContacts(
supabase: SupabaseClient,
departmentId: string
): Promise<DepartmentPinnedContact[]> {
const { data, error } = await db(supabase)
.from('department_pinned_contacts')
.select('*')
.eq('department_id', departmentId);
if (error) {
log.error('fetchPinnedContacts failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as DepartmentPinnedContact[];
}
export async function pinContact(
supabase: SupabaseClient,
departmentId: string,
contactId: string
): Promise<DepartmentPinnedContact> {
const { data, error } = await db(supabase)
.from('department_pinned_contacts')
.insert({ department_id: departmentId, contact_id: contactId })
.select()
.single();
if (error) {
log.error('pinContact failed', { error, data: { departmentId, contactId } });
throw error;
}
return data as DepartmentPinnedContact;
}
export async function unpinContact(
supabase: SupabaseClient,
departmentId: string,
contactId: string
): Promise<void> {
const { error } = await db(supabase)
.from('department_pinned_contacts')
.delete()
.eq('department_id', departmentId)
.eq('contact_id', contactId);
if (error) {
log.error('unpinContact failed', { error, data: { departmentId, contactId } });
throw error;
}
}

View File

@@ -0,0 +1,250 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchUserOrganizations,
createOrganization,
updateOrganization,
deleteOrganization,
fetchOrgMembers,
inviteMember,
updateMemberRole,
removeMember,
generateSlug,
} from './organizations';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'not', 'order', 'single', 'in'];
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;
}
// ── generateSlug (pure function) ─────────────────────────────────────────────
describe('generateSlug', () => {
it('lowercases and replaces spaces with hyphens', () => {
expect(generateSlug('My Organization')).toBe('my-organization');
});
it('removes special characters', () => {
expect(generateSlug('Test & Co. (2024)')).toBe('test-co-2024');
});
it('trims leading/trailing hyphens', () => {
expect(generateSlug('---hello---')).toBe('hello');
});
it('truncates to 50 characters', () => {
const long = 'a'.repeat(100);
expect(generateSlug(long).length).toBeLessThanOrEqual(50);
});
it('handles empty string', () => {
expect(generateSlug('')).toBe('');
});
it('collapses multiple hyphens', () => {
expect(generateSlug('hello world')).toBe('hello-world');
});
});
// ── fetchUserOrganizations ───────────────────────────────────────────────────
describe('fetchUserOrganizations', () => {
it('returns orgs with roles', async () => {
const data = [
{ role: 'admin', organizations: { id: 'o1', name: 'Org1', slug: 'org1' } },
];
const sb = mockSupabase({ data, error: null });
const result = await fetchUserOrganizations(sb);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('admin');
expect(result[0].id).toBe('o1');
});
it('filters out null organizations', async () => {
const data = [
{ role: 'admin', organizations: { id: 'o1', name: 'Org1', slug: 'org1' } },
{ role: 'viewer', organizations: null },
];
const sb = mockSupabase({ data, error: null });
const result = await fetchUserOrganizations(sb);
expect(result).toHaveLength(1);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchUserOrganizations(sb)).rejects.toEqual({ message: 'fail' });
});
});
// ── createOrganization ───────────────────────────────────────────────────────
describe('createOrganization', () => {
it('creates and returns org', async () => {
const org = { id: 'o1', name: 'New Org', slug: 'new-org' };
const sb = mockSupabase({ data: org, error: null });
expect(await createOrganization(sb, 'New Org', 'new-org')).toEqual(org);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'dup slug' } });
await expect(createOrganization(sb, 'X', 'x')).rejects.toEqual({ message: 'dup slug' });
});
});
// ── updateOrganization ───────────────────────────────────────────────────────
describe('updateOrganization', () => {
it('updates and returns org', async () => {
const org = { id: 'o1', name: 'Updated' };
const sb = mockSupabase({ data: org, error: null });
expect(await updateOrganization(sb, 'o1', { name: 'Updated' })).toEqual(org);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateOrganization(sb, 'o1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
// ── deleteOrganization ───────────────────────────────────────────────────────
describe('deleteOrganization', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteOrganization(sb, 'o1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteOrganization(sb, 'o1')).rejects.toEqual({ message: 'fail' });
});
});
// ── fetchOrgMembers ──────────────────────────────────────────────────────────
describe('fetchOrgMembers', () => {
it('returns members', async () => {
const members = [{ id: 'm1', user_id: 'u1', role: 'admin' }];
const sb = mockSupabase({ data: members, error: null });
expect(await fetchOrgMembers(sb, 'o1')).toEqual(members);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchOrgMembers(sb, 'o1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchOrgMembers(sb, 'o1')).rejects.toEqual({ message: 'fail' });
});
});
// ── inviteMember ─────────────────────────────────────────────────────────────
describe('inviteMember', () => {
it('throws when user not found', async () => {
const sb = mockSupabase({ data: null, error: { message: 'not found' } });
await expect(inviteMember(sb, 'o1', 'nobody@test.com')).rejects.toThrow('User not found');
});
it('throws when user is already a member', async () => {
const profile = { id: 'u1', email: 'a@b.com' };
const existing = { id: 'om1' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', '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: profile, error: null }); // profile lookup
if (singleIdx === 2) return Promise.resolve({ data: existing, error: null }); // already member
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
await expect(inviteMember(sb, 'o1', 'a@b.com')).rejects.toThrow('already a member');
});
it('invites successfully when user exists and is not a member', async () => {
const profile = { id: 'u1', email: 'a@b.com' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', '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: profile, error: null }); // profile
if (singleIdx === 2) return Promise.resolve({ data: null, error: null }); // not a member
return Promise.resolve({ data: null, error: null });
});
chain.then = (resolve: any) => resolve({ data: null, error: null }); // insert success
const sb = { from: vi.fn(() => chain) } as any;
await expect(inviteMember(sb, 'o1', 'a@b.com')).resolves.toBeUndefined();
});
it('throws on insert error', async () => {
const profile = { id: 'u1', email: 'a@b.com' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', '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: profile, error: null });
if (singleIdx === 2) return Promise.resolve({ data: null, error: null });
return Promise.resolve({ data: null, error: null });
});
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'insert fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(inviteMember(sb, 'o1', 'a@b.com')).rejects.toEqual({ message: 'insert fail' });
});
});
// ── updateMemberRole ─────────────────────────────────────────────────────────
describe('updateMemberRole', () => {
it('updates without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(updateMemberRole(sb, 'm1', 'editor')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateMemberRole(sb, 'm1', 'admin')).rejects.toEqual({ message: 'fail' });
});
});
// ── removeMember ─────────────────────────────────────────────────────────────
describe('removeMember', () => {
it('removes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(removeMember(sb, 'm1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(removeMember(sb, 'm1')).rejects.toEqual({ message: 'fail' });
});
});

View File

@@ -0,0 +1,171 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchStages,
createStage,
updateStage,
deleteStage,
fetchBlocks,
createBlock,
updateBlock,
deleteBlock,
} from './schedule';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', '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;
}
// ── Stages ───────────────────────────────────────────────────────────────────
describe('fetchStages', () => {
it('returns stages for a department', async () => {
const stages = [{ id: 's1', name: 'Main Stage', department_id: 'd1' }];
const sb = mockSupabase({ data: stages, error: null });
expect(await fetchStages(sb, 'd1')).toEqual(stages);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchStages(sb, 'd1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchStages(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createStage', () => {
it('creates with default color', async () => {
const stage = { id: 's1', name: 'VIP', color: '#6366f1' };
const sb = mockSupabase({ data: stage, error: null });
expect(await createStage(sb, 'd1', 'VIP')).toEqual(stage);
});
it('creates with custom color', async () => {
const stage = { id: 's2', name: 'Outdoor', color: '#00ff00' };
const sb = mockSupabase({ data: stage, error: null });
expect(await createStage(sb, 'd1', 'Outdoor', '#00ff00')).toEqual(stage);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createStage(sb, 'd1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateStage', () => {
it('updates and returns stage', async () => {
const stage = { id: 's1', name: 'Renamed' };
const sb = mockSupabase({ data: stage, error: null });
expect(await updateStage(sb, 's1', { name: 'Renamed' })).toEqual(stage);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateStage(sb, 's1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteStage', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteStage(sb, 's1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteStage(sb, 's1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Blocks ───────────────────────────────────────────────────────────────────
describe('fetchBlocks', () => {
it('returns blocks for a department', async () => {
const blocks = [{ id: 'b1', title: 'Opening', department_id: 'd1' }];
const sb = mockSupabase({ data: blocks, error: null });
expect(await fetchBlocks(sb, 'd1')).toEqual(blocks);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchBlocks(sb, 'd1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchBlocks(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createBlock', () => {
it('creates a block with minimal params', async () => {
const block = { id: 'b1', title: 'Keynote', start_time: '09:00', end_time: '10:00' };
const sb = mockSupabase({ data: block, error: null });
const result = await createBlock(sb, 'd1', {
title: 'Keynote',
start_time: '09:00',
end_time: '10:00',
});
expect(result).toEqual(block);
});
it('creates a block with all params', async () => {
const block = { id: 'b2', title: 'Panel', speaker: 'John' };
const sb = mockSupabase({ data: block, error: null });
const result = await createBlock(sb, 'd1', {
title: 'Panel',
start_time: '10:00',
end_time: '11:00',
stage_id: 's1',
description: 'A panel',
color: '#ff0000',
speaker: 'John',
}, 'user1');
expect(result).toEqual(block);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createBlock(sb, 'd1', { title: 'X', start_time: '09:00', end_time: '10:00' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateBlock', () => {
it('updates and returns block', async () => {
const block = { id: 'b1', title: 'Updated' };
const sb = mockSupabase({ data: block, error: null });
expect(await updateBlock(sb, 'b1', { title: 'Updated' })).toEqual(block);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateBlock(sb, 'b1', { title: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteBlock', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteBlock(sb, 'b1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteBlock(sb, 'b1')).rejects.toEqual({ message: 'fail' });
});
});

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchEventSponsorAllocations,
fetchDepartmentSponsorAllocations,
createSponsorAllocation,
updateSponsorAllocation,
deleteSponsorAllocation,
} from './sponsor-allocations';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
// Terminal - resolve the promise
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
// For non-single queries, make the chain itself thenable
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;
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('sponsor-allocations API', () => {
describe('fetchEventSponsorAllocations', () => {
it('should return allocations stripped of join data', async () => {
const raw = [
{ id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 500, used_amount: 100, event_departments: { event_id: 'e1' } },
{ id: 'a2', sponsor_id: 's2', department_id: 'd2', allocated_amount: 300, used_amount: 0, event_departments: { event_id: 'e1' } },
];
const supabase = mockSupabase({ data: raw, error: null });
const result = await fetchEventSponsorAllocations(supabase, 'e1');
expect(result).toHaveLength(2);
expect(result[0]).not.toHaveProperty('event_departments');
expect(result[0].id).toBe('a1');
expect(result[1].allocated_amount).toBe(300);
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEventSponsorAllocations(supabase, 'e1')).rejects.toEqual({ message: 'fail' });
});
it('should return empty array when data is null', async () => {
const supabase = mockSupabase({ data: null, error: null });
const result = await fetchEventSponsorAllocations(supabase, 'e1');
expect(result).toEqual([]);
});
});
describe('fetchDepartmentSponsorAllocations', () => {
it('should return allocations for a department', async () => {
const raw = [{ id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 200, used_amount: 50 }];
const supabase = mockSupabase({ data: raw, error: null });
const result = await fetchDepartmentSponsorAllocations(supabase, 'd1');
expect(result).toHaveLength(1);
expect(result[0].department_id).toBe('d1');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'dept fail' } });
await expect(fetchDepartmentSponsorAllocations(supabase, 'd1')).rejects.toEqual({ message: 'dept fail' });
});
});
describe('createSponsorAllocation', () => {
it('should create and return an allocation', async () => {
const created = { id: 'new1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 1000, used_amount: 0, notes: null };
const supabase = mockSupabase({ data: created, error: null });
const result = await createSponsorAllocation(supabase, {
sponsor_id: 's1',
department_id: 'd1',
allocated_amount: 1000,
});
expect(result.id).toBe('new1');
expect(result.allocated_amount).toBe(1000);
expect(result.used_amount).toBe(0);
});
it('should pass used_amount and notes when provided', async () => {
const created = { id: 'new2', sponsor_id: 's1', department_id: 'd1', allocated_amount: 500, used_amount: 100, notes: 'test' };
const supabase = mockSupabase({ data: created, error: null });
const result = await createSponsorAllocation(supabase, {
sponsor_id: 's1',
department_id: 'd1',
allocated_amount: 500,
used_amount: 100,
notes: 'test',
});
expect(result.used_amount).toBe(100);
expect(result.notes).toBe('test');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'create fail' } });
await expect(
createSponsorAllocation(supabase, { sponsor_id: 's1', department_id: 'd1', allocated_amount: 100 })
).rejects.toEqual({ message: 'create fail' });
});
});
describe('updateSponsorAllocation', () => {
it('should update and return the allocation', async () => {
const updated = { id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 750, used_amount: 200, notes: 'updated' };
const supabase = mockSupabase({ data: updated, error: null });
const result = await updateSponsorAllocation(supabase, 'a1', { allocated_amount: 750, used_amount: 200, notes: 'updated' });
expect(result.allocated_amount).toBe(750);
expect(result.notes).toBe('updated');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'update fail' } });
await expect(updateSponsorAllocation(supabase, 'a1', { allocated_amount: 100 })).rejects.toEqual({ message: 'update fail' });
});
});
describe('deleteSponsorAllocation', () => {
it('should delete without error', async () => {
// delete doesn't call .single(), so we need a different mock
const chain: any = {};
const methods = ['from', 'delete', 'eq'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: null });
const supabase = { from: vi.fn(() => chain) } as any;
await expect(deleteSponsorAllocation(supabase, 'a1')).resolves.toBeUndefined();
});
it('should throw on error', async () => {
const chain: any = {};
const methods = ['from', 'delete', 'eq'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'delete fail' } });
const supabase = { from: vi.fn(() => chain) } as any;
await expect(deleteSponsorAllocation(supabase, 'a1')).rejects.toEqual({ message: 'delete fail' });
});
});
});

View File

@@ -0,0 +1,115 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { SponsorAllocation } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.sponsor-allocations');
function db(supabase: SupabaseClient) {
return supabase as any;
}
// ============================================================
// Fetch allocations for an event (via departments)
// ============================================================
export async function fetchEventSponsorAllocations(
supabase: SupabaseClient,
eventId: string
): Promise<SponsorAllocation[]> {
const { data, error } = await db(supabase)
.from('sponsor_allocations')
.select('*, event_departments!inner(event_id)')
.eq('event_departments.event_id', eventId);
if (error) {
log.error('fetchEventSponsorAllocations failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { event_departments, ...alloc } = d;
return alloc;
}) as SponsorAllocation[];
}
export async function fetchDepartmentSponsorAllocations(
supabase: SupabaseClient,
departmentId: string
): Promise<SponsorAllocation[]> {
const { data, error } = await db(supabase)
.from('sponsor_allocations')
.select('*')
.eq('department_id', departmentId);
if (error) {
log.error('fetchDepartmentSponsorAllocations failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as SponsorAllocation[];
}
// ============================================================
// CRUD
// ============================================================
export async function createSponsorAllocation(
supabase: SupabaseClient,
params: {
sponsor_id: string;
department_id: string;
allocated_amount: number;
used_amount?: number;
notes?: string;
}
): Promise<SponsorAllocation> {
const { data, error } = await db(supabase)
.from('sponsor_allocations')
.insert({
sponsor_id: params.sponsor_id,
department_id: params.department_id,
allocated_amount: params.allocated_amount,
used_amount: params.used_amount ?? 0,
notes: params.notes ?? null,
})
.select()
.single();
if (error) {
log.error('createSponsorAllocation failed', { error, data: params });
throw error;
}
return data as SponsorAllocation;
}
export async function updateSponsorAllocation(
supabase: SupabaseClient,
allocationId: string,
params: Partial<Pick<SponsorAllocation, 'allocated_amount' | 'used_amount' | 'notes'>>
): Promise<SponsorAllocation> {
const { data, error } = await db(supabase)
.from('sponsor_allocations')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', allocationId)
.select()
.single();
if (error) {
log.error('updateSponsorAllocation failed', { error, data: { allocationId } });
throw error;
}
return data as SponsorAllocation;
}
export async function deleteSponsorAllocation(
supabase: SupabaseClient,
allocationId: string
): Promise<void> {
const { error } = await db(supabase)
.from('sponsor_allocations')
.delete()
.eq('id', allocationId);
if (error) {
log.error('deleteSponsorAllocation failed', { error, data: { allocationId } });
throw error;
}
}

View File

@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchEventSponsorTiers,
fetchEventSponsors,
fetchEventDeliverables,
} from './sponsors';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', '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;
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('event-wide sponsor fetching', () => {
describe('fetchEventSponsorTiers', () => {
it('should return tiers stripped of join data', async () => {
const raw = [
{ id: 't1', name: 'Gold', sort_order: 0, event_departments: { event_id: 'e1' } },
{ id: 't2', name: 'Silver', sort_order: 1, event_departments: { event_id: 'e1' } },
];
const supabase = mockSupabase({ data: raw, error: null });
const result = await fetchEventSponsorTiers(supabase, 'e1');
expect(result).toHaveLength(2);
expect(result[0]).not.toHaveProperty('event_departments');
expect(result[0].name).toBe('Gold');
expect(result[1].name).toBe('Silver');
});
it('should return empty array when data is null', async () => {
const supabase = mockSupabase({ data: null, error: null });
const result = await fetchEventSponsorTiers(supabase, 'e1');
expect(result).toEqual([]);
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'tier fail' } });
await expect(fetchEventSponsorTiers(supabase, 'e1')).rejects.toEqual({ message: 'tier fail' });
});
});
describe('fetchEventSponsors', () => {
it('should return sponsors stripped of join data', async () => {
const raw = [
{ id: 's1', name: 'Acme', amount: 5000, status: 'confirmed', event_departments: { event_id: 'e1' } },
{ id: 's2', name: 'Beta', amount: 2000, status: 'prospect', event_departments: { event_id: 'e1' } },
];
const supabase = mockSupabase({ data: raw, error: null });
const result = await fetchEventSponsors(supabase, 'e1');
expect(result).toHaveLength(2);
expect(result[0]).not.toHaveProperty('event_departments');
expect(result[0].name).toBe('Acme');
expect(result[0].amount).toBe(5000);
});
it('should return empty array when data is null', async () => {
const supabase = mockSupabase({ data: null, error: null });
const result = await fetchEventSponsors(supabase, 'e1');
expect(result).toEqual([]);
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'sponsor fail' } });
await expect(fetchEventSponsors(supabase, 'e1')).rejects.toEqual({ message: 'sponsor fail' });
});
});
describe('fetchEventDeliverables', () => {
it('should return deliverables stripped of join data', async () => {
const raw = [
{ id: 'd1', description: 'Logo placement', is_completed: false, sponsors: { department_id: 'dep1', event_departments: { event_id: 'e1' } } },
];
const supabase = mockSupabase({ data: raw, error: null });
const result = await fetchEventDeliverables(supabase, 'e1');
expect(result).toHaveLength(1);
expect(result[0]).not.toHaveProperty('sponsors');
expect(result[0].description).toBe('Logo placement');
});
it('should return empty array when data is null', async () => {
const supabase = mockSupabase({ data: null, error: null });
const result = await fetchEventDeliverables(supabase, 'e1');
expect(result).toEqual([]);
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'deliverable fail' } });
await expect(fetchEventDeliverables(supabase, 'e1')).rejects.toEqual({ message: 'deliverable fail' });
});
});
});

View File

@@ -0,0 +1,287 @@
import { describe, it, expect, vi } from 'vitest';
import {
SPONSOR_STATUSES,
STATUS_LABELS,
STATUS_COLORS,
fetchSponsorTiers,
createSponsorTier,
updateSponsorTier,
deleteSponsorTier,
fetchSponsors,
createSponsor,
updateSponsor,
deleteSponsor,
fetchDeliverables,
fetchAllDeliverables,
createDeliverable,
updateDeliverable,
deleteDeliverable,
} from './sponsors';
// ── 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;
}
// ── Constants ────────────────────────────────────────────────────────────────
describe('sponsor constants', () => {
it('SPONSOR_STATUSES has expected entries', () => {
expect(SPONSOR_STATUSES).toContain('prospect');
expect(SPONSOR_STATUSES).toContain('confirmed');
expect(SPONSOR_STATUSES).toContain('declined');
expect(SPONSOR_STATUSES).toContain('active');
expect(SPONSOR_STATUSES.length).toBe(5);
});
it('STATUS_LABELS has a label for every status', () => {
for (const s of SPONSOR_STATUSES) {
expect(STATUS_LABELS[s]).toBeDefined();
expect(typeof STATUS_LABELS[s]).toBe('string');
}
});
it('STATUS_COLORS has a color for every status', () => {
for (const s of SPONSOR_STATUSES) {
expect(STATUS_COLORS[s]).toBeDefined();
expect(STATUS_COLORS[s]).toMatch(/^#/);
}
});
});
// ── Sponsor Tiers ────────────────────────────────────────────────────────────
describe('fetchSponsorTiers', () => {
it('returns tiers for a department', async () => {
const tiers = [{ id: 't1', name: 'Gold', department_id: 'd1' }];
const sb = mockSupabase({ data: tiers, error: null });
expect(await fetchSponsorTiers(sb, 'd1')).toEqual(tiers);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchSponsorTiers(sb, 'd1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchSponsorTiers(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createSponsorTier', () => {
it('creates with default color', async () => {
const tier = { id: 't1', name: 'Silver', amount: 0, color: '#F59E0B' };
const sb = mockSupabase({ data: tier, error: null });
expect(await createSponsorTier(sb, 'd1', 'Silver')).toEqual(tier);
});
it('creates with custom amount and color', async () => {
const tier = { id: 't2', name: 'Platinum', amount: 10000, color: '#00ff00' };
const sb = mockSupabase({ data: tier, error: null });
expect(await createSponsorTier(sb, 'd1', 'Platinum', 10000, '#00ff00')).toEqual(tier);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createSponsorTier(sb, 'd1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateSponsorTier', () => {
it('updates and returns tier', async () => {
const tier = { id: 't1', name: 'Updated' };
const sb = mockSupabase({ data: tier, error: null });
expect(await updateSponsorTier(sb, 't1', { name: 'Updated' })).toEqual(tier);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateSponsorTier(sb, 't1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteSponsorTier', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteSponsorTier(sb, 't1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteSponsorTier(sb, 't1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Sponsors ─────────────────────────────────────────────────────────────────
describe('fetchSponsors', () => {
it('returns sponsors for a department', async () => {
const sponsors = [{ id: 's1', name: 'Acme', department_id: 'd1' }];
const sb = mockSupabase({ data: sponsors, error: null });
expect(await fetchSponsors(sb, 'd1')).toEqual(sponsors);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchSponsors(sb, 'd1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchSponsors(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createSponsor', () => {
it('creates with minimal params', async () => {
const sponsor = { id: 's1', name: 'Acme', status: 'prospect', amount: 0 };
const sb = mockSupabase({ data: sponsor, error: null });
expect(await createSponsor(sb, 'd1', { name: 'Acme' })).toEqual(sponsor);
});
it('creates with all params', async () => {
const sponsor = { id: 's2', name: 'BigCo', status: 'confirmed', amount: 5000 };
const sb = mockSupabase({ data: sponsor, error: null });
expect(await createSponsor(sb, 'd1', {
name: 'BigCo',
tier_id: 't1',
contact_name: 'John',
contact_email: 'john@bigco.com',
contact_phone: '+1234',
website: 'https://bigco.com',
logo_url: 'https://bigco.com/logo.png',
status: 'confirmed',
amount: 5000,
notes: 'VIP sponsor',
})).toEqual(sponsor);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createSponsor(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateSponsor', () => {
it('updates and returns sponsor', async () => {
const sponsor = { id: 's1', name: 'Updated' };
const sb = mockSupabase({ data: sponsor, error: null });
expect(await updateSponsor(sb, 's1', { name: 'Updated' })).toEqual(sponsor);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateSponsor(sb, 's1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteSponsor', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteSponsor(sb, 's1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteSponsor(sb, 's1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Deliverables ─────────────────────────────────────────────────────────────
describe('fetchDeliverables', () => {
it('returns deliverables for a sponsor', async () => {
const delivs = [{ id: 'dl1', description: 'Logo placement', sponsor_id: 's1' }];
const sb = mockSupabase({ data: delivs, error: null });
expect(await fetchDeliverables(sb, 's1')).toEqual(delivs);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchDeliverables(sb, 's1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchDeliverables(sb, 's1')).rejects.toEqual({ message: 'fail' });
});
});
describe('fetchAllDeliverables', () => {
it('returns empty array for empty sponsor list', async () => {
const sb = mockSupabase({ data: [], error: null });
expect(await fetchAllDeliverables(sb, [])).toEqual([]);
});
it('returns deliverables for multiple sponsors', async () => {
const delivs = [
{ id: 'dl1', sponsor_id: 's1' },
{ id: 'dl2', sponsor_id: 's2' },
];
const sb = mockSupabase({ data: delivs, error: null });
expect(await fetchAllDeliverables(sb, ['s1', 's2'])).toEqual(delivs);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchAllDeliverables(sb, ['s1'])).rejects.toEqual({ message: 'fail' });
});
});
describe('createDeliverable', () => {
it('creates with description only', async () => {
const deliv = { id: 'dl1', description: 'Banner', sponsor_id: 's1' };
const sb = mockSupabase({ data: deliv, error: null });
expect(await createDeliverable(sb, 's1', 'Banner')).toEqual(deliv);
});
it('creates with due date', async () => {
const deliv = { id: 'dl2', description: 'Video', sponsor_id: 's1', due_date: '2025-06-01' };
const sb = mockSupabase({ data: deliv, error: null });
expect(await createDeliverable(sb, 's1', 'Video', '2025-06-01')).toEqual(deliv);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createDeliverable(sb, 's1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateDeliverable', () => {
it('updates and returns deliverable', async () => {
const deliv = { id: 'dl1', description: 'Updated' };
const sb = mockSupabase({ data: deliv, error: null });
expect(await updateDeliverable(sb, 'dl1', { description: 'Updated' })).toEqual(deliv);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateDeliverable(sb, 'dl1', { description: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteDeliverable', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteDeliverable(sb, 'dl1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteDeliverable(sb, 'dl1')).rejects.toEqual({ message: 'fail' });
});
});

View File

@@ -108,6 +108,70 @@ export async function deleteSponsorTier(
}
}
// ============================================================
// Event-wide Tier + Sponsor fetching
// ============================================================
export async function fetchEventSponsorTiers(
supabase: SupabaseClient,
eventId: string
): Promise<SponsorTier[]> {
const { data, error } = await db(supabase)
.from('sponsor_tiers')
.select('*, event_departments!inner(event_id)')
.eq('event_departments.event_id', eventId)
.order('sort_order');
if (error) {
log.error('fetchEventSponsorTiers failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { event_departments, ...tier } = d;
return tier;
}) as SponsorTier[];
}
export async function fetchEventSponsors(
supabase: SupabaseClient,
eventId: string
): Promise<Sponsor[]> {
const { data, error } = await db(supabase)
.from('sponsors')
.select('*, event_departments!inner(event_id)')
.eq('event_departments.event_id', eventId)
.order('name');
if (error) {
log.error('fetchEventSponsors failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { event_departments, ...sponsor } = d;
return sponsor;
}) as Sponsor[];
}
export async function fetchEventDeliverables(
supabase: SupabaseClient,
eventId: string
): Promise<SponsorDeliverable[]> {
const { data, error } = await db(supabase)
.from('sponsor_deliverables')
.select('*, sponsors!inner(department_id, event_departments!inner(event_id))')
.eq('sponsors.event_departments.event_id', eventId)
.order('sort_order');
if (error) {
log.error('fetchEventDeliverables failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { sponsors, ...del } = d;
return del;
}) as SponsorDeliverable[];
}
// ============================================================
// Sponsors
// ============================================================