mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-22 16:01:12 +00:00
216 lines
8.4 KiB
TypeScript
216 lines
8.4 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
buildQueryRequest,
|
|
resolveScopeTargets,
|
|
UNIFIED_INBOX_ID,
|
|
type EmailQuery,
|
|
type EmailPage,
|
|
} from '../jmap/search-utils';
|
|
import type { Mailbox } from '../jmap/types';
|
|
|
|
const baseSort = { by: 'receivedAt' as const, ascending: false };
|
|
const firstPage: EmailPage = { limit: 50 };
|
|
const noRoles = {};
|
|
|
|
describe('buildQueryRequest', () => {
|
|
describe('folder scope', () => {
|
|
it('sets inMailbox from the mailboxId with no text', () => {
|
|
const q: EmailQuery = { scope: { kind: 'folder', mailboxId: 'mb1' }, sort: baseSort };
|
|
const r = buildQueryRequest(q, firstPage, noRoles);
|
|
expect(r.filter).toEqual({ inMailbox: 'mb1' });
|
|
});
|
|
|
|
it('AND-combines text with inMailbox', () => {
|
|
const q: EmailQuery = { text: 'hello', scope: { kind: 'folder', mailboxId: 'mb1' }, sort: baseSort };
|
|
const r = buildQueryRequest(q, firstPage, noRoles);
|
|
expect(r.filter).toEqual({
|
|
operator: 'AND',
|
|
conditions: [{ text: 'hello' }, { inMailbox: 'mb1' }],
|
|
});
|
|
});
|
|
|
|
it('never adds a trash/junk NOT clause even when role ids are known', () => {
|
|
const q: EmailQuery = { text: 'hi', scope: { kind: 'folder', mailboxId: 'mb1' }, sort: baseSort };
|
|
const r = buildQueryRequest(q, firstPage, { trashId: 't', junkId: 'j' });
|
|
expect(JSON.stringify(r.filter)).not.toContain('NOT');
|
|
});
|
|
});
|
|
|
|
describe('global scope', () => {
|
|
it('excludes trash and junk via NOT when includeTrashJunk is false', () => {
|
|
const q: EmailQuery = { text: 'hi', scope: { kind: 'all', includeTrashJunk: false }, sort: baseSort };
|
|
const r = buildQueryRequest(q, firstPage, { trashId: 't', junkId: 'j' });
|
|
expect(r.filter).toEqual({
|
|
operator: 'AND',
|
|
conditions: [
|
|
{ text: 'hi' },
|
|
{ operator: 'NOT', conditions: [{ inMailbox: 't' }, { inMailbox: 'j' }] },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('omits the NOT clause entirely when includeTrashJunk is true', () => {
|
|
const q: EmailQuery = { text: 'hi', scope: { kind: 'all', includeTrashJunk: true }, sort: baseSort };
|
|
const r = buildQueryRequest(q, firstPage, { trashId: 't', junkId: 'j' });
|
|
expect(r.filter).toEqual({ text: 'hi' });
|
|
});
|
|
|
|
it('drops only the missing role from the NOT clause (junk id absent)', () => {
|
|
const q: EmailQuery = { text: 'hi', scope: { kind: 'all', includeTrashJunk: false }, sort: baseSort };
|
|
const r = buildQueryRequest(q, firstPage, { trashId: 't' });
|
|
expect(r.filter).toEqual({
|
|
operator: 'AND',
|
|
conditions: [{ text: 'hi' }, { operator: 'NOT', conditions: [{ inMailbox: 't' }] }],
|
|
});
|
|
});
|
|
|
|
it('omits the NOT clause when both role ids are missing', () => {
|
|
const q: EmailQuery = { text: 'hi', scope: { kind: 'all', includeTrashJunk: false }, sort: baseSort };
|
|
const r = buildQueryRequest(q, firstPage, noRoles);
|
|
expect(r.filter).toEqual({ text: 'hi' });
|
|
});
|
|
|
|
it('collapses an empty base to the bare NOT clause', () => {
|
|
const q: EmailQuery = { scope: { kind: 'all', includeTrashJunk: false }, sort: baseSort };
|
|
const r = buildQueryRequest(q, firstPage, { trashId: 't', junkId: 'j' });
|
|
expect(r.filter).toEqual({
|
|
operator: 'NOT',
|
|
conditions: [{ inMailbox: 't' }, { inMailbox: 'j' }],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('sort mapping (RFC 8621)', () => {
|
|
it.each([
|
|
['receivedAt', true],
|
|
['receivedAt', false],
|
|
['from', true],
|
|
['subject', false],
|
|
['size', true],
|
|
] as const)('maps %s ascending=%s', (by, ascending) => {
|
|
const q: EmailQuery = { scope: { kind: 'folder', mailboxId: 'mb1' }, sort: { by, ascending } };
|
|
const r = buildQueryRequest(q, firstPage, noRoles);
|
|
expect(r.sort).toEqual([{ property: by, isAscending: ascending }]);
|
|
});
|
|
});
|
|
|
|
describe('paging', () => {
|
|
it('first page: position 0, no anchor, calculateTotal true', () => {
|
|
const q: EmailQuery = { scope: { kind: 'folder', mailboxId: 'mb1' }, sort: baseSort };
|
|
const r = buildQueryRequest(q, { limit: 50 }, noRoles);
|
|
expect(r.position).toBe(0);
|
|
expect(r.anchor).toBeUndefined();
|
|
expect(r.anchorOffset).toBeUndefined();
|
|
expect(r.calculateTotal).toBe(true);
|
|
expect(r.limit).toBe(50);
|
|
});
|
|
|
|
it('anchored page: anchor set, anchorOffset defaults to 1, no position, calculateTotal false', () => {
|
|
const q: EmailQuery = { scope: { kind: 'folder', mailboxId: 'mb1' }, sort: baseSort };
|
|
const r = buildQueryRequest(q, { limit: 25, anchor: 'e99' }, noRoles);
|
|
expect(r.anchor).toBe('e99');
|
|
expect(r.anchorOffset).toBe(1);
|
|
expect(r.position).toBeUndefined();
|
|
expect(r.calculateTotal).toBe(false);
|
|
expect(r.limit).toBe(25);
|
|
});
|
|
|
|
it('anchored page respects an explicit anchorOffset', () => {
|
|
const q: EmailQuery = { scope: { kind: 'folder', mailboxId: 'mb1' }, sort: baseSort };
|
|
const r = buildQueryRequest(q, { limit: 25, anchor: 'e99', anchorOffset: 0 }, noRoles);
|
|
expect(r.anchorOffset).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('UNIFIED_INBOX_ID', () => {
|
|
it('is the unified:inbox sentinel', () => {
|
|
expect(UNIFIED_INBOX_ID).toBe('unified:inbox');
|
|
});
|
|
});
|
|
|
|
function mkMailbox(o: Partial<Mailbox>): Mailbox {
|
|
return { id: '', name: '', sortOrder: 1 as const, ...o } as Mailbox;
|
|
}
|
|
|
|
const multiAccountMailboxes: Mailbox[] = [
|
|
mkMailbox({ id: 'inbox-a', role: 'inbox', accountId: 'acc-a' }),
|
|
mkMailbox({ id: 'archive-a', role: 'archive', accountId: 'acc-a' }),
|
|
mkMailbox({ id: 'acc-b:inbox-b', originalId: 'inbox-b', role: 'inbox', isShared: true, accountId: 'acc-b' }),
|
|
mkMailbox({ id: 'acc-c:inbox-c', originalId: 'inbox-c', role: 'inbox', isShared: true, accountId: 'acc-c' }),
|
|
];
|
|
|
|
describe('resolveScopeTargets', () => {
|
|
it('returns null for folder scope (keeps the single-account path)', () => {
|
|
const targets = resolveScopeTargets(
|
|
{ kind: 'folder', mailboxId: 'inbox-a' },
|
|
multiAccountMailboxes,
|
|
[]
|
|
);
|
|
expect(targets).toBeNull();
|
|
});
|
|
|
|
it("unified: targets each included account's inbox with un-namespaced mailbox ids", () => {
|
|
const targets = resolveScopeTargets({ kind: 'unified' }, multiAccountMailboxes, []);
|
|
expect(targets).toEqual([
|
|
{ accountId: 'acc-a', mailboxId: 'inbox-a' },
|
|
{ accountId: 'acc-b', mailboxId: 'inbox-b' },
|
|
{ accountId: 'acc-c', mailboxId: 'inbox-c' },
|
|
]);
|
|
});
|
|
|
|
it('unified: omits excluded accounts', () => {
|
|
const targets = resolveScopeTargets({ kind: 'unified' }, multiAccountMailboxes, ['acc-b']);
|
|
expect(targets).toEqual([
|
|
{ accountId: 'acc-a', mailboxId: 'inbox-a' },
|
|
{ accountId: 'acc-c', mailboxId: 'inbox-c' },
|
|
]);
|
|
});
|
|
|
|
it('unified: a stale exclude entry for a removed account changes nothing', () => {
|
|
const targets = resolveScopeTargets({ kind: 'unified' }, multiAccountMailboxes, ['acc-gone']);
|
|
expect(targets).toEqual([
|
|
{ accountId: 'acc-a', mailboxId: 'inbox-a' },
|
|
{ accountId: 'acc-b', mailboxId: 'inbox-b' },
|
|
{ accountId: 'acc-c', mailboxId: 'inbox-c' },
|
|
]);
|
|
});
|
|
|
|
it('unified: skips inbox mailboxes that carry no accountId', () => {
|
|
const withLegacy = [mkMailbox({ id: 'inbox-x', role: 'inbox' }), ...multiAccountMailboxes];
|
|
const targets = resolveScopeTargets({ kind: 'unified' }, withLegacy, []);
|
|
expect(targets).toEqual([
|
|
{ accountId: 'acc-a', mailboxId: 'inbox-a' },
|
|
{ accountId: 'acc-b', mailboxId: 'inbox-b' },
|
|
{ accountId: 'acc-c', mailboxId: 'inbox-c' },
|
|
]);
|
|
});
|
|
|
|
it('all (Everywhere): one whole-account target per session account, ignoring the exclude list', () => {
|
|
const targets = resolveScopeTargets(
|
|
{ kind: 'all', includeTrashJunk: false },
|
|
multiAccountMailboxes,
|
|
['acc-b']
|
|
);
|
|
expect(targets).toEqual([
|
|
{ accountId: 'acc-a' },
|
|
{ accountId: 'acc-b' },
|
|
{ accountId: 'acc-c' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('buildQueryRequest with unified scope', () => {
|
|
it('yields an account-agnostic filter: no inMailbox, no trash/junk NOT clause', () => {
|
|
const q: EmailQuery = { text: 'hi', scope: { kind: 'unified' }, sort: baseSort };
|
|
const r = buildQueryRequest(q, firstPage, { trashId: 't', junkId: 'j' });
|
|
expect(r.filter).toEqual({ text: 'hi' });
|
|
});
|
|
|
|
it('yields an empty filter with no text or filters', () => {
|
|
const q: EmailQuery = { scope: { kind: 'unified' }, sort: baseSort };
|
|
const r = buildQueryRequest(q, firstPage, { trashId: 't', junkId: 'j' });
|
|
expect(r.filter).toEqual({});
|
|
});
|
|
});
|