Files
webmail/lib/__tests__/search-utils.test.ts
T
Linus Rath c633185cda Merge branch 'main' into feat/stalwart-1.0
# Conflicts:
#	CONTRIBUTING.md
#	LICENSE
#	README.md
#	app/api/calendar-agenda/route.ts
#	app/api/wopi/files/[fileId]/contents/route.ts
#	app/api/wopi/launch/route.ts
#	components/calendar/calendar-app.tsx
#	components/calendar/calendar-day-view.tsx
#	components/calendar/calendar-month-view.tsx
#	components/calendar/calendar-week-view.tsx
#	components/calendar/event-modal.tsx
#	components/contacts/contact-detail.tsx
#	components/email/email-composer.tsx
#	components/email/email-list.tsx
#	components/files/file-browser.tsx
#	components/files/files-app.tsx
#	components/files/folder-tree-sidebar.tsx
#	components/filters/filter-rule-modal.tsx
#	components/mail/mail-app.tsx
#	hooks/use-mailbox-drop.ts
#	integration/run-tests.sh
#	lib/__tests__/calendar-agenda-route.test.ts
#	lib/__tests__/jmap-draft-lifecycle.test.ts
#	lib/__tests__/web-push.test.ts
#	lib/account-state-manager.ts
#	lib/jmap/client-interface.ts
#	lib/jmap/client.ts
#	lib/jmap/types.ts
#	lib/message-list-order.ts
#	lib/sieve/generator.ts
#	lib/sieve/parser.ts
#	lib/telemetry/payload.ts
#	lib/thread-utils.ts
#	lib/unified-mailbox.ts
#	lib/web-push.ts
#	lib/wopi/token.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
#	scripts/lite/lib.mjs
#	stores/__tests__/calendar-store-synthetic-ids.test.ts
#	stores/__tests__/email-store-shared-folder-keywords.test.ts
#	stores/__tests__/email-store-unified-actions.test.ts
#	stores/__tests__/file-store.test.ts
#	stores/email-store.ts
#	stores/file-store.ts
#	stores/message-list-tabs-store.ts
2026-10-12 08:35:08 +02:00

