feat: folder management settings with CRUD and standard role assignment
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
|
||||
return {
|
||||
id: overrides.id ?? 'mb-1',
|
||||
name: overrides.name ?? 'Test Folder',
|
||||
sortOrder: 0,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
},
|
||||
isSubscribed: true,
|
||||
isShared: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockClient(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
createMailbox: vi.fn().mockResolvedValue(makeMailbox({ id: 'mb-new' })),
|
||||
updateMailbox: vi.fn().mockResolvedValue(undefined),
|
||||
deleteMailbox: vi.fn().mockResolvedValue(undefined),
|
||||
getAllMailboxes: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('email-store folder management', () => {
|
||||
const inbox = makeMailbox({ id: 'inbox-1', name: 'Inbox', role: 'inbox' });
|
||||
const sent = makeMailbox({ id: 'sent-1', name: 'Sent', role: 'sent' });
|
||||
const trash = makeMailbox({ id: 'trash-1', name: 'Trash', role: 'trash' });
|
||||
const custom = makeMailbox({ id: 'custom-1', name: 'My Folder' });
|
||||
|
||||
beforeEach(() => {
|
||||
useEmailStore.setState({
|
||||
mailboxes: [inbox, sent, trash, custom],
|
||||
selectedMailbox: 'inbox-1',
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMailbox', () => {
|
||||
it('should call client.createMailbox and refresh mailboxes', async () => {
|
||||
const newMailboxes = [
|
||||
...useEmailStore.getState().mailboxes,
|
||||
makeMailbox({ id: 'mb-new', name: 'New Folder' }),
|
||||
];
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(newMailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().createMailbox(client, 'New Folder');
|
||||
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('New Folder', undefined);
|
||||
expect(client.getAllMailboxes).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call client.createMailbox with parentId', async () => {
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(useEmailStore.getState().mailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().createMailbox(client, 'Sub Folder', 'inbox-1');
|
||||
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('Sub Folder', 'inbox-1');
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const client = makeMockClient({
|
||||
createMailbox: vi.fn().mockRejectedValue(new Error('Server error')),
|
||||
});
|
||||
|
||||
await expect(
|
||||
useEmailStore.getState().createMailbox(client, 'Fail')
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useEmailStore.getState().error).toBe('Server error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameMailbox', () => {
|
||||
it('should update mailbox name locally', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().renameMailbox(client, 'custom-1', 'Renamed');
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { name: 'Renamed' });
|
||||
const mb = useEmailStore.getState().mailboxes.find(m => m.id === 'custom-1');
|
||||
expect(mb?.name).toBe('Renamed');
|
||||
});
|
||||
|
||||
it('should not change other mailboxes', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().renameMailbox(client, 'custom-1', 'Renamed');
|
||||
|
||||
const inboxMb = useEmailStore.getState().mailboxes.find(m => m.id === 'inbox-1');
|
||||
expect(inboxMb?.name).toBe('Inbox');
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const client = makeMockClient({
|
||||
updateMailbox: vi.fn().mockRejectedValue(new Error('Rename failed')),
|
||||
});
|
||||
|
||||
await expect(
|
||||
useEmailStore.getState().renameMailbox(client, 'custom-1', 'Fail')
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useEmailStore.getState().error).toBe('Rename failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMailbox', () => {
|
||||
it('should remove mailbox from state', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().deleteMailbox(client, 'custom-1');
|
||||
|
||||
expect(client.deleteMailbox).toHaveBeenCalledWith('custom-1');
|
||||
const mb = useEmailStore.getState().mailboxes.find(m => m.id === 'custom-1');
|
||||
expect(mb).toBeUndefined();
|
||||
expect(useEmailStore.getState().mailboxes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should switch to inbox when deleting selected mailbox', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: 'custom-1' });
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().deleteMailbox(client, 'custom-1');
|
||||
|
||||
expect(useEmailStore.getState().selectedMailbox).toBe('inbox-1');
|
||||
});
|
||||
|
||||
it('should keep current selection when deleting non-selected mailbox', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().deleteMailbox(client, 'custom-1');
|
||||
|
||||
expect(useEmailStore.getState().selectedMailbox).toBe('inbox-1');
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const client = makeMockClient({
|
||||
deleteMailbox: vi.fn().mockRejectedValue(new Error('Delete failed')),
|
||||
});
|
||||
|
||||
await expect(
|
||||
useEmailStore.getState().deleteMailbox(client, 'custom-1')
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useEmailStore.getState().error).toBe('Delete failed');
|
||||
// Mailbox should still exist
|
||||
expect(useEmailStore.getState().mailboxes).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMailboxRole', () => {
|
||||
it('should assign a role to a mailbox', async () => {
|
||||
const newMailboxes = useEmailStore.getState().mailboxes.map(mb =>
|
||||
mb.id === 'custom-1' ? { ...mb, role: 'archive' } : mb
|
||||
);
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(newMailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'custom-1', 'archive');
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { role: 'archive' });
|
||||
});
|
||||
|
||||
it('should clear existing role from another mailbox when reassigning', async () => {
|
||||
const newMailboxes = useEmailStore.getState().mailboxes.map(mb => {
|
||||
if (mb.id === 'custom-1') return { ...mb, role: 'trash' };
|
||||
if (mb.id === 'trash-1') return { ...mb, role: undefined };
|
||||
return mb;
|
||||
});
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(newMailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'custom-1', 'trash');
|
||||
|
||||
// Should first clear trash role from trash-1
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: null });
|
||||
// Then set trash role on custom-1
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { role: 'trash' });
|
||||
});
|
||||
|
||||
it('should clear role from a mailbox when role is null', async () => {
|
||||
const newMailboxes = useEmailStore.getState().mailboxes.map(mb =>
|
||||
mb.id === 'trash-1' ? { ...mb, role: undefined } : mb
|
||||
);
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(newMailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'trash-1', null);
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: null });
|
||||
});
|
||||
|
||||
it('should not clear role from same mailbox when re-assigning same role', async () => {
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(useEmailStore.getState().mailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'trash-1', 'trash');
|
||||
|
||||
// Should only call once (to set the role), not twice (no need to clear from same mailbox)
|
||||
expect(client.updateMailbox).toHaveBeenCalledTimes(1);
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: 'trash' });
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const client = makeMockClient({
|
||||
updateMailbox: vi.fn().mockRejectedValue(new Error('Role update failed')),
|
||||
});
|
||||
|
||||
await expect(
|
||||
useEmailStore.getState().setMailboxRole(client, 'custom-1', 'archive')
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useEmailStore.getState().error).toBe('Role update failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,12 @@ interface EmailStore {
|
||||
collapseAllThreads: () => void;
|
||||
updateThreadCache: (threadId: string, emails: Email[]) => void;
|
||||
|
||||
// Mailbox management
|
||||
createMailbox: (client: JMAPClient, name: string, parentId?: string) => Promise<void>;
|
||||
renameMailbox: (client: JMAPClient, mailboxId: string, name: string) => Promise<void>;
|
||||
deleteMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
|
||||
setMailboxRole: (client: JMAPClient, mailboxId: string, role: string | null) => Promise<void>;
|
||||
|
||||
// Mock data for demo
|
||||
loadMockData: () => void;
|
||||
}
|
||||
@@ -1176,6 +1182,68 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
set({ threadEmailsCache: newCache });
|
||||
},
|
||||
|
||||
// Mailbox management
|
||||
createMailbox: async (client, name, parentId) => {
|
||||
try {
|
||||
await client.createMailbox(name, parentId);
|
||||
await get().fetchMailboxes(client);
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to create folder' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
renameMailbox: async (client, mailboxId, name) => {
|
||||
try {
|
||||
await client.updateMailbox(mailboxId, { name });
|
||||
set({
|
||||
mailboxes: get().mailboxes.map(mb =>
|
||||
mb.id === mailboxId ? { ...mb, name } : mb
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to rename folder' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteMailbox: async (client, mailboxId) => {
|
||||
try {
|
||||
await client.deleteMailbox(mailboxId);
|
||||
const { mailboxes, selectedMailbox } = get();
|
||||
const newMailboxes = mailboxes.filter(mb => mb.id !== mailboxId);
|
||||
const updates: Partial<EmailStore> = { mailboxes: newMailboxes };
|
||||
// If the deleted mailbox was selected, switch to inbox
|
||||
if (selectedMailbox === mailboxId) {
|
||||
const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared);
|
||||
if (inbox) {
|
||||
updates.selectedMailbox = inbox.id;
|
||||
}
|
||||
}
|
||||
set(updates as EmailStore);
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to delete folder' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setMailboxRole: async (client, mailboxId, role) => {
|
||||
try {
|
||||
// If assigning a role, first clear that role from any other mailbox
|
||||
if (role) {
|
||||
const existingMailbox = get().mailboxes.find(mb => mb.role === role && !mb.isShared);
|
||||
if (existingMailbox && existingMailbox.id !== mailboxId) {
|
||||
await client.updateMailbox(existingMailbox.id, { role: null });
|
||||
}
|
||||
}
|
||||
await client.updateMailbox(mailboxId, { role });
|
||||
await get().fetchMailboxes(client);
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to update folder role' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadMockData: () => {
|
||||
const mockEmails: Email[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user