feat: map shapes, image persistence, grab tool, layer rename/delete, i18n, page metadata
This commit is contained in:
63
src/lib/api/activity.test.ts
Normal file
63
src/lib/api/activity.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
190
src/lib/api/budget.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
131
src/lib/api/contacts.test.ts
Normal file
131
src/lib/api/contacts.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
311
src/lib/api/department-dashboard.test.ts
Normal file
311
src/lib/api/department-dashboard.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
146
src/lib/api/document-locks.test.ts
Normal file
146
src/lib/api/document-locks.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
206
src/lib/api/event-tasks.test.ts
Normal file
206
src/lib/api/event-tasks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
31
src/lib/api/google-calendar-push.test.ts
Normal file
31
src/lib/api/google-calendar-push.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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
326
src/lib/api/kanban.test.ts
Normal 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
252
src/lib/api/map.ts
Normal 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;
|
||||
}
|
||||
203
src/lib/api/org-contacts.test.ts
Normal file
203
src/lib/api/org-contacts.test.ts
Normal 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
158
src/lib/api/org-contacts.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
250
src/lib/api/organizations.test.ts
Normal file
250
src/lib/api/organizations.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
171
src/lib/api/schedule.test.ts
Normal file
171
src/lib/api/schedule.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
166
src/lib/api/sponsor-allocations.test.ts
Normal file
166
src/lib/api/sponsor-allocations.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
115
src/lib/api/sponsor-allocations.ts
Normal file
115
src/lib/api/sponsor-allocations.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
116
src/lib/api/sponsors-event.test.ts
Normal file
116
src/lib/api/sponsors-event.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
287
src/lib/api/sponsors.test.ts
Normal file
287
src/lib/api/sponsors.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user