mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
246 lines
14 KiB
TypeScript
246 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { InstalledPlugin } from '../plugin-types';
|
|
import type { IJMAPClient } from '../jmap/client-interface';
|
|
import type { Calendar, CalendarEvent, CalendarTask } from '../jmap/types';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useCalendarStore } from '@/stores/calendar-store';
|
|
import { useTaskStore } from '@/stores/task-store';
|
|
import { useIdentityStore } from '@/stores/identity-store';
|
|
import { useSettingsStore } from '@/stores/settings-store';
|
|
import { calendarHooks, taskHooks } from '../plugin-hooks';
|
|
|
|
vi.mock('@/stores/toast-store', () => ({
|
|
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
|
}));
|
|
|
|
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
|
|
|
function plugin(perms: string[]): InstalledPlugin {
|
|
return {
|
|
id: 'planner', name: 'Planner', version: '1.0.0', author: 'test', description: '',
|
|
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
|
enabled: true, status: 'running', settings: {},
|
|
};
|
|
}
|
|
|
|
const READ = plugin(['calendar:read', 'tasks:read']);
|
|
const WRITE = plugin(['calendar:read', 'calendar:write', 'tasks:read', 'tasks:write']);
|
|
const NONE = plugin([]);
|
|
|
|
const work = { id: 'cal-work', name: 'Work', color: '#f00', isVisible: true, isDefault: true, isTasksOnly: false } as Calendar;
|
|
const todo = { id: 'cal-todo', name: 'To do', color: null, isVisible: true, isDefault: false, isTasksOnly: true } as Calendar;
|
|
|
|
function event(over: Partial<CalendarEvent>): CalendarEvent {
|
|
return {
|
|
id: 'e1', uid: 'u1', calendarIds: { 'cal-work': true }, title: 'Standup', description: '',
|
|
start: '2026-05-04T09:00:00', duration: 'PT15M', timeZone: 'Europe/Berlin', showWithoutTime: false,
|
|
utcStart: '2026-05-04T07:00:00Z', utcEnd: '2026-05-04T07:15:00Z', status: 'confirmed',
|
|
participants: null, recurrenceRules: null, keywords: null, locations: null,
|
|
...over,
|
|
} as CalendarEvent;
|
|
}
|
|
|
|
function task(over: Partial<CalendarTask>): CalendarTask {
|
|
return {
|
|
id: 't1', uid: 'tu1', '@type': 'Task', calendarIds: { 'cal-todo': true }, title: 'Pay rent',
|
|
description: '', due: '2026-05-01', showWithoutTime: true, progress: 'needs-action', priority: 0,
|
|
keywords: null,
|
|
...over,
|
|
} as CalendarTask;
|
|
}
|
|
|
|
let client: Record<string, ReturnType<typeof vi.fn>>;
|
|
|
|
beforeEach(() => {
|
|
client = {
|
|
queryAllCalendarEvents: vi.fn(async () => [
|
|
event({ id: 'late', start: '2026-05-06T10:00:00', utcStart: '2026-05-06T08:00:00Z', calendarIds: { 'cal-home': true } }),
|
|
event({}),
|
|
]),
|
|
getCalendarEvent: vi.fn(async (id: string) => (id === 'e1' ? event({}) : null)),
|
|
createCalendarEvent: vi.fn(async (e: Partial<CalendarEvent>) => event({ ...e, id: 'new-1' })),
|
|
updateCalendarEvent: vi.fn(async () => undefined),
|
|
deleteCalendarEvent: vi.fn(async () => undefined),
|
|
getCalendarTasks: vi.fn(async () => [task({}), task({ id: 't2', title: 'Done', progress: 'completed' })]),
|
|
createCalendarTask: vi.fn(async (t: Partial<CalendarTask>) => task({ ...t, id: 'new-t' })),
|
|
updateCalendarTask: vi.fn(async (id: string) => id),
|
|
deleteCalendarTask: vi.fn(async () => undefined),
|
|
};
|
|
useAuthStore.setState({ client: client as unknown as IJMAPClient });
|
|
useCalendarStore.setState({ calendars: [work, todo], events: [], error: null });
|
|
useTaskStore.setState({ tasks: [] });
|
|
useIdentityStore.setState({
|
|
identities: [{ id: 'id1', name: 'Ann', email: 'ann@example.com' }] as never,
|
|
preferredPrimaryId: null,
|
|
});
|
|
useSettingsStore.setState({ timeZone: 'Europe/Berlin' } as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
useAuthStore.setState({ client: null });
|
|
useCalendarStore.setState({ calendars: [], events: [] });
|
|
useTaskStore.setState({ tasks: [] });
|
|
useIdentityStore.setState({ identities: [] });
|
|
calendarHooks.onBeforeEventCreate.removePlugin('veto');
|
|
taskHooks.onBeforeTaskDelete.removePlugin('veto');
|
|
});
|
|
|
|
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
|
|
|
|
describe('api.calendar', () => {
|
|
it('lists calendars as plain views', async () => {
|
|
expect(await call(READ, 'calendar.listCalendars')).toEqual([
|
|
{ id: 'cal-work', name: 'Work', color: '#f00', isVisible: true, isDefault: true, isTasksOnly: false },
|
|
{ id: 'cal-todo', name: 'To do', color: '', isVisible: true, isDefault: false, isTasksOnly: true },
|
|
]);
|
|
});
|
|
|
|
it('returns events in a range earliest first, filtered by calendar', async () => {
|
|
const all = await call(READ, 'calendar.getEvents', { from: '2026-05-01', to: '2026-06-01' }) as { id: string }[];
|
|
expect(all.map((e) => e.id)).toEqual(['e1', 'late']);
|
|
expect(client.queryAllCalendarEvents).toHaveBeenCalledWith({ after: '2026-05-01T00:00:00', before: '2026-06-01T00:00:00' });
|
|
const work = await call(READ, 'calendar.getEvents', { from: '2026-05-01', to: '2026-06-01', calendarIds: ['cal-work'] }) as { id: string; title: string }[];
|
|
expect(work).toEqual([expect.objectContaining({ id: 'e1', title: 'Standup', calendarId: 'cal-work' })]);
|
|
});
|
|
|
|
it('refuses a range over a year, a reversed range and junk dates', async () => {
|
|
await expect(call(READ, 'calendar.getEvents', { from: '2026-01-01', to: '2027-01-03' })).rejects.toThrow(/at most 366 days/);
|
|
await expect(call(READ, 'calendar.getEvents', { from: '2026-05-02', to: '2026-05-01' })).rejects.toThrow(/after from/);
|
|
await expect(call(READ, 'calendar.getEvents', { from: '2026-02-30', to: '2026-03-01' })).rejects.toThrow(/not a real date/);
|
|
await expect(call(READ, 'calendar.getEvents', { from: 'tomorrow', to: '2026-03-01' })).rejects.toThrow(/must look like/);
|
|
expect(client.queryAllCalendarEvents).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('gets one event or null', async () => {
|
|
expect(await call(READ, 'calendar.getEvent', 'e1')).toEqual(expect.objectContaining({ id: 'e1', title: 'Standup' }));
|
|
expect(await call(READ, 'calendar.getEvent', 'missing')).toBeNull();
|
|
await expect(call(READ, 'calendar.getEvent', 42)).rejects.toThrow(/id must be a string/);
|
|
});
|
|
|
|
it('needs calendar:read to read and calendar:write to change', async () => {
|
|
await expect(call(NONE, 'calendar.getEvents', { from: '2026-05-01', to: '2026-05-02' })).rejects.toThrow(/lacks permission "calendar:read"/);
|
|
await expect(call(READ, 'calendar.createEvent', { title: 'x', start: '2026-05-01' })).rejects.toThrow(/lacks permission "calendar:write"/);
|
|
await expect(call(READ, 'calendar.deleteEvent', 'e1')).rejects.toThrow(/lacks permission "calendar:write"/);
|
|
expect(client.deleteCalendarEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates a timed event from the plugin shape, in the default calendar, without invitations', async () => {
|
|
const created = await call(WRITE, 'calendar.createEvent', {
|
|
title: ' Review ', start: '2026-05-04T14:00', end: '2026-05-04T15:30', location: 'Room 1',
|
|
participants: [{ email: 'bob@example.com', name: 'Bob' }],
|
|
});
|
|
expect(created).toEqual(expect.objectContaining({ id: 'new-1', title: 'Review', location: 'Room 1' }));
|
|
const [sent, sendScheduling] = client.createCalendarEvent.mock.calls[0];
|
|
expect(sendScheduling).toBe(false);
|
|
expect(sent).toEqual(expect.objectContaining({
|
|
title: 'Review', start: '2026-05-04T14:00:00', duration: 'PT1H30M', timeZone: 'Europe/Berlin',
|
|
showWithoutTime: false, calendarIds: { 'cal-work': true }, organizerCalendarAddress: 'mailto:ann@example.com',
|
|
}));
|
|
const emails = Object.values(sent.participants as Record<string, { email: string }>).map((p) => p.email);
|
|
expect(emails).toEqual(['ann@example.com', 'bob@example.com']);
|
|
// The store saw it, so the calendar on screen shows it.
|
|
expect(useCalendarStore.getState().events.map((e) => e.id)).toContain('new-1');
|
|
});
|
|
|
|
it('creates an all-day event with an exclusive end', async () => {
|
|
await call(WRITE, 'calendar.createEvent', { title: 'Trip', start: '2026-05-01', end: '2026-05-04' });
|
|
expect(client.createCalendarEvent.mock.calls[0][0]).toEqual(expect.objectContaining({
|
|
start: '2026-05-01T00:00:00', duration: 'P3D', timeZone: null, showWithoutTime: true,
|
|
}));
|
|
});
|
|
|
|
it('creates a yearly event under a uid in the plugin namespace', async () => {
|
|
await call(WRITE, 'calendar.createEvent', {
|
|
title: 'Birthday of Ada', start: '2026-12-10', uid: 'contact-42', repeat: { frequency: 'yearly' },
|
|
});
|
|
|
|
const sent = client.createCalendarEvent.mock.calls[0][0];
|
|
expect(sent.uid).toMatch(/^contact-42@.+\.plugin\.bulwark$/);
|
|
expect(sent.recurrenceRules).toEqual([expect.objectContaining({ '@type': 'RecurrenceRule', frequency: 'yearly', interval: 1, count: null, until: null })]);
|
|
});
|
|
|
|
it('checks repeat and uid', async () => {
|
|
await expect(call(WRITE, 'calendar.createEvent', { title: 'x', start: '2026-05-01', repeat: { frequency: 'hourly' } })).rejects.toThrow(/repeat.frequency/);
|
|
await expect(call(WRITE, 'calendar.createEvent', { title: 'x', start: '2026-05-01', repeat: { frequency: 'daily', count: 3, until: '2026-06-01' } })).rejects.toThrow(/not both/);
|
|
await expect(call(WRITE, 'calendar.createEvent', { title: 'x', start: '2026-05-01', uid: 'a b' })).rejects.toThrow(/uid must be/);
|
|
await expect(call(WRITE, 'calendar.updateEvent', 'e1', { uid: 'other' })).rejects.toThrow(/unknown property "uid"/);
|
|
expect(client.createCalendarEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses arbitrary JSCalendar fields and bad input', async () => {
|
|
await expect(call(WRITE, 'calendar.createEvent', { title: 'x', start: '2026-05-01', recurrenceRules: [] }))
|
|
.rejects.toThrow(/unknown property "recurrenceRules"/);
|
|
await expect(call(WRITE, 'calendar.createEvent', { title: '', start: '2026-05-01' })).rejects.toThrow(/title must be 1-500/);
|
|
await expect(call(WRITE, 'calendar.createEvent', { title: 'x', start: '2026-05-01T10:00', timeZone: 'Mars/Base' }))
|
|
.rejects.toThrow(/not a known time zone/);
|
|
await expect(call(WRITE, 'calendar.createEvent', { title: 'x', start: '2026-05-01T10:00', end: '2026-05-01T09:00' }))
|
|
.rejects.toThrow(/end must be after start/);
|
|
await expect(call(WRITE, 'calendar.createEvent', { title: 'x', start: '2026-05-01', calendarId: 'nope' }))
|
|
.rejects.toThrow(/no calendar with id "nope"/);
|
|
expect(client.createCalendarEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('goes through the store, so another plugin can stop a create', async () => {
|
|
calendarHooks.onBeforeEventCreate.register('veto', () => false);
|
|
expect(await call(WRITE, 'calendar.createEvent', { title: 'x', start: '2026-05-01' })).toBeNull();
|
|
expect(client.createCalendarEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('updates times against the event as it is and deletes through the store', async () => {
|
|
await call(WRITE, 'calendar.updateEvent', 'e1', { start: '2026-05-04T10:00' });
|
|
expect(client.updateCalendarEvent).toHaveBeenCalledWith('e1', expect.objectContaining({
|
|
start: '2026-05-04T10:00:00', duration: 'PT15M', timeZone: 'Europe/Berlin',
|
|
}), false, undefined);
|
|
await call(WRITE, 'calendar.deleteEvent', 'e1');
|
|
expect(client.deleteCalendarEvent).toHaveBeenCalledWith('e1', false, undefined);
|
|
await expect(call(WRITE, 'calendar.deleteEvent', 'missing')).rejects.toThrow(/no event with id "missing"/);
|
|
await expect(call(WRITE, 'calendar.updateEvent', 'e1', {})).rejects.toThrow(/patch is empty/);
|
|
});
|
|
});
|
|
|
|
describe('api.tasks', () => {
|
|
it('lists open tasks, or all with includeCompleted', async () => {
|
|
expect((await call(READ, 'tasks.list') as { id: string }[]).map((t) => t.id)).toEqual(['t1']);
|
|
expect((await call(READ, 'tasks.list', { includeCompleted: true }) as { id: string }[]).map((t) => t.id)).toEqual(['t1', 't2']);
|
|
expect(await call(READ, 'tasks.get', 't2')).toEqual(expect.objectContaining({ id: 't2', isComplete: true }));
|
|
await expect(call(READ, 'tasks.list', { includeCompleted: 'yes' })).rejects.toThrow(/true or false/);
|
|
});
|
|
|
|
it('needs tasks:read and tasks:write', async () => {
|
|
await expect(call(NONE, 'tasks.list')).rejects.toThrow(/lacks permission "tasks:read"/);
|
|
await expect(call(READ, 'tasks.create', { title: 'x' })).rejects.toThrow(/lacks permission "tasks:write"/);
|
|
expect(client.createCalendarTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates a task in the tasks calendar', async () => {
|
|
const created = await call(WRITE, 'tasks.create', { title: 'Call Bob', due: '2026-05-02T17:00', priority: 1 });
|
|
expect(created).toEqual(expect.objectContaining({ id: 'new-t', title: 'Call Bob', priority: 1 }));
|
|
expect(client.createCalendarTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
title: 'Call Bob', due: '2026-05-02T17:00:00', showWithoutTime: false, priority: 1,
|
|
progress: 'needs-action', calendarIds: { 'cal-todo': true },
|
|
}));
|
|
});
|
|
|
|
it('refuses bad task input', async () => {
|
|
await expect(call(WRITE, 'tasks.create', { title: 'x', priority: 12 })).rejects.toThrow(/priority must be a whole number from 0 to 9/);
|
|
await expect(call(WRITE, 'tasks.create', { title: 'x', progress: 'completed' })).rejects.toThrow(/unknown property "progress"/);
|
|
await expect(call(WRITE, 'tasks.create', { title: 'x', due: '2026-05-02T17:00Z' })).rejects.toThrow(/without Z/);
|
|
expect(client.createCalendarTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('completes, updates and deletes through the store', async () => {
|
|
await call(WRITE, 'tasks.setComplete', 't1', true);
|
|
expect(client.updateCalendarTask).toHaveBeenCalledWith('t1', { progress: 'completed' });
|
|
client.updateCalendarTask.mockClear();
|
|
await call(WRITE, 'tasks.setComplete', 't2', true);
|
|
expect(client.updateCalendarTask).not.toHaveBeenCalled();
|
|
await call(WRITE, 'tasks.update', 't1', { due: null, title: 'Pay rent today' });
|
|
expect(client.updateCalendarTask).toHaveBeenCalledWith('t1', { title: 'Pay rent today', due: null, showWithoutTime: true });
|
|
|
|
taskHooks.onBeforeTaskDelete.register('veto', () => false);
|
|
await call(WRITE, 'tasks.delete', 't1');
|
|
expect(client.deleteCalendarTask).not.toHaveBeenCalled();
|
|
await expect(call(WRITE, 'tasks.delete', 'nope')).rejects.toThrow(/no task with id "nope"/);
|
|
});
|
|
});
|