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.
This commit is contained in:
committed by
Linus Rath
parent
dda9fd1433
commit
2fac6ebfb8
@@ -0,0 +1,205 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user