mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
The task store was not cleared on an account switch, unlike the calendar, contacts and the other account-scoped stores, so the next account showed the previous one's tasks until its own arrived. And a fetch answered after a later one had started still wrote its list: the outgoing account's answer could put its tasks back after the switch, and an older answer could replace a newer one. clearAllStores now clears the tasks, and each fetch gets a number: only the latest one's answer or error is kept, and clearing drops the fetches still under way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
7.8 KiB
TypeScript
199 lines
7.8 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { useTaskStore } from '../task-store';
|
|
import type { CalendarTask } from '@/lib/jmap/types';
|
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|
|
|
function makeTask(overrides: Partial<CalendarTask> = {}): CalendarTask {
|
|
return {
|
|
id: 'task-1',
|
|
calendarIds: { 'cal-1': true },
|
|
'@type': 'Task',
|
|
uid: 'task-uid-1',
|
|
title: 'Test task',
|
|
description: '',
|
|
due: '2026-09-08T10:00:00',
|
|
start: null,
|
|
duration: null,
|
|
timeZone: null,
|
|
showWithoutTime: false,
|
|
progress: 'needs-action',
|
|
priority: 0,
|
|
privacy: 'public',
|
|
keywords: null,
|
|
categories: null,
|
|
color: null,
|
|
created: '2026-09-01T10:00:00Z',
|
|
updated: '2026-09-01T10:00:00Z',
|
|
recurrenceRules: null,
|
|
alerts: null,
|
|
relatedTo: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('task-store', () => {
|
|
let mockClient: Partial<IJMAPClient>;
|
|
|
|
beforeEach(() => {
|
|
useTaskStore.setState({
|
|
tasks: [],
|
|
selectedTaskId: null,
|
|
filter: 'all',
|
|
showCompleted: false,
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
|
|
mockClient = {
|
|
getCalendarTasks: vi.fn().mockResolvedValue([makeTask()]),
|
|
createCalendarTask: vi.fn().mockImplementation(async (t) => makeTask({ id: 'task-created', ...t })),
|
|
updateCalendarTask: vi.fn().mockResolvedValue(undefined),
|
|
deleteCalendarTask: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
});
|
|
|
|
describe('fetchTasks', () => {
|
|
// A fetch answered after a later one started (or after the store was
|
|
// cleared for another account) must not put its list back.
|
|
function heldClient() {
|
|
const answers: { resolve: (tasks: CalendarTask[]) => void; reject: (error: Error) => void }[] = [];
|
|
const client = {
|
|
...mockClient,
|
|
getCalendarTasks: vi.fn().mockImplementation(() => new Promise<CalendarTask[]>((resolve, reject) => {
|
|
answers.push({ resolve, reject });
|
|
})),
|
|
} as IJMAPClient;
|
|
return { client, answers };
|
|
}
|
|
|
|
it('drops the answer of a fetch that a later one overtook', async () => {
|
|
const { client, answers } = heldClient();
|
|
const first = useTaskStore.getState().fetchTasks(client);
|
|
const second = useTaskStore.getState().fetchTasks(client);
|
|
answers[1].resolve([makeTask({ id: 'new' })]);
|
|
await second;
|
|
answers[0].resolve([makeTask({ id: 'old' })]);
|
|
await first;
|
|
|
|
expect(useTaskStore.getState().tasks.map((t) => t.id)).toEqual(['new']);
|
|
expect(useTaskStore.getState().isLoading).toBe(false);
|
|
});
|
|
|
|
it('drops the error of a fetch that a later one overtook', async () => {
|
|
const { client, answers } = heldClient();
|
|
const first = useTaskStore.getState().fetchTasks(client);
|
|
const second = useTaskStore.getState().fetchTasks(client);
|
|
answers[1].resolve([makeTask({ id: 'new' })]);
|
|
await second;
|
|
answers[0].reject(new Error('late'));
|
|
await first;
|
|
|
|
expect(useTaskStore.getState().error).toBeNull();
|
|
expect(useTaskStore.getState().tasks.map((t) => t.id)).toEqual(['new']);
|
|
});
|
|
|
|
it('drops the answer of a fetch still under way when the store is cleared', async () => {
|
|
const { client, answers } = heldClient();
|
|
const pending = useTaskStore.getState().fetchTasks(client);
|
|
useTaskStore.getState().clearTasks();
|
|
answers[0].resolve([makeTask({ id: 'other-account' })]);
|
|
await pending;
|
|
|
|
expect(useTaskStore.getState().tasks).toEqual([]);
|
|
expect(useTaskStore.getState().isLoading).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('toggleTaskComplete', () => {
|
|
it('toggles needs-action to completed without sending progressUpdated in payload (#958)', async () => {
|
|
const task = makeTask({ id: 't1', progress: 'needs-action' });
|
|
useTaskStore.setState({ tasks: [task] });
|
|
|
|
await useTaskStore.getState().toggleTaskComplete(mockClient as IJMAPClient, task);
|
|
|
|
expect(mockClient.updateCalendarTask).toHaveBeenCalledTimes(1);
|
|
const [calledId, calledUpdates] = (mockClient.updateCalendarTask as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(calledId).toBe('t1');
|
|
expect(calledUpdates).toEqual({
|
|
progress: 'completed',
|
|
});
|
|
// Stalwart rejects progressUpdated with invalidProperties error
|
|
expect(calledUpdates).not.toHaveProperty('progressUpdated');
|
|
|
|
const updatedTask = useTaskStore.getState().tasks.find((t) => t.id === 't1');
|
|
expect(updatedTask?.progress).toBe('completed');
|
|
});
|
|
|
|
it('toggles completed back to needs-action without sending progressUpdated in payload', async () => {
|
|
const task = makeTask({ id: 't2', progress: 'completed' });
|
|
useTaskStore.setState({ tasks: [task] });
|
|
|
|
await useTaskStore.getState().toggleTaskComplete(mockClient as IJMAPClient, task);
|
|
|
|
expect(mockClient.updateCalendarTask).toHaveBeenCalledTimes(1);
|
|
const [calledId, calledUpdates] = (mockClient.updateCalendarTask as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
expect(calledId).toBe('t2');
|
|
expect(calledUpdates).toEqual({
|
|
progress: 'needs-action',
|
|
});
|
|
expect(calledUpdates).not.toHaveProperty('progressUpdated');
|
|
|
|
const updatedTask = useTaskStore.getState().tasks.find((t) => t.id === 't2');
|
|
expect(updatedTask?.progress).toBe('needs-action');
|
|
});
|
|
});
|
|
|
|
describe('CRUD operations', () => {
|
|
it('fetches tasks and updates store state', async () => {
|
|
await useTaskStore.getState().fetchTasks(mockClient as IJMAPClient, ['cal-1']);
|
|
|
|
expect(mockClient.getCalendarTasks).toHaveBeenCalledWith(['cal-1']);
|
|
expect(useTaskStore.getState().tasks).toHaveLength(1);
|
|
expect(useTaskStore.getState().isLoading).toBe(false);
|
|
expect(useTaskStore.getState().error).toBeNull();
|
|
});
|
|
|
|
it('creates a task and appends to store', async () => {
|
|
const created = await useTaskStore.getState().createTask(mockClient as IJMAPClient, { title: 'New Task' });
|
|
|
|
expect(mockClient.createCalendarTask).toHaveBeenCalledWith({ title: 'New Task' });
|
|
expect(created?.id).toBe('task-created');
|
|
expect(useTaskStore.getState().tasks).toContainEqual(created);
|
|
});
|
|
|
|
it('updates a task and preserves unchanged fields', async () => {
|
|
const task = makeTask({ id: 't3', title: 'Original Title' });
|
|
useTaskStore.setState({ tasks: [task] });
|
|
|
|
await useTaskStore.getState().updateTask(mockClient as IJMAPClient, 't3', { title: 'Updated Title' });
|
|
|
|
expect(mockClient.updateCalendarTask).toHaveBeenCalledWith('t3', { title: 'Updated Title' });
|
|
const updated = useTaskStore.getState().tasks.find((t) => t.id === 't3');
|
|
expect(updated?.title).toBe('Updated Title');
|
|
expect(updated?.progress).toBe('needs-action');
|
|
});
|
|
|
|
it('reloads the list when a move to a shared calendar gives the task a new id (#1204)', async () => {
|
|
useTaskStore.setState({ tasks: [makeTask({ id: 't5' })], selectedTaskId: 't5' });
|
|
(mockClient.updateCalendarTask as ReturnType<typeof vi.fn>).mockResolvedValue('acct-2:new');
|
|
(mockClient.getCalendarTasks as ReturnType<typeof vi.fn>).mockResolvedValue([makeTask({ id: 'acct-2:new' })]);
|
|
|
|
await useTaskStore.getState().updateTask(mockClient as IJMAPClient, 't5', { calendarIds: { 'acct-2:cal-s': true } });
|
|
|
|
expect(useTaskStore.getState().tasks.map((t) => t.id)).toEqual(['acct-2:new']);
|
|
expect(useTaskStore.getState().selectedTaskId).toBe('acct-2:new');
|
|
});
|
|
|
|
it('deletes a task and resets selectedTaskId if selected', async () => {
|
|
const task = makeTask({ id: 't4' });
|
|
useTaskStore.setState({ tasks: [task], selectedTaskId: 't4' });
|
|
|
|
await useTaskStore.getState().deleteTask(mockClient as IJMAPClient, 't4');
|
|
|
|
expect(mockClient.deleteCalendarTask).toHaveBeenCalledWith('t4');
|
|
expect(useTaskStore.getState().tasks).toHaveLength(0);
|
|
expect(useTaskStore.getState().selectedTaskId).toBeNull();
|
|
});
|
|
});
|
|
});
|