mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
686 lines
39 KiB
TypeScript
686 lines
39 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, CalendarEventAlert, CalendarLink, 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 { useContactStore } from '@/stores/contact-store';
|
|
import { BIRTHDAY_CALENDAR_ID, BIRTHDAY_CALENDAR_COLOR } from '@/lib/birthday-calendar';
|
|
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 READ_WITH_CONTACTS = plugin(['calendar:read', 'tasks:read', 'contacts:read']);
|
|
const WRITE = plugin(['calendar:read', 'calendar:write', 'tasks:read', 'tasks:write']);
|
|
const WRITE_WITH_CONTACTS = plugin(['calendar:read', 'calendar:write', 'tasks:read', 'tasks:write', 'contacts:read']);
|
|
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', showBirthdayCalendar: false, birthdayCalendarColor: BIRTHDAY_CALENDAR_COLOR } as never);
|
|
useContactStore.setState({ contacts: [], contactsLoaded: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
useAuthStore.setState({ client: null });
|
|
useCalendarStore.setState({ calendars: [], events: [] });
|
|
useTaskStore.setState({ tasks: [] });
|
|
useIdentityStore.setState({ identities: [] });
|
|
useSettingsStore.setState({ showBirthdayCalendar: false });
|
|
useContactStore.setState({ contacts: [] });
|
|
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/);
|
|
});
|
|
|
|
// A plugin writes plain text; left labelled text/html, other clients would
|
|
// run its lines together.
|
|
it('labels a description written over an HTML one as plain text', async () => {
|
|
client.getCalendarEvent.mockResolvedValue(event({ description: '<p>Old</p>', descriptionContentType: 'text/html' }));
|
|
await call(WRITE, 'calendar.updateEvent', 'e1', { description: 'New\nlines' });
|
|
expect(client.updateCalendarEvent).toHaveBeenCalledWith('e1', expect.objectContaining({
|
|
description: 'New\nlines', descriptionContentType: 'text/plain',
|
|
}), false, undefined);
|
|
});
|
|
|
|
it('includes the virtual birthday calendar in listCalendars only when contacts:read is held and showBirthdayCalendar is enabled', async () => {
|
|
useSettingsStore.setState({ showBirthdayCalendar: true, birthdayCalendarColor: '#f59e0b' });
|
|
// Plugin without contacts:read does not see the birthday calendar
|
|
const calsWithoutContacts = await call(READ, 'calendar.listCalendars') as { id: string }[];
|
|
expect(calsWithoutContacts.some((c) => c.id === BIRTHDAY_CALENDAR_ID)).toBe(false);
|
|
|
|
// Plugin with contacts:read sees the birthday calendar
|
|
const cals = await call(READ_WITH_CONTACTS, 'calendar.listCalendars') as { id: string; name: string; color: string }[];
|
|
expect(cals).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 },
|
|
{ id: BIRTHDAY_CALENDAR_ID, name: 'Birthdays', color: '#f59e0b', isVisible: true, isDefault: false, isTasksOnly: false },
|
|
]);
|
|
});
|
|
|
|
it('returns contact birthdays in getEvents only when contacts:read is held and showBirthdayCalendar is enabled', async () => {
|
|
useSettingsStore.setState({ showBirthdayCalendar: true });
|
|
useContactStore.setState({
|
|
contacts: [
|
|
{
|
|
id: 'c1',
|
|
name: { full: 'Alice Smith' },
|
|
anniversaries: { b1: { kind: 'birth', date: '1990-05-15' } },
|
|
} as never,
|
|
],
|
|
});
|
|
|
|
// Plugin without contacts:read does not see birthday events
|
|
const eventsWithoutContacts = await call(READ, 'calendar.getEvents', { from: '2026-05-01', to: '2026-06-01' }) as { id: string; calendarId: string }[];
|
|
expect(eventsWithoutContacts.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
|
|
|
|
// Plugin with contacts:read sees birthday events
|
|
const events = await call(READ_WITH_CONTACTS, 'calendar.getEvents', { from: '2026-05-01', to: '2026-06-01' }) as {
|
|
id: string; title: string; calendarId: string; isAllDay: boolean; start: string;
|
|
}[];
|
|
|
|
const birthday = events.find((e) => e.calendarId === BIRTHDAY_CALENDAR_ID);
|
|
expect(birthday).toBeDefined();
|
|
expect(birthday).toEqual(expect.objectContaining({
|
|
id: 'birthday-c1-b1-2026',
|
|
title: '🎂 Alice Smith (36)',
|
|
calendarId: BIRTHDAY_CALENDAR_ID,
|
|
isAllDay: true,
|
|
start: '2026-05-15T00:00:00',
|
|
}));
|
|
|
|
// Respects date range bounds using same start/end as regular events
|
|
const beforeBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
|
|
from: '2026-05-01', to: '2026-05-10',
|
|
}) as { calendarId: string }[];
|
|
expect(beforeBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
|
|
|
|
const afterBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
|
|
from: '2026-05-20', to: '2026-05-31',
|
|
}) as { calendarId: string }[];
|
|
expect(afterBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
|
|
|
|
// The end is exclusive, as for regular events: a week ending at the
|
|
// birthday's midnight does not contain it.
|
|
const weekEndingOnBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
|
|
from: '2026-05-08', to: '2026-05-15',
|
|
}) as { calendarId: string }[];
|
|
expect(weekEndingOnBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
|
|
|
|
// A birthday covers its whole day, so a range inside that day finds it.
|
|
const middayOnBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
|
|
from: '2026-05-15T10:00', to: '2026-05-15T12:00',
|
|
}) as { id: string }[];
|
|
expect(middayOnBirthday.map((e) => e.id)).toContain('birthday-c1-b1-2026');
|
|
|
|
// Respects calendarIds filtering
|
|
const onlyWork = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
|
|
from: '2026-05-01', to: '2026-06-01', calendarIds: ['cal-work'],
|
|
}) as { calendarId: string }[];
|
|
expect(onlyWork.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false);
|
|
|
|
const onlyBirthdays = await call(READ_WITH_CONTACTS, 'calendar.getEvents', {
|
|
from: '2026-05-01', to: '2026-06-01', calendarIds: [BIRTHDAY_CALENDAR_ID],
|
|
}) as { calendarId: string }[];
|
|
expect(onlyBirthdays.every((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(true);
|
|
|
|
// Fetching a birthday event by ID requires contacts:read
|
|
expect(await call(READ, 'calendar.getEvent', 'birthday-c1-b1-2026')).toBeNull();
|
|
const single = await call(READ_WITH_CONTACTS, 'calendar.getEvent', 'birthday-c1-b1-2026');
|
|
expect(single).toEqual(expect.objectContaining({
|
|
id: 'birthday-c1-b1-2026',
|
|
title: '🎂 Alice Smith (36)',
|
|
}));
|
|
|
|
// Birthday events cannot be mutated
|
|
await expect(call(WRITE_WITH_CONTACTS, 'calendar.updateEvent', 'birthday-c1-b1-2026', { title: 'New' }))
|
|
.rejects.toThrow(/birthday events cannot be modified/);
|
|
await expect(call(WRITE_WITH_CONTACTS, 'calendar.deleteEvent', 'birthday-c1-b1-2026'))
|
|
.rejects.toThrow(/birthday events cannot be modified/);
|
|
await expect(call(WRITE_WITH_CONTACTS, 'calendar.createEvent', {
|
|
title: 'New', calendarId: BIRTHDAY_CALENDAR_ID, start: '2026-05-15T10:00', end: '2026-05-15T11:00',
|
|
})).rejects.toThrow(/no calendar with id "__birthday-calendar__"/);
|
|
});
|
|
});
|
|
|
|
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"/);
|
|
});
|
|
});
|
|
|
|
describe('api.tasks alerts', () => {
|
|
const at = (when: string, over: Partial<CalendarEventAlert> = {}): CalendarEventAlert => ({
|
|
'@type': 'Alert', trigger: { '@type': 'AbsoluteTrigger', when }, action: 'display', acknowledged: null, relatedTo: null, ...over,
|
|
});
|
|
const updates = () => client.updateCalendarTask.mock.calls.map((c) => c[1] as Record<string, unknown>);
|
|
|
|
it('creates a task with alerts, turning the calendar\'s defaults off', async () => {
|
|
await call(WRITE, 'tasks.create', {
|
|
title: 'Follow up: Offer',
|
|
due: '2026-05-04',
|
|
alerts: {
|
|
'follow-up': { trigger: { at: '2026-05-04T07:00:00Z' } },
|
|
'follow-up-mail': { trigger: { offset: '-PT1H' }, action: 'email' },
|
|
},
|
|
});
|
|
expect(client.createCalendarTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
due: '2026-05-04', showWithoutTime: true, useDefaultAlerts: false,
|
|
alerts: {
|
|
'follow-up': at('2026-05-04T07:00:00Z'),
|
|
'follow-up-mail': {
|
|
'@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'end' },
|
|
action: 'email', acknowledged: null, relatedTo: null,
|
|
},
|
|
},
|
|
}));
|
|
});
|
|
|
|
it('returns them, each alert with whether it was acknowledged, and whether the defaults are in use', async () => {
|
|
client.getCalendarTasks.mockResolvedValue([task({
|
|
alerts: {
|
|
a: at('2026-05-04T07:00:00Z', { acknowledged: '2026-05-04T07:01:00Z' }),
|
|
b: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'email', acknowledged: null, relatedTo: null },
|
|
},
|
|
})]);
|
|
expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({
|
|
alerts: {
|
|
a: { trigger: { at: '2026-05-04T07:00:00Z' }, action: 'display', acknowledged: '2026-05-04T07:01:00Z' },
|
|
b: { trigger: { offset: '-PT15M', relativeTo: 'start' }, action: 'email', acknowledged: null },
|
|
},
|
|
useDefaultAlerts: false,
|
|
}));
|
|
const [listed] = await call(READ, 'tasks.list') as { alerts: object }[];
|
|
expect(Object.keys(listed.alerts)).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('refuses bad triggers, ids and too many alerts', async () => {
|
|
const create = (over: Record<string, unknown>) => call(WRITE, 'tasks.create', { title: 'x', ...over });
|
|
await expect(create({ alerts: [{ id: 'a', trigger: { offset: 'PT0S' } }] })).rejects.toThrow(/object keyed by id/);
|
|
const six = Object.fromEntries([0, 1, 2, 3, 4, 5].map((i) => [`a${i}`, { trigger: { offset: 'PT0S' } }]));
|
|
await expect(create({ alerts: six })).rejects.toThrow(/at most 5 entries/);
|
|
await expect(create({ alerts: { a: { trigger: { offset: 'P400D' } } } })).rejects.toThrow(/at most 366 days/);
|
|
await expect(create({ alerts: { a: { trigger: { offset: 'P' } } } })).rejects.toThrow(/ISO 8601 duration/);
|
|
await expect(create({ alerts: { a: { trigger: { at: '2026-05-04T09:00' } } } })).rejects.toThrow(/in UTC/);
|
|
await expect(create({ alerts: { a: { trigger: { offset: 'PT0S' }, action: 'audio' } } })).rejects.toThrow(/display or email/);
|
|
await expect(create({ alerts: { 'a b': { trigger: { offset: 'PT0S' } } } })).rejects.toThrow(/letters, digits/);
|
|
expect(client.createCalendarTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('merges by id with patch paths, leaving alerts it did not name and other fields alone', async () => {
|
|
useTaskStore.setState({ tasks: [task({
|
|
title: 'Kept',
|
|
alerts: { foreign: at('2026-05-04T06:00:00Z', { action: 'email' }), old: at('2026-05-04T05:00:00Z') },
|
|
})] });
|
|
await call(WRITE, 'tasks.update', 't1', {
|
|
alerts: { 'follow-up': { trigger: { at: '2026-05-04T07:00:00Z' } }, old: null },
|
|
});
|
|
expect(updates()[0]).toEqual({
|
|
'alerts/follow-up': at('2026-05-04T07:00:00Z'),
|
|
'alerts/old': null,
|
|
});
|
|
const held = useTaskStore.getState().tasks[0];
|
|
expect(held.title).toBe('Kept');
|
|
expect(Object.keys(held.alerts ?? {})).toEqual(['foreign', 'follow-up']);
|
|
|
|
await call(WRITE, 'tasks.update', 't1', { alerts: null });
|
|
expect(updates()[1]).toEqual({ alerts: null });
|
|
});
|
|
|
|
it('writes the alerts whole on a task that has none yet', async () => {
|
|
useTaskStore.setState({ tasks: [task({ alerts: null })] });
|
|
await call(WRITE, 'tasks.update', 't1', { alerts: { a: { trigger: { at: '2026-05-04T07:00:00Z' } } } });
|
|
expect(updates()[0]).toEqual({ alerts: { a: at('2026-05-04T07:00:00Z') } });
|
|
});
|
|
|
|
it('keeps an unchanged alert acknowledged and lets a snoozed one fire again', async () => {
|
|
useTaskStore.setState({ tasks: [task({
|
|
alerts: {
|
|
a: at('2026-05-04T07:00:00Z', { acknowledged: '2026-05-04T07:01:00Z' }),
|
|
b: at('2026-05-04T07:00:00Z', { acknowledged: '2026-05-04T07:01:00Z' }),
|
|
},
|
|
})] });
|
|
await call(WRITE, 'tasks.update', 't1', { alerts: {
|
|
a: { trigger: { at: '2026-05-04T07:00:00Z' } },
|
|
b: { trigger: { at: '2026-05-04T08:00:00Z' } },
|
|
} });
|
|
expect(updates()[0]).toEqual({ 'alerts/b': at('2026-05-04T08:00:00Z') });
|
|
const held = useTaskStore.getState().tasks[0];
|
|
expect(held.alerts?.a.acknowledged).toBe('2026-05-04T07:01:00Z');
|
|
expect(held.alerts?.b.acknowledged).toBeNull();
|
|
});
|
|
|
|
it('refuses a sixth alert counting those another app wrote', async () => {
|
|
useTaskStore.setState({ tasks: [task({
|
|
alerts: Object.fromEntries([1, 2, 3, 4, 5].map((i) => [`x${i}`, at('2026-05-04T07:00:00Z')])),
|
|
})] });
|
|
await expect(call(WRITE, 'tasks.update', 't1', { alerts: { new: { trigger: { offset: 'PT0S' } } } })).rejects.toThrow(/more than 5 alerts/);
|
|
await call(WRITE, 'tasks.update', 't1', { alerts: { x1: null, new: { trigger: { offset: 'PT0S' } } } });
|
|
expect(updates()[0]).toEqual({ 'alerts/x1': null, 'alerts/new': expect.objectContaining({ action: 'display' }) });
|
|
});
|
|
|
|
it('makes the calendar\'s default alerts the task\'s own under new ids, refusing a plugin alert under a default\'s id', async () => {
|
|
useTaskStore.setState({ tasks: [task({
|
|
useDefaultAlerts: true,
|
|
alerts: { d1: at('2026-05-04T06:00:00Z'), d2: at('2026-05-04T06:30:00Z') },
|
|
})] });
|
|
expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({ useDefaultAlerts: true }));
|
|
await expect(call(WRITE, 'tasks.update', 't1', { alerts: { d1: { trigger: { offset: 'PT0S' } } } })).rejects.toThrow(/default alerts.*another id/);
|
|
await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'd1')).rejects.toThrow(/useDefaultAlerts/);
|
|
expect(client.updateCalendarTask).not.toHaveBeenCalled();
|
|
|
|
await call(WRITE, 'tasks.update', 't1', { alerts: { d2: null, mine: { trigger: { at: '2026-05-04T07:00:00Z' } } } });
|
|
expect(updates()[0]).toEqual({
|
|
useDefaultAlerts: false,
|
|
alerts: { alert1: at('2026-05-04T06:00:00Z'), mine: at('2026-05-04T07:00:00Z') },
|
|
});
|
|
useTaskStore.setState({ tasks: [task({ useDefaultAlerts: true, alerts: { d1: at('2026-05-04T06:00:00Z') } })] });
|
|
await call(WRITE, 'tasks.update', 't1', { alerts: {} });
|
|
expect(updates()[1]).toEqual({ useDefaultAlerts: false, alerts: { alert1: at('2026-05-04T06:00:00Z') } });
|
|
});
|
|
|
|
it('takes ids such as __proto__ and constructor as plain keys', async () => {
|
|
await call(WRITE, 'tasks.create', {
|
|
title: 'x',
|
|
alerts: JSON.parse('{"__proto__": {"trigger": {"offset": "PT0S"}}, "constructor": {"trigger": {"offset": "PT0S"}}}'),
|
|
});
|
|
const created = client.createCalendarTask.mock.calls[0][0] as CalendarTask;
|
|
expect(Object.getPrototypeOf(created.alerts)).toBe(Object.prototype);
|
|
expect(Object.keys(created.alerts ?? {})).toEqual(['__proto__', 'constructor']);
|
|
|
|
useTaskStore.setState({ tasks: [task({ alerts: { a: at('2026-05-04T07:00:00Z') } })] });
|
|
await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'constructor')).rejects.toThrow(/no alert "constructor"/);
|
|
await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', '__proto__')).rejects.toThrow(/no alert "__proto__"/);
|
|
await call(WRITE, 'tasks.update', 't1', { alerts: JSON.parse('{"__proto__": {"trigger": {"offset": "PT0S"}}}') });
|
|
expect(updates()[0]).toEqual(JSON.parse('{"alerts/__proto__": {"@type": "Alert", "trigger": {"@type": "OffsetTrigger", "offset": "PT0S", "relativeTo": "end"}, "action": "display", "acknowledged": null, "relatedTo": null}}'));
|
|
const held = useTaskStore.getState().tasks[0];
|
|
expect(Object.getPrototypeOf(held.alerts)).toBe(Object.prototype);
|
|
expect(Object.keys(held.alerts ?? {})).toEqual(['a', '__proto__']);
|
|
});
|
|
|
|
it('acknowledges an alert by its path, now or at a given time, and undoes it', async () => {
|
|
vi.useFakeTimers({ now: new Date('2026-05-04T07:02:03.456Z'), toFake: ['Date'] });
|
|
try {
|
|
useTaskStore.setState({ tasks: [task({ alerts: { a: at('2026-05-04T07:00:00Z'), b: at('2026-05-04T07:00:00Z', { action: 'email' }) } })] });
|
|
await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a');
|
|
expect(client.updateCalendarTask).toHaveBeenLastCalledWith('t1', { 'alerts/a/acknowledged': '2026-05-04T07:02:03Z' });
|
|
expect(useTaskStore.getState().tasks[0].alerts?.a.acknowledged).toBe('2026-05-04T07:02:03Z');
|
|
await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'b', '2026-05-04T06:00:00Z');
|
|
expect(useTaskStore.getState().tasks[0].alerts?.b.acknowledged).toBe('2026-05-04T06:00:00Z');
|
|
await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', null);
|
|
expect(client.updateCalendarTask).toHaveBeenLastCalledWith('t1', { 'alerts/a/acknowledged': null });
|
|
expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({
|
|
alerts: expect.objectContaining({ a: expect.objectContaining({ acknowledged: null }) }),
|
|
}));
|
|
|
|
client.updateCalendarTask.mockClear();
|
|
await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', null);
|
|
expect(client.updateCalendarTask).not.toHaveBeenCalled();
|
|
await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'zz')).rejects.toThrow(/no alert "zz"/);
|
|
await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', '2026-05-04T06:00')).rejects.toThrow(/in UTC/);
|
|
await expect(call(READ, 'tasks.acknowledgeAlert', 't1', 'a')).rejects.toThrow(/lacks permission "tasks:write"/);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('acknowledges two alerts at once one after the other, neither undoing the other', async () => {
|
|
useTaskStore.setState({ tasks: [task({ alerts: { a: at('2026-05-04T07:00:00Z'), b: at('2026-05-04T07:00:00Z') } })] });
|
|
let inFlight = 0;
|
|
let most = 0;
|
|
client.updateCalendarTask.mockImplementation(async (id: string) => {
|
|
most = Math.max(most, ++inFlight);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
inFlight -= 1;
|
|
return id;
|
|
});
|
|
await Promise.all([
|
|
call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', '2026-05-04T07:01:00Z'),
|
|
call(WRITE, 'tasks.acknowledgeAlert', 't1', 'b', '2026-05-04T07:02:00Z'),
|
|
]);
|
|
// Stalwart refuses a second set on an object while one is under way.
|
|
expect(most).toBe(1);
|
|
expect(updates()).toEqual([
|
|
{ 'alerts/a/acknowledged': '2026-05-04T07:01:00Z' },
|
|
{ 'alerts/b/acknowledged': '2026-05-04T07:02:00Z' },
|
|
]);
|
|
const alerts = useTaskStore.getState().tasks[0].alerts;
|
|
expect([alerts?.a.acknowledged, alerts?.b.acknowledged]).toEqual(['2026-05-04T07:01:00Z', '2026-05-04T07:02:00Z']);
|
|
});
|
|
|
|
it('acknowledges an alert of a task the list does not hold, emitting the whole task', async () => {
|
|
const full = task({ title: 'From the server', alerts: { a: at('2026-05-04T07:00:00Z'), b: at('2026-05-04T08:00:00Z') } });
|
|
client.getCalendarTasks.mockResolvedValue([full]);
|
|
const seen: { patch: Record<string, unknown> }[] = [];
|
|
const emitted: { title: string; alerts: Record<string, { acknowledged: string | null }> }[] = [];
|
|
taskHooks.onBeforeTaskUpdate.register('watch', (e: unknown) => { seen.push(e as { patch: Record<string, unknown> }); return true; });
|
|
taskHooks.onTaskUpdate.register('watch', (v: unknown) => { emitted.push(v as never); });
|
|
try {
|
|
await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', '2026-05-04T07:01:00Z');
|
|
expect(updates()).toEqual([{ 'alerts/a/acknowledged': '2026-05-04T07:01:00Z' }]);
|
|
expect(Object.keys(seen[0].patch.alerts as object)).toEqual(['a', 'b']);
|
|
await vi.waitFor(() => expect(emitted).toHaveLength(1));
|
|
expect(emitted[0].title).toBe('From the server');
|
|
expect(emitted[0].alerts.a.acknowledged).toBe('2026-05-04T07:01:00Z');
|
|
expect(emitted[0].alerts.b.acknowledged).toBeNull();
|
|
} finally {
|
|
taskHooks.onBeforeTaskUpdate.removePlugin('watch');
|
|
taskHooks.onTaskUpdate.removePlugin('watch');
|
|
}
|
|
});
|
|
|
|
it('decides on the alerts as the server holds them, not an older copy in the list', async () => {
|
|
useTaskStore.setState({ tasks: [task({ alerts: null })] });
|
|
client.getCalendarEvent.mockImplementation(async (id: string) => (id === 't1'
|
|
? { id: 't1', alerts: { other: at('2026-05-04T06:00:00Z', { action: 'email' }) } }
|
|
: null));
|
|
await call(WRITE, 'tasks.update', 't1', { alerts: { mine: { trigger: { at: '2026-05-04T07:00:00Z' } } } });
|
|
expect(updates()[0]).toEqual({ 'alerts/mine': at('2026-05-04T07:00:00Z') });
|
|
const held = useTaskStore.getState().tasks[0];
|
|
expect(Object.keys(held.alerts ?? {})).toEqual(['other', 'mine']);
|
|
expect(client.getCalendarEvent).toHaveBeenCalledWith('t1', undefined);
|
|
});
|
|
|
|
it('turns the calendar\'s default alerts off when every alert is removed', async () => {
|
|
useTaskStore.setState({ tasks: [task({ useDefaultAlerts: true, alerts: { d1: at('2026-05-04T06:00:00Z') } })] });
|
|
await call(WRITE, 'tasks.update', 't1', { alerts: null });
|
|
expect(updates()[0]).toEqual({ alerts: null, useDefaultAlerts: false });
|
|
});
|
|
});
|
|
|
|
describe('api.tasks links and relations', () => {
|
|
const link = (href: string, over: Partial<CalendarLink> = {}): CalendarLink => ({
|
|
'@type': 'Link', href, cid: null, contentType: null, size: null, rel: null, display: null, title: null, ...over,
|
|
});
|
|
const updates = () => client.updateCalendarTask.mock.calls.map((c) => c[1] as Record<string, unknown>);
|
|
|
|
it('creates a task with links and a related mail, a path of this Bulwark made a full URL', async () => {
|
|
await call(WRITE, 'tasks.create', {
|
|
title: 'Follow up: Offer',
|
|
links: {
|
|
mail: { href: '/mail/message/m1?mailAccount=a1', rel: 'describedby', title: 'Offer' },
|
|
from: { href: 'mailto:bob@example.com', rel: 'author', title: 'Bob' },
|
|
},
|
|
relatedTo: { '<offer-1@example.com>': { relation: ['parent'] } },
|
|
});
|
|
expect(client.createCalendarTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
links: {
|
|
mail: link(`${window.location.origin}/mail/message/m1?mailAccount=a1`, { rel: 'describedby', title: 'Offer' }),
|
|
from: link('mailto:bob@example.com', { rel: 'author', title: 'Bob' }),
|
|
},
|
|
relatedTo: { '<offer-1@example.com>': { '@type': 'Relation', relation: { parent: true } } },
|
|
}));
|
|
});
|
|
|
|
it('returns them as any app wrote them, an attachment\'s link too', async () => {
|
|
client.getCalendarTasks.mockResolvedValue([task({
|
|
links: { file: link('cid:part1', { rel: 'enclosure', contentType: 'application/pdf', size: 12 }) },
|
|
relatedTo: { '<m@x>': { '@type': 'Relation', relation: null } },
|
|
})]);
|
|
expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({
|
|
links: { file: { href: 'cid:part1', rel: 'enclosure', title: null, contentType: 'application/pdf' } },
|
|
relatedTo: { '<m@x>': { relation: [] } },
|
|
}));
|
|
});
|
|
|
|
it('refuses bad schemes, bad keys and over-long fields', async () => {
|
|
const create = (over: Record<string, unknown>) => call(WRITE, 'tasks.create', { title: 'x', ...over });
|
|
await expect(create({ links: { l: { href: 'javascript:alert(1)' } } })).rejects.toThrow(/http\(s\) URL, a mailto: address or a path/);
|
|
await expect(create({ links: { l: { href: '//evil.example/x' } } })).rejects.toThrow(/http\(s\) URL/);
|
|
await expect(create({ links: { l: { href: 'https://user:pw@example.com/' } } })).rejects.toThrow(/user name or password/);
|
|
await expect(create({ links: { l: { href: 'mailto:a@example.com?body=hi' } } })).rejects.toThrow(/one address/);
|
|
await expect(create({ links: { l: { href: `https://example.com/${'a'.repeat(2048)}` } } })).rejects.toThrow(/1-2048 characters/);
|
|
await expect(create({ links: { l: { href: 'https://example.com', rel: 'Described By' } } })).rejects.toThrow(/rel must be/);
|
|
await expect(create({ links: { l: { href: 'https://example.com', title: 'x'.repeat(501) } } })).rejects.toThrow(/title must be at most 500/);
|
|
await expect(create({ links: [{ id: 'l', href: 'https://a.example' }] })).rejects.toThrow(/object keyed by id/);
|
|
const eleven = Object.fromEntries(Array.from({ length: 11 }, (_, i) => [`l${i}`, { href: 'https://a.example' }]));
|
|
await expect(create({ links: eleven })).rejects.toThrow(/at most 10 entries/);
|
|
await expect(create({ relatedTo: { 'has space': {} } })).rejects.toThrow(/Message-ID/);
|
|
await expect(create({ relatedTo: { [`<${'a'.repeat(1000)}>`]: {} } })).rejects.toThrow(/at most 998/);
|
|
await expect(create({ relatedTo: { '<m@x>': { relation: ['Parent'] } } })).rejects.toThrow(/relation\[0\]/);
|
|
expect(client.createCalendarTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('merges by id with patch paths, keeping links another app wrote', async () => {
|
|
useTaskStore.setState({ tasks: [task({
|
|
links: { att: link('cid:part1', { rel: 'enclosure', contentType: 'application/pdf' }), old: link('https://old.example') },
|
|
relatedTo: null,
|
|
})] });
|
|
await call(WRITE, 'tasks.update', 't1', {
|
|
links: { mail: { href: 'https://mail.example/m1' }, old: null },
|
|
relatedTo: { '<m/1~x@y>': { relation: ['parent'] } },
|
|
});
|
|
expect(updates()[0]).toEqual({
|
|
'links/mail': link('https://mail.example/m1'),
|
|
'links/old': null,
|
|
// No relatedTo yet: written whole.
|
|
relatedTo: { '<m/1~x@y>': { '@type': 'Relation', relation: { parent: true } } },
|
|
});
|
|
expect(Object.keys(useTaskStore.getState().tasks[0].links ?? {})).toEqual(['att', 'mail']);
|
|
|
|
await call(WRITE, 'tasks.update', 't1', { relatedTo: { '<m/1~x@y>': null } });
|
|
expect(updates()[1]).toEqual({ 'relatedTo/<m~11~0x@y>': null });
|
|
});
|
|
|
|
it('decides on the links as the server holds them, not an older copy in the list', async () => {
|
|
useTaskStore.setState({ tasks: [task({ links: null })] });
|
|
client.getCalendarEvent.mockImplementation(async (id: string) => (id === 't1'
|
|
? { id: 't1', links: { att: link('cid:part1', { rel: 'enclosure' }) }, alerts: null, relatedTo: null }
|
|
: null));
|
|
await call(WRITE, 'tasks.update', 't1', { links: { mail: { href: 'https://mail.example/m1' } } });
|
|
expect(updates()[0]).toEqual({ 'links/mail': link('https://mail.example/m1') });
|
|
expect(Object.keys(useTaskStore.getState().tasks[0].links ?? {})).toEqual(['att', 'mail']);
|
|
});
|
|
});
|