569 lines
22 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
buildJMAPFilter,
isFilterEmpty,
activeFilterCount,
splitSearchText,
textConditions,
textFilter,
normalizePluginFilter,
DEFAULT_SEARCH_FILTERS,
} from '../jmap/search-utils';
import type { SearchFilters } from '../jmap/search-utils';
type Filter = Record<string, unknown>;
describe('normalizePluginFilter', () => {
// Plugin search.run and tab queries share this check: Stalwart 1.0 panics
// on JMAP Metadata filter conditions.
it('rejects JMAP Metadata conditions in a plugin search', () => {
expect(() => normalizePluginFilter('search.run: query', {
operator: 'AND',
conditions: [{ from: 'a@example.com' }, { privateMetadataTextContains: 'x' }],
})).toThrow('search.run: query may not filter on JMAP Metadata ("privateMetadataTextContains")');
expect(normalizePluginFilter('search.run: query', { from: 'a@example.com' })).toEqual({ from: 'a@example.com' });
});
});
/** Stalwart rejects a filter object with both `operator` and property keys. */
function expectNoMixedKeys(filter: Filter): void {
if ('operator' in filter) {
expect(Object.keys(filter).sort()).toEqual(['conditions', 'operator']);
for (const c of filter.conditions as Filter[]) expectNoMixedKeys(c);
} else {
expect(filter).not.toHaveProperty('conditions');
}
}
// ---------------------------------------------------------------------------
// A small model of Email/query text conditions on Stalwart's built-in index,
// so the tests can check what a filter finds on each version, not only its
// shape. It leaves out stemming and language detection.
// - 1.0 (store/src/search/document.rs `has_text`): a value wrapped in `"` or
// `'` is an exact phrase (word positions exist for subject and body only;
// address fields fall back to all words); a value ending in `*` is one
// prefix over the rest, lower-cased and not split into words, at most 16
// bytes, matching words of at most 16 bytes; anything else needs all its
// words in the same field.
// - 0.16: no phrases and no prefixes; every value needs all its words in the
// same field (a quoted one without stemming, which the model leaves out),
// and `*` only separates words.
// - `text` is an OR over from, to, subject and body.
// ---------------------------------------------------------------------------
type Version = '0.16' | '1.0';
interface Message { id: string; from: string; to: string; subject: string; body: string }
const MESSAGES: Message[] = [
{ id: 'report', from: 'Anna Muster <anna@firma.de>', to: 'team@firma.de', subject: 'Draft: annual report 2024', body: 'The numbers are final.' },
{ id: 'shuffled', from: 'Ben <ben@firma.de>', to: 'team@firma.de', subject: 'Report on annual goals, 2024 draft', body: '' },
{ id: 'budget', from: 'Carla <carla@firma.de>', to: 'team@firma.de', subject: 'Project budget', body: 'Numbers for Q3.' },
{ id: 'board', from: 'Dieter <dieter@firma.de>', to: 'team@firma.de', subject: 'Protokoll', body: 'Die Geschäftsführung hat entschieden.' },
];
const wordsOf = (value: string): string[] => value.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
const byteLength = (value: string) => new TextEncoder().encode(value).length;
function fieldMatches(version: Version, value: string, field: string, hasPositions: boolean): boolean {
const tokens = wordsOf(field);
if (version === '1.0') {
const exact = /^"([\s\S]*)"$/.exec(value) ?? /^'([\s\S]*)'$/.exec(value);
if (exact) {
const phrase = wordsOf(exact[1]);
if (phrase.length === 0) return false;
if (!hasPositions) return phrase.every((w) => tokens.includes(w));
return tokens.some((_, i) => phrase.every((w, j) => tokens[i + j] === w));
}
if (value.endsWith('*')) {
const prefix = value.slice(0, -1).trim().toLowerCase();
if (!prefix || byteLength(prefix) > 16) return false;
return tokens.some((t) => byteLength(t) <= 16 && t.startsWith(prefix));
}
}
const words = wordsOf(value);
return words.length > 0 && words.every((w) => tokens.includes(w));
}
function messageMatches(version: Version, filter: Filter, m: Message): boolean {
if (filter.operator === 'AND') return (filter.conditions as Filter[]).every((c) => messageMatches(version, c, m));
const entries = Object.entries(filter) as [string, string][];
return entries.every(([key, value]) => {
switch (key) {
case 'text':
return fieldMatches(version, value, m.from, false) || fieldMatches(version, value, m.to, false)
|| fieldMatches(version, value, m.subject, true) || fieldMatches(version, value, m.body, true);
case 'from':
case 'to':
return fieldMatches(version, value, m[key], false);
case 'subject':
case 'body':
return fieldMatches(version, value, m[key], true);
default:
throw new Error(`the model has no ${key} condition`);
}
});
}
const hits = (version: Version, filter: Filter) =>
MESSAGES.filter((m) => messageMatches(version, filter, m)).map((m) => m.id);
// ---------------------------------------------------------------------------
// splitSearchText / textConditions / textFilter
// ---------------------------------------------------------------------------
describe('splitSearchText', () => {
it('gives every quoted phrase a part of its own', () => {
expect(splitSearchText('"annual report" 2024 "draft"')).toEqual(['"annual report"', '2024', '"draft"']);
});
it('keeps plain queries as one part, as typed', () => {
expect(splitSearchText('project budget')).toEqual(['project budget']);
expect(splitSearchText('anna@firma.de')).toEqual(['anna@firma.de']);
expect(splitSearchText('Geschäftsführung')).toEqual(['Geschäftsführung']);
expect(splitSearchText(' project budget ')).toEqual(['project budget']);
});
it('gives every word ending in * a part of its own', () => {
expect(splitSearchText('project budg*')).toEqual(['project', 'budg*']);
expect(splitSearchText('"a b" c d*')).toEqual(['"a b"', 'c', 'd*']);
});
it('keeps the other words together in one part where the first of them was typed', () => {
expect(splitSearchText('q3 "annual report" budget')).toEqual(['q3 budget', '"annual report"']);
expect(splitSearchText('budg* "annual report" project')).toEqual(['budg*', '"annual report"', 'project']);
});
it('closes an unterminated quote at the end of the input', () => {
expect(splitSearchText('"half typed')).toEqual(['"half typed"']);
expect(splitSearchText('invoice "half typed')).toEqual(['invoice', '"half typed"']);
});
it('drops empty quotes and lone stars', () => {
expect(splitSearchText('""')).toEqual([]);
expect(splitSearchText('" " budget')).toEqual(['budget']);
expect(splitSearchText('*')).toEqual([]);
expect(splitSearchText('budget * **')).toEqual(['budget']);
});
it('strips outer single quotes, which would make the words one phrase', () => {
expect(splitSearchText("'annual' 2024 'draft'")).toEqual(["annual' 2024 'draft"]);
expect(splitSearchText("'annual report'")).toEqual(['annual report']);
expect(splitSearchText("''")).toEqual([]);
});
});
describe('textConditions / textFilter', () => {
it('makes one condition per part on the given property', () => {
expect(textConditions('"annual report" 2024 "draft"')).toEqual([
{ text: '"annual report"' }, { text: '2024' }, { text: '"draft"' },
]);
expect(textConditions('anna mül*', 'from')).toEqual([{ from: 'anna' }, { from: 'mül*' }]);
});
it('sends the input as before when no part is left', () => {
expect(textConditions('""')).toEqual([{ text: '""' }]);
expect(textConditions(' * ')).toEqual([{ text: '*' }]);
});
it('returns a single condition as is and ANDs several', () => {
expect(textFilter('project budget')).toEqual({ text: 'project budget' });
expect(textFilter('"annual report" 2024')).toEqual({
operator: 'AND',
conditions: [{ text: '"annual report"' }, { text: '2024' }],
});
});
});
describe('text search results', () => {
it('finds "annual report" 2024 "draft" on Stalwart 1.0, where the joined value was one phrase', () => {
expect(hits('1.0', { text: '"annual report" 2024 "draft"' })).toEqual([]);
expect(hits('1.0', textFilter('"annual report" 2024 "draft"'))).toEqual(['report']);
});
it('keeps the 0.16 hits for "annual report" 2024 "draft": an AND of the words', () => {
expect(hits('0.16', { text: '"annual report" 2024 "draft"' })).toEqual(['report', 'shuffled']);
expect(hits('0.16', textFilter('"annual report" 2024 "draft"'))).toEqual(['report', 'shuffled']);
});
it('finds project budg* on Stalwart 1.0 and changes nothing on 0.16', () => {
expect(hits('1.0', { text: 'project budg*' })).toEqual([]);
expect(hits('1.0', textFilter('project budg*'))).toEqual(['budget']);
expect(hits('0.16', { text: 'project budg*' })).toEqual([]);
expect(hits('0.16', textFilter('project budg*'))).toEqual([]);
});
it('sends plain queries unchanged, with the same hits on 0.16 and 1.0', () => {
const plain: Array<[string, string[]]> = [
['project budget', ['budget']],
['anna@firma.de', ['report']],
['Geschäftsführung', ['board']],
];
for (const [query, expected] of plain) {
expect(textFilter(query)).toEqual({ text: query });
expect(hits('0.16', textFilter(query))).toEqual(expected);
expect(hits('1.0', textFilter(query))).toEqual(expected);
}
});
});
// ---------------------------------------------------------------------------
// buildJMAPFilter
// ---------------------------------------------------------------------------
describe('buildJMAPFilter', () => {
const emptyFilters: SearchFilters = { ...DEFAULT_SEARCH_FILTERS };
// -- text query ----------------------------------------------------------
describe('text query', () => {
it('builds a text filter from textQuery alone, as typed', () => {
const result = buildJMAPFilter('pri', emptyFilters);
expect(result).toEqual({ text: 'pri' });
});
it('keeps a multi-word query as one text condition', () => {
const result = buildJMAPFilter('hello world', emptyFilters);
expect(result).toEqual({ text: 'hello world' });
});
it('returns empty object when no query and no filters', () => {
const result = buildJMAPFilter('', emptyFilters);
expect(result).toEqual({});
});
it('keeps a lone phrase as one condition', () => {
expect(buildJMAPFilter('"annual report"', emptyFilters)).toEqual({ text: '"annual report"' });
});
it('ANDs quoted phrases and prefixes as separate conditions, next to inMailbox', () => {
const result = buildJMAPFilter('"annual report" 2024 budg*', emptyFilters, 'inbox-1');
expect(result).toEqual({
operator: 'AND',
conditions: [
{ text: '"annual report"' },
{ text: '2024' },
{ text: 'budg*' },
{ inMailbox: 'inbox-1' },
],
});
expectNoMixedKeys(result);
});
it('splits the from, to, subject and body fields the same way', () => {
const filters: SearchFilters = {
...emptyFilters,
from: 'anna mül*',
to: 'team@firma.de',
subject: '"Q3 report" draft',
body: "'annual report'",
};
const result = buildJMAPFilter('', filters);
expect(result).toEqual({
operator: 'AND',
conditions: [
{ from: 'anna' },
{ from: 'mül*' },
{ to: 'team@firma.de' },
{ subject: '"Q3 report"' },
{ subject: 'draft' },
{ body: 'annual report' },
],
});
expectNoMixedKeys(result);
});
});
// -- individual field filters -------------------------------------------
describe('individual field filters', () => {
it('builds a from filter (no wildcard)', () => {
const filters: SearchFilters = { ...emptyFilters, from: 'alice' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ from: 'alice' });
});
it('builds a to filter (no wildcard)', () => {
const filters: SearchFilters = { ...emptyFilters, to: 'bob@example.com' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ to: 'bob@example.com' });
});
it('builds a subject filter (no wildcard)', () => {
const filters: SearchFilters = { ...emptyFilters, subject: 'meeting' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ subject: 'meeting' });
});
it('builds a body filter (no wildcard)', () => {
const filters: SearchFilters = { ...emptyFilters, body: 'payment' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ body: 'payment' });
});
});
// -- boolean / keyword filters ------------------------------------------
describe('boolean and keyword filters', () => {
it('adds hasAttachment: true', () => {
const filters: SearchFilters = { ...emptyFilters, hasAttachment: true };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ hasAttachment: true });
});
it('adds hasAttachment: false', () => {
const filters: SearchFilters = { ...emptyFilters, hasAttachment: false };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ hasAttachment: false });
});
it('adds notKeyword $seen for isUnread=true', () => {
const filters: SearchFilters = { ...emptyFilters, isUnread: true };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ notKeyword: '$seen' });
});
it('adds hasKeyword $seen for isUnread=false', () => {
const filters: SearchFilters = { ...emptyFilters, isUnread: false };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ hasKeyword: '$seen' });
});
it('adds hasKeyword $flagged for isStarred=true', () => {
const filters: SearchFilters = { ...emptyFilters, isStarred: true };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ hasKeyword: '$flagged' });
});
it('adds notKeyword $flagged for isStarred=false', () => {
const filters: SearchFilters = { ...emptyFilters, isStarred: false };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ notKeyword: '$flagged' });
});
});
// -- date filters -------------------------------------------------------
describe('date filters', () => {
it('adds after condition for dateAfter', () => {
const filters: SearchFilters = { ...emptyFilters, dateAfter: '2024-06-01' };
const result = buildJMAPFilter('', filters);
expect(result).toHaveProperty('after');
expect(new Date(result.after as string).toISOString()).toContain('2024-06-01');
});
it('adds before condition for dateBefore (end of day)', () => {
const filters: SearchFilters = { ...emptyFilters, dateBefore: '2024-12-31' };
const result = buildJMAPFilter('', filters);
expect(result).toHaveProperty('before');
const d = new Date(result.before as string);
expect(d.getHours()).toBe(23);
expect(d.getMinutes()).toBe(59);
});
it('ignores invalid dateAfter', () => {
const filters: SearchFilters = { ...emptyFilters, dateAfter: 'not-a-date' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({});
});
it('ignores invalid dateBefore', () => {
const filters: SearchFilters = { ...emptyFilters, dateBefore: 'nope' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({});
});
});
// -- mailbox filter ------------------------------------------------------
describe('mailbox filter', () => {
it('returns inMailbox filter when only mailboxId provided', () => {
const result = buildJMAPFilter('', emptyFilters, 'inbox-1');
expect(result).toEqual({ inMailbox: 'inbox-1' });
});
it('wraps in AND when mailboxId combined with text query', () => {
const result = buildJMAPFilter('pri', emptyFilters, 'inbox-1');
expect(result).toEqual({
operator: 'AND',
conditions: [
{ text: 'pri' },
{ inMailbox: 'inbox-1' },
],
});
});
});
// -- combinations --------------------------------------------------------
describe('combined filters', () => {
it('ANDs text query with from filter', () => {
const filters: SearchFilters = { ...emptyFilters, from: 'alice' };
const result = buildJMAPFilter('urgent', filters);
expect(result).toEqual({
operator: 'AND',
conditions: [
{ text: 'urgent' },
{ from: 'alice' },
],
});
});
it('ANDs text query, from, subject, hasAttachment, and mailbox', () => {
const filters: SearchFilters = {
...emptyFilters,
from: 'ceo@corp.com',
subject: 'quarterly',
hasAttachment: true,
};
const result = buildJMAPFilter('report', filters, 'mb-42');
expect(result).toEqual({
operator: 'AND',
conditions: [
{ text: 'report' },
{ from: 'ceo@corp.com' },
{ subject: 'quarterly' },
{ hasAttachment: true },
{ inMailbox: 'mb-42' },
],
});
});
it('ANDs all possible filters at once', () => {
const filters: SearchFilters = {
from: 'alice',
to: 'bob',
subject: 'invoice',
body: 'payment',
hasAttachment: true,
dateAfter: '2024-01-01',
dateBefore: '2024-12-31',
isUnread: true,
isStarred: true,
minSizeKb: '',
maxSizeKb: '',
};
const result = buildJMAPFilter('money', filters, 'mb-1') as {
operator: string;
conditions: Record<string, unknown>[];
};
expect(result.operator).toBe('AND');
expect(result.conditions).toContainEqual({ text: 'money' });
expect(result.conditions).toContainEqual({ from: 'alice' });
expect(result.conditions).toContainEqual({ to: 'bob' });
expect(result.conditions).toContainEqual({ subject: 'invoice' });
expect(result.conditions).toContainEqual({ body: 'payment' });
expect(result.conditions).toContainEqual({ hasAttachment: true });
expect(result.conditions).toContainEqual({ notKeyword: '$seen' });
expect(result.conditions).toContainEqual({ hasKeyword: '$flagged' });
expect(result.conditions).toContainEqual({ inMailbox: 'mb-1' });
// dateAfter and dateBefore produce after/before ISO strings
expect(result.conditions.some(c => 'after' in c)).toBe(true);
expect(result.conditions.some(c => 'before' in c)).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// isFilterEmpty
// ---------------------------------------------------------------------------
describe('isFilterEmpty', () => {
it('returns true for default filters', () => {
expect(isFilterEmpty(DEFAULT_SEARCH_FILTERS)).toBe(true);
});
it('returns false when from is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, from: 'alice' })).toBe(false);
});
it('returns false when to is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, to: 'bob' })).toBe(false);
});
it('returns false when subject is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, subject: 'hello' })).toBe(false);
});
it('returns false when body is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, body: 'world' })).toBe(false);
});
it('returns false when hasAttachment is true', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, hasAttachment: true })).toBe(false);
});
it('returns false when hasAttachment is false', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, hasAttachment: false })).toBe(false);
});
it('returns false when dateAfter is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, dateAfter: '2024-01-01' })).toBe(false);
});
it('returns false when dateBefore is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, dateBefore: '2024-12-31' })).toBe(false);
});
it('returns false when isUnread is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, isUnread: true })).toBe(false);
});
it('returns false when isStarred is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, isStarred: false })).toBe(false);
});
});
// ---------------------------------------------------------------------------
// activeFilterCount
// ---------------------------------------------------------------------------
describe('activeFilterCount', () => {
it('returns 0 for default filters', () => {
expect(activeFilterCount(DEFAULT_SEARCH_FILTERS)).toBe(0);
});
it('counts each active text field', () => {
expect(activeFilterCount({ ...DEFAULT_SEARCH_FILTERS, from: 'a', to: 'b' })).toBe(2);
});
it('counts boolean filters', () => {
expect(activeFilterCount({
...DEFAULT_SEARCH_FILTERS,
hasAttachment: true,
isUnread: false,
isStarred: true,
})).toBe(3);
});
it('counts date filters', () => {
expect(activeFilterCount({
...DEFAULT_SEARCH_FILTERS,
dateAfter: '2024-01-01',
dateBefore: '2024-12-31',
})).toBe(2);
});
it('counts all 9 filters when all active', () => {
expect(activeFilterCount({
from: 'a',
to: 'b',
subject: 'c',
body: 'd',
hasAttachment: true,
dateAfter: '2024-01-01',
dateBefore: '2024-12-31',
isUnread: true,
isStarred: true,
minSizeKb: '',
maxSizeKb: '',
})).toBe(9);
});
});
describe('size filters', () => {
const base = { ...DEFAULT_SEARCH_FILTERS };
it('converts KB bounds to minSize / maxSize byte conditions', () => {
expect(buildJMAPFilter('', { ...base, minSizeKb: '100', maxSizeKb: '2048' })).toEqual({
operator: 'AND',
conditions: [{ minSize: 102400 }, { maxSize: 2097152 }],
});
});
it('ignores empty, zero and non-numeric sizes', () => {
expect(buildJMAPFilter('', { ...base, minSizeKb: '0', maxSizeKb: 'abc' })).toEqual({});
expect(isFilterEmpty({ ...base, minSizeKb: '0', maxSizeKb: '' })).toBe(true);
expect(isFilterEmpty({ ...base, minSizeKb: '5' })).toBe(false);
expect(activeFilterCount({ ...base, minSizeKb: '5', maxSizeKb: '9' })).toBe(2);
});
});