Files
SRCmail/lib/__tests__/unified-mailbox.test.ts
T
Stefan HildebrandtandLinus Rath 2fac6ebfb8 test: add characterisation tests for untested integration seams
Golden-master tests pinning the CURRENT behavior of high-value modules
that had no coverage — integration seams, security helpers, two API
route handlers, and complex pure utils. 111 tests across 12 files.

New tests:
- auth-crypto / session-cookie: AES-256-GCM session encryption roundtrip,
  tamper/version/missing-secret handling; cookie-slot naming.
- unified-mailbox: multi-account fan-out, sort, totals, per-account error
  isolation, personal-vs-shared JMAP target resolution, counts/roles.
- account-state-manager: snapshot/restore across the six real Zustand
  stores; clearAllStores reset shape; evict.
- mdn: RFC 5322 MDN assembly (CRLF, RFC2047, base64 wrap, headers).
- tnef: winmail.dat binary parsing from hand-built fixtures.
- download-filename / subject-prefix / birthday-calendar / eml-import:
  filename templating, multilingual prefix stripping, birthday event
  generation, .eml/.zip import.
- webdav / caldav-discover route handlers: auth guards, path validation,
  upstream URL construction, candidate probing.
- helpers/factories.ts: shared makeEmail/makeMailbox/makeFakeJmapClient.

Tests follow the repo's existing patterns (route-import, fake IJMAPClient,
fetch spy, real store singletons). Where current behavior looks buggy it
is pinned and flagged with a // CHARACTERISATION: comment (see PR for the
suspected-bugs list); no production code is changed.
2026-06-19 23:52:42 +02:00

