import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import type { InstalledPlugin } from '../plugin-types'; import type { IJMAPClient } from '../jmap/client-interface'; import type { Mailbox } from '../jmap/types'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from '../jmap/search-utils'; vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, })); import { dispatchApiCall } from '../plugin-sandbox/host-api'; function plugin(overrides: Partial = {}): InstalledPlugin { return { id: 'related', name: 'Related mail', version: '1.0.0', author: 'test', description: '', type: 'hook', permissions: ['email:read'], grantedPermissions: ['email:read'], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, ...overrides, }; } const inbox = { id: 'mb-inbox', name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, } as unknown as Mailbox; const QUERY = { operator: 'AND', conditions: [ { from: 'alice@example.com' }, { header: ['List-Id'] }, { notKeyword: '$seen' }, ], }; let advancedSearchEmails: ReturnType; beforeEach(() => { advancedSearchEmails = vi.fn(async () => ({ emails: [{ id: 'e1', threadId: 't1', mailboxIds: { 'mb-inbox': true }, keywords: {} }], hasMore: false, total: 1, })); const client = { advancedSearchEmails } as unknown as IJMAPClient; useAuthStore.setState({ client }); useEmailStore.setState({ mailboxes: [inbox], selectedMailbox: 'mb-inbox', // A folder picked in the search panel: the search stays in it. searchMailboxId: 'mb-inbox', searchQuery: 'typed text', searchFilters: { ...DEFAULT_SEARCH_FILTERS, subject: 'old' }, emails: [], }); }); afterEach(() => { useAuthStore.setState({ client: null }); useEmailStore.setState({ searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', emails: [], mailboxes: [], }); }); describe('api.search.run (#1024)', () => { it('needs email:read', async () => { await expect(dispatchApiCall(plugin({ permissions: [], grantedPermissions: [] }), 'search.run', [QUERY])) .rejects.toThrow(/lacks permission "email:read"/); }); it('runs the filter as the list search, replacing the user search', async () => { await dispatchApiCall(plugin(), 'search.run', [QUERY]); expect(advancedSearchEmails).toHaveBeenCalledTimes(1); expect(advancedSearchEmails.mock.calls[0][0]).toEqual({ operator: 'AND', conditions: [QUERY, { inMailbox: 'mb-inbox' }], }); const state = useEmailStore.getState(); expect(state.searchQuery).toBe(''); expect(state.searchFilters.subject).toBe(''); expect(state.searchFilters.pluginFilter).toEqual({ pluginId: 'related', label: 'Related mail', filter: QUERY }); expect(state.emails.map((e) => e.id)).toEqual(['e1']); }); it('uses the label the plugin passes for the chip', async () => { await dispatchApiCall(plugin(), 'search.run', [QUERY, { label: ' From Alice ' }]); expect(useEmailStore.getState().searchFilters.pluginFilter?.label).toBe('From Alice'); }); it('stores a plain copy, so later changes to the object do not reach the request', async () => { const query = { from: 'alice@example.com' }; await dispatchApiCall(plugin(), 'search.run', [query]); query.from = 'mallory@example.com'; expect(useEmailStore.getState().searchFilters.pluginFilter?.filter).toEqual({ from: 'alice@example.com' }); }); it.each([ ['an array', [[{ from: 'a' }]], /must be a JMAP filter object/], ['a string', ['from:alice'], /must be a JMAP filter object/], ['an empty filter', [{}], /must not be empty/], ['an oversized filter', [{ text: 'x'.repeat(17 * 1024) }], /exceeds/], ['a non-string label', [{ from: 'a' }, { label: 5 }], /label must be a string/], ])('rejects %s without touching the search', async (_name, args, error) => { await expect(dispatchApiCall(plugin(), 'search.run', args)).rejects.toThrow(error); expect(advancedSearchEmails).not.toHaveBeenCalled(); expect(useEmailStore.getState().searchQuery).toBe('typed text'); }); }); describe('plugin filter in SearchFilters', () => { const pluginFilter = { pluginId: 'related', label: 'Related', filter: { from: 'alice@example.com' } }; it('counts as an active search and is ANDed with the other fields', () => { const filters = { ...DEFAULT_SEARCH_FILTERS, isUnread: true, pluginFilter }; expect(isFilterEmpty(filters)).toBe(false); expect(buildJMAPFilter('', filters, undefined)).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$seen' }, { from: 'alice@example.com' }], }); }); it('is gone after the filters are cleared', () => { useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS, pluginFilter } }); useEmailStore.getState().clearSearchFilters(); expect(isFilterEmpty(useEmailStore.getState().searchFilters)).toBe(true); }); });