Files
webmail/lib/global-search/__tests__/query-parser.test.ts
T

119 lines
5.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { accountMatches, matchesTerms, parseSearchQuery, tokenizeQuery, withScopeAndAccount } from '../query-parser';
describe('parseSearchQuery', () => {
it('treats plain words as free text terms', () => {
const parsed = parseSearchQuery(' quarterly Report ');
expect(parsed.text).toBe('quarterly Report');
expect(parsed.terms).toEqual(['quarterly', 'report']);
expect(parsed.scope).toBe('all');
expect(parsed.hasOperators).toBe(false);
});
it('keeps quoted phrases intact for the server and lower-cases them for local matching', () => {
const parsed = parseSearchQuery('"Q3 planning" budget');
expect(parsed.text).toBe('"Q3 planning" budget');
expect(parsed.terms).toEqual(['q3 planning', 'budget']);
});
it('tolerates an unterminated quote while the user is still typing', () => {
expect(parseSearchQuery('"half typed').terms).toEqual(['half typed']);
});
it('parses in: with aliases, including trash/junk as mail-only roles', () => {
expect(parseSearchQuery('in:contacts bob').scope).toBe('contacts');
expect(parseSearchQuery('in:events bob').scope).toBe('calendar');
expect(parseSearchQuery('in:drive bob').scope).toBe('files');
const trash = parseSearchQuery('in:trash invoice');
expect(trash.scope).toBe('mail');
expect(trash.mailboxRole).toBe('trash');
expect(parseSearchQuery('in:spam x').mailboxRole).toBe('junk');
});
it('maps mail operators onto SearchFilters', () => {
const parsed = parseSearchQuery('from:alice to:bob subject:"Q3 report" has:attachment is:unread is:starred invoice');
expect(parsed.mail.from).toBe('alice');
expect(parsed.mail.to).toBe('bob');
expect(parsed.mail.subject).toBe('Q3 report');
expect(parsed.mail.hasAttachment).toBe(true);
expect(parsed.mail.isUnread).toBe(true);
expect(parsed.mail.isStarred).toBe(true);
expect(parsed.text).toBe('invoice');
expect(parsed.hasOperators).toBe(true);
});
it('accepts valid dates and feeds them to both mail and calendar', () => {
const parsed = parseSearchQuery('after:2026-01-01 before:2026-02-28 sync');
expect(parsed.after).toBe('2026-01-01');
expect(parsed.before).toBe('2026-02-28');
expect(parsed.mail.dateAfter).toBe('2026-01-01');
expect(parsed.mail.dateBefore).toBe('2026-02-28');
});
it('keeps malformed dates and unknown operators as free text', () => {
const parsed = parseSearchQuery('after:2026-02-31 size:>5MB before:yesterday tag:x hello');
expect(parsed.after).toBeNull();
expect(parsed.before).toBeNull();
expect(parsed.terms).toEqual(['after:2026-02-31', 'size:>5mb', 'before:yesterday', 'tag:x', 'hello']);
expect(parsed.hasOperators).toBe(false);
});
it('is:anything lifts the trash/junk exclusion', () => {
expect(parseSearchQuery('is:anything x').includeTrashAndJunk).toBe(true);
expect(parseSearchQuery('x').includeTrashAndJunk).toBe(false);
});
it('lower-cases the account operator and supports quoted labels', () => {
expect(parseSearchQuery('account:Work x').account).toBe('work');
expect(parseSearchQuery('account:"Linus Rath" x').account).toBe('linus rath');
});
it('handles a colon inside an ordinary word', () => {
const parsed = parseSearchQuery('re: meeting 10:30');
expect(parsed.terms).toEqual(['re:', 'meeting', '10:30']);
});
});
describe('tokenizeQuery', () => {
it('splits operators, quoted values and words', () => {
expect(tokenizeQuery('a from:"x y" "p q" k:v')).toEqual([
{ key: null, value: 'a', quoted: false, original: 'a' },
{ key: 'from', value: 'x y', quoted: true, original: 'from:"x y"' },
{ key: null, value: 'p q', quoted: true, original: '"p q"' },
{ key: 'k', value: 'v', quoted: false, original: 'k:v' },
]);
});
});
describe('withScopeAndAccount', () => {
it('rewrites in: and account: without touching the rest', () => {
expect(withScopeAndAccount('in:mail from:bob hello account:old', 'contacts', 'Work')).toBe('from:bob hello in:contacts account:Work');
expect(withScopeAndAccount('in:contacts hello', 'all', null)).toBe('hello');
expect(withScopeAndAccount('hello', 'files', 'Linus Rath')).toBe('hello in:files account:"Linus Rath"');
});
it('keeps in:trash because it is a folder, not a scope', () => {
expect(withScopeAndAccount('in:trash x', 'all', null)).toBe('in:trash x');
});
});
describe('accountMatches', () => {
const account = { label: 'Work mail', email: 'linus@example.org', localAccountId: 'linus@example.org@mail.example.org' };
it('matches label substrings, email prefixes and the login id', () => {
expect(accountMatches(null, account)).toBe(true);
expect(accountMatches('work', account)).toBe(true);
expect(accountMatches('linus@', account)).toBe(true);
expect(accountMatches('linus@example.org', account)).toBe(true);
expect(accountMatches('other', account)).toBe(false);
});
});
describe('matchesTerms', () => {
it('requires every term as a substring of some field', () => {
expect(matchesTerms(['bob', 'partner'], ['Bob Miller', 'bob@partner.example'])).toBe(true);
expect(matchesTerms(['bob', 'zzz'], ['Bob Miller'])).toBe(false);
expect(matchesTerms([], ['x'])).toBe(false);
expect(matchesTerms(['x'], [null, undefined, ''])).toBe(false);
});
});