206 lines
9.0 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import {
findMailboxByRole,
fetchUnifiedEmails,
searchUnifiedEmails,
advancedSearchUnifiedEmails,
fetchUnifiedMailboxCounts,
getUnifiedRoles,
type UnifiedAccountClient,
} from '@/lib/unified-mailbox';
// ── factories ────────────────────────────────────────────────────────────────
const makeEmail = (id: string, receivedAt: string): Email =>
({ id, receivedAt } as unknown as Email);
const makeMailbox = (over: Partial<Mailbox> & { role: string }): Mailbox =>
({ id: `mb-${over.role}`, unreadEmails: 0, totalEmails: 0, ...over } as unknown as Mailbox);
type FetchResult = { emails: Email[]; total: number; hasMore: boolean };
function makeAccount(
over: Partial<UnifiedAccountClient> & { accountId: string },
clientImpl: Partial<IJMAPClient> = {},
): UnifiedAccountClient {
return {
accountLabel: over.accountId,
mailboxes: [],
client: clientImpl as unknown as IJMAPClient,
...over,
};
}
describe('findMailboxByRole', () => {
it('returns the first mailbox matching the role', () => {
const a = makeMailbox({ role: 'inbox', id: 'a' });
const b = makeMailbox({ role: 'inbox', id: 'b' });
expect(findMailboxByRole([a, b], 'inbox')).toBe(a);
});
it('returns undefined when no mailbox has the role', () => {
expect(findMailboxByRole([makeMailbox({ role: 'sent' })], 'inbox')).toBeUndefined();
});
});
describe('fetchUnifiedEmails', () => {
it('merges across accounts and sorts by receivedAt descending, decorating each email', async () => {
const acc1 = makeAccount(
{ accountId: 'A', accountLabel: 'Account A', mailboxes: [makeMailbox({ role: 'inbox', id: 'a-in' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({
emails: [makeEmail('a1', '2026-01-01T10:00:00Z'), makeEmail('a2', '2026-01-03T10:00:00Z')],
total: 5, hasMore: false,
})) },
);
const acc2 = makeAccount(
{ accountId: 'B', accountLabel: 'Account B', mailboxes: [makeMailbox({ role: 'inbox', id: 'b-in' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({
emails: [makeEmail('b1', '2026-01-02T10:00:00Z')],
total: 3, hasMore: true,
})) },
);
const result = await fetchUnifiedEmails([acc1, acc2], 'inbox', 20, 0);
expect(result.emails.map((e) => e.id)).toEqual(['a2', 'b1', 'a1']); // newest first
expect(result.total).toBe(8); // sum of per-account totals, not merged length
expect(result.hasMore).toBe(true); // OR across accounts
expect(result.errors.size).toBe(0);
// decoration
const a2 = result.emails.find((e) => e.id === 'a2')!;
expect(a2.accountId).toBe('A');
expect(a2.accountLabel).toBe('Account A');
// getEmails called with (mailboxId, accountId=undefined for personal, limit, position)
expect(acc1.client.getEmails).toHaveBeenCalledWith('a-in', undefined, 20, 0);
});
it('isolates per-account errors and still returns the rest', async () => {
const ok = makeAccount(
{ accountId: 'OK', mailboxes: [makeMailbox({ role: 'inbox', id: 'ok-in' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({ emails: [makeEmail('x', '2026-01-01T00:00:00Z')], total: 1, hasMore: false })) },
);
const boom = makeAccount(
{ accountId: 'BOOM', mailboxes: [makeMailbox({ role: 'inbox', id: 'boom-in' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => { throw new Error('network down'); }) },
);
const result = await fetchUnifiedEmails([ok, boom], 'inbox', 20, 0);
expect(result.emails.map((e) => e.id)).toEqual(['x']);
expect(result.total).toBe(1);
expect(result.errors.get('BOOM')).toBe('network down');
});
it('stringifies a non-Error rejection', async () => {
const acc = makeAccount(
{ accountId: 'S', mailboxes: [makeMailbox({ role: 'inbox' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => { throw 'boom-string'; }) },
);
const result = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
expect(result.errors.get('S')).toBe('boom-string');
});
it('skips accounts that have no mailbox for the role (no error recorded)', async () => {
const getEmails = vi.fn(async (): Promise<FetchResult> => ({ emails: [], total: 0, hasMore: false }));
const acc = makeAccount(
{ accountId: 'NOROLE', mailboxes: [makeMailbox({ role: 'sent' })] },
{ getEmails },
);
const result = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() });
expect(getEmails).not.toHaveBeenCalled();
});
it('returns an empty result for no accounts', async () => {
const result = await fetchUnifiedEmails([], 'inbox', 20, 0);
expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() });
});
it('CHARACTERISATION: mutates the source email objects in place (shared reference)', async () => {
const original = makeEmail('m1', '2026-01-01T00:00:00Z');
const acc = makeAccount(
{ accountId: 'A', accountLabel: 'Label A', mailboxes: [makeMailbox({ role: 'inbox' })] },
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({ emails: [original], total: 1, hasMore: false })) },
);
await fetchUnifiedEmails([acc], 'inbox', 20, 0);
// The very object passed back by the client was mutated, not a copy.
expect(original.accountId).toBe('A');
expect(original.accountLabel).toBe('Label A');
});
});
describe('resolveJmapTarget (via searchUnifiedEmails / advancedSearchUnifiedEmails)', () => {
const empty = async (): Promise<FetchResult> => ({ emails: [], total: 0, hasMore: false });
it('personal account: uses mailbox.id and undefined accountId', async () => {
const searchEmails = vi.fn(empty);
const acc = makeAccount(
{ accountId: 'A', mailboxes: [makeMailbox({ role: 'inbox', id: 'real-id' })] },
{ searchEmails },
);
await searchUnifiedEmails([acc], 'inbox', 'hello', 10, 0);
expect(searchEmails).toHaveBeenCalledWith('hello', 'real-id', undefined, 10, 0);
});
it('shared account: uses mailbox.originalId and the owner accountId', async () => {
const searchEmails = vi.fn(empty);
const acc = makeAccount(
{ accountId: 'OWNER', isShared: true, mailboxes: [makeMailbox({ role: 'inbox', id: 'OWNER:orig', originalId: 'orig' })] },
{ searchEmails },
);
await searchUnifiedEmails([acc], 'inbox', 'q', 10, 5);
expect(searchEmails).toHaveBeenCalledWith('q', 'orig', 'OWNER', 10, 5);
});
it('shared account without originalId: falls back to mailbox.id', async () => {
const searchEmails = vi.fn(empty);
const acc = makeAccount(
{ accountId: 'OWNER', isShared: true, mailboxes: [makeMailbox({ role: 'inbox', id: 'just-id' })] },
{ searchEmails },
);
await searchUnifiedEmails([acc], 'inbox', 'q', 10, 0);
expect(searchEmails).toHaveBeenCalledWith('q', 'just-id', 'OWNER', 10, 0);
});
it('advancedSearch: builds the filter from the resolved mailbox id and forwards accountId', async () => {
const advancedSearchEmails = vi.fn(empty);
const acc = makeAccount(
{ accountId: 'A', mailboxes: [makeMailbox({ role: 'inbox', id: 'mbx' })] },
{ advancedSearchEmails },
);
const filterFor = vi.fn((mailboxId: string) => ({ inMailbox: mailboxId, from: 'x' }));
await advancedSearchUnifiedEmails([acc], 'inbox', filterFor, 10, 0);
expect(filterFor).toHaveBeenCalledWith('mbx');
expect(advancedSearchEmails).toHaveBeenCalledWith({ inMailbox: 'mbx', from: 'x' }, undefined, 10, 0);
});
});
describe('fetchUnifiedMailboxCounts', () => {
it('aggregates counts per role across accounts, in ALL_UNIFIED_ROLES order, omitting absent roles', () => {
const acc1 = makeAccount({ accountId: 'A', mailboxes: [
makeMailbox({ role: 'inbox', unreadEmails: 2, totalEmails: 10 }),
makeMailbox({ role: 'sent', unreadEmails: 0, totalEmails: 4 }),
] });
const acc2 = makeAccount({ accountId: 'B', mailboxes: [
makeMailbox({ role: 'inbox', unreadEmails: 3, totalEmails: 7 }),
] });
expect(fetchUnifiedMailboxCounts([acc1, acc2])).toEqual([
{ role: 'inbox', unreadEmails: 5, totalEmails: 17 },
{ role: 'sent', unreadEmails: 0, totalEmails: 4 },
]);
});
it('returns an empty array when no accounts have mailboxes', () => {
expect(fetchUnifiedMailboxCounts([makeAccount({ accountId: 'A' })])).toEqual([]);
});
});
describe('getUnifiedRoles', () => {
it('lists roles present in at least one account once, in canonical order', () => {
const acc1 = makeAccount({ accountId: 'A', mailboxes: [makeMailbox({ role: 'drafts' }), makeMailbox({ role: 'inbox' })] });
const acc2 = makeAccount({ accountId: 'B', mailboxes: [makeMailbox({ role: 'inbox' }), makeMailbox({ role: 'trash' })] });
expect(getUnifiedRoles([acc1, acc2])).toEqual(['inbox', 'drafts', 'trash']);
});
});