Files
SRCmail/lib/mail-index/__tests__/extract.test.ts
T
Bernd RodlerandClaude Sonnet 5 7e9aefcfa1 test(mail-index): unit tests for the extractors, FTS query builder and store
48 assertions. The pure extractors and toFtsMatchQuery need no database; the
store tests run against REAL SQLCipher and skip themselves when the optional
native binding is absent (e.g. Alpine/musl), which is the same guard the
runtime uses.

The two that matter most:

* "writes an ENCRYPTED file" reads the raw bytes back and asserts a canary
  string is absent. This is the assertion that catches `PRAGMA key` silently
  doing nothing - a plain-SQLite binding leaves the mailbox in cleartext with
  no error anywhere, so a functional test alone would pass.

* "upserting the same id REPLACES the FTS row" - the FTS table is maintained by
  hand (standalone, not external-content), so a missed delete leaves the OLD
  body permanently searchable. The test asserts the old text stops matching,
  not just that the new text starts.

Also covered: FTS5 MATCH injection (its grammar is not protected by SQL
parameter binding, so a bare quote would 500 the search route), account-scoped
keys not merging two accounts' identical JMAP ids, title-over-body bm25
weighting, and the hosted-deployment env gate rejecting a relative path.

Note: lib/__tests__/builtin-themes.test.ts has 2 pre-existing failures on this
branch (theme author "VNC" vs. expected "Built-in", from the earlier rebrand) -
verified failing identically at b15098a6, before any of this work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 23:20:12 +02:00

284 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import type {
CalendarEvent, CalendarParticipant, ContactCard, Email, EmailBodyPart, FileNode,
} from '@/lib/jmap/types';
import {
contactDisplayName, emailBodyText, extractCalendarEvent, extractContact, extractFile,
extractMail, htmlToText, MAX_BODY_CHARS, normaliseText,
} from '../extract';
import { buildFilePaths } from '../jmap';
describe('htmlToText', () => {
it('drops script and style CONTENT, not just the tags', () => {
// The important case: a naive `<[^>]+>` strip leaves the script body behind
// as searchable text, so a page full of JS would pollute the index.
const out = htmlToText('<p>Hello</p><script>var secretToken = "abc123";</script><style>.a{color:red}</style>');
expect(out).toContain('Hello');
expect(out).not.toContain('secretToken');
expect(out).not.toContain('abc123');
expect(out).not.toContain('color:red');
});
it('turns block boundaries into newlines and decodes entities', () => {
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\ntwo');
expect(htmlToText('a<br>b')).toBe('a\nb');
expect(htmlToText('R&amp;D &lt;tag&gt; &quot;q&quot; &nbsp;x')).toBe('R&D <tag> "q" x');
expect(htmlToText('&#8364;10 &#x20AC;20')).toBe('€10 €20');
});
it('ignores comments and out-of-range numeric entities without throwing', () => {
expect(htmlToText('a<!-- hidden -->b')).toBe('a b');
expect(() => htmlToText('&#1114112; &#x999999;')).not.toThrow();
});
});
describe('normaliseText', () => {
it('collapses runs of spaces, tabs and non-breaking spaces', () => {
expect(normaliseText('a \t   b')).toBe('a b');
});
it('caps blank-line runs and handles null/undefined', () => {
expect(normaliseText('a\n\n\n\n\nb')).toBe('a\n\nb');
expect(normaliseText(undefined)).toBe('');
expect(normaliseText(null)).toBe('');
});
});
function baseEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'M1', threadId: 'T1', mailboxIds: { mb1: true }, keywords: {},
size: 100, receivedAt: '2026-08-01T10:00:00Z', hasAttachment: false,
...overrides,
} as Email;
}
describe('emailBodyText', () => {
it('prefers the text/plain part', () => {
const email = baseEmail({
textBody: [{ partId: 'p1' } as EmailBodyPart],
htmlBody: [{ partId: 'p2' } as EmailBodyPart],
bodyValues: { p1: { value: 'plain wins' }, p2: { value: '<b>html loses</b>' } },
});
expect(emailBodyText(email)).toBe('plain wins');
});
it('falls back to flattened HTML when there is no plain alternative', () => {
const email = baseEmail({
htmlBody: [{ partId: 'p2' } as EmailBodyPart],
bodyValues: { p2: { value: '<p>hello</p><p>world</p>' } },
});
expect(emailBodyText(email)).toBe('hello\nworld');
});
it('falls back to preview when bodyValues is missing entirely', () => {
// This is the shape a caller gets when the Email/get omitted
// fetchTextBodyValues - a silent empty body if we did not handle it.
const email = baseEmail({
textBody: [{ partId: 'p1' } as EmailBodyPart],
preview: 'server preview text',
});
expect(emailBodyText(email)).toBe('server preview text');
});
it('treats a whitespace-only plain part as absent', () => {
const email = baseEmail({
textBody: [{ partId: 'p1' } as EmailBodyPart],
htmlBody: [{ partId: 'p2' } as EmailBodyPart],
bodyValues: { p1: { value: ' \n ' }, p2: { value: 'real content' } },
});
expect(emailBodyText(email)).toBe('real content');
});
});
describe('extractMail', () => {
it('flattens addresses into `people` and keeps metadata', () => {
const doc = extractMail('acc1', baseEmail({
subject: 'Quarterly budget',
from: [{ name: 'Sophie Müller', email: 'sophie@example.com' }],
to: [{ email: 'me@example.com' }],
cc: [{ name: 'Bob', email: 'bob@example.com' }],
preview: 'hi',
}));
expect(doc.contentType).toBe('mail');
expect(doc.title).toBe('Quarterly budget');
expect(doc.people).toContain('Sophie Müller sophie@example.com');
expect(doc.people).toContain('bob@example.com');
expect(doc.occurredAt).toBe('2026-08-01T10:00:00Z');
expect(doc.metadata.threadId).toBe('T1');
expect(doc.metadata.mailboxIds).toEqual(['mb1']);
});
it('substitutes a placeholder title rather than indexing an empty one', () => {
expect(extractMail('acc1', baseEmail()).title).toBe('(no subject)');
});
it('clamps a huge body', () => {
const doc = extractMail('acc1', baseEmail({
textBody: [{ partId: 'p1' } as EmailBodyPart],
bodyValues: { p1: { value: 'x'.repeat(MAX_BODY_CHARS * 2) } },
}));
expect(doc.body.length).toBe(MAX_BODY_CHARS);
});
});
function baseEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'E1', calendarIds: { c1: true }, isDraft: false, isOrigin: true,
utcStart: '2026-08-10T09:00:00Z', utcEnd: '2026-08-10T10:00:00Z',
'@type': 'Event', uid: 'u1', title: 'Standup', description: '',
descriptionContentType: 'text/plain', created: null, updated: '2026-08-01T00:00:00Z',
sequence: 0, start: '2026-08-10T11:00:00', duration: 'PT1H', timeZone: 'Europe/Zurich',
showWithoutTime: false, status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public',
color: null, keywords: null, categories: null, locale: null, replyTo: null,
organizerCalendarAddress: null, participants: null, mayInviteSelf: false,
mayInviteOthers: false, hideAttendees: false, recurrenceId: null,
recurrenceIdTimeZone: null, recurrenceRules: null, recurrenceOverrides: null,
excludedRecurrenceRules: null, useDefaultAlerts: false, alerts: null,
locations: null, virtualLocations: null, links: null, relatedTo: null,
...overrides,
} as CalendarEvent;
}
describe('extractCalendarEvent', () => {
it('indexes description, location, attendees and organizer', () => {
const doc = extractCalendarEvent('acc1', baseEvent({
title: 'Lease decision',
description: 'Zurich office lease renewal',
locations: { l1: { '@type': 'Location', name: 'Room 3.14', description: null, locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
organizerCalendarAddress: 'mailto:boss@example.com',
// A partial participant on purpose: servers omit most JSCalendar fields,
// and the extractor must cope with exactly this shape.
participants: {
p1: { name: 'Ana', email: 'ana@example.com', sendTo: { imip: 'mailto:ana@example.com' } } as unknown as CalendarParticipant,
},
}));
expect(doc.title).toBe('Lease decision');
expect(doc.body).toContain('Zurich office lease renewal');
expect(doc.body).toContain('Room 3.14');
// mailto: prefixes stripped so the address tokenises like every other one.
expect(doc.people).toContain('boss@example.com');
expect(doc.people).not.toContain('mailto:');
expect(doc.people).toContain('ana@example.com');
expect(doc.metadata.participantCount).toBe(1);
});
it('flattens an HTML description', () => {
const doc = extractCalendarEvent('acc1', baseEvent({
description: '<p>agenda</p><script>bad()</script>',
descriptionContentType: 'text/html',
}));
expect(doc.body).toContain('agenda');
expect(doc.body).not.toContain('bad()');
});
it('prefers utcStart over the zone-less local start for ordering', () => {
expect(extractCalendarEvent('acc1', baseEvent()).occurredAt).toBe('2026-08-10T09:00:00Z');
expect(extractCalendarEvent('acc1', baseEvent({ utcStart: null })).occurredAt)
.toBe('2026-08-10T11:00:00');
});
});
describe('extractContact', () => {
const card = (overrides: Partial<ContactCard> = {}): ContactCard =>
({ id: 'C1', addressBookIds: { a1: true }, ...overrides }) as ContactCard;
it('uses name.full when present', () => {
expect(contactDisplayName(card({ name: { full: 'Ada Lovelace' } }))).toBe('Ada Lovelace');
});
it('assembles components in the right order when full is absent', () => {
expect(contactDisplayName(card({
name: { components: [{ kind: 'surname', value: 'Hopper' }, { kind: 'given', value: 'Grace' }] },
}))).toBe('Grace Hopper');
});
it('degrades to an email, then an org, then a placeholder', () => {
expect(contactDisplayName(card({ emails: { e: { address: 'x@y.z' } } }))).toBe('x@y.z');
expect(contactDisplayName(card({ organizations: { o: { name: 'ACME' } } }))).toBe('ACME');
expect(contactDisplayName(card())).toBe('(unnamed contact)');
});
it('puts emails and phones in `people` and notes/orgs in `body`', () => {
const doc = extractContact('acc1', card({
name: { full: 'Ada Lovelace' },
emails: { e1: { address: 'ada@example.com' } },
phones: { p1: { number: '+41 44 000 00 00' } },
organizations: { o1: { name: 'Analytical Engines' } },
notes: { n1: { note: 'met at the Zurich conference' } },
nicknames: { k1: { name: 'The Countess' } },
}));
expect(doc.people).toContain('ada@example.com');
expect(doc.people).toContain('+41 44 000 00 00');
expect(doc.people).toContain('The Countess');
expect(doc.body).toContain('Analytical Engines');
expect(doc.body).toContain('met at the Zurich conference');
// A contact has no single meaningful date; ranking is relevance-only.
expect(doc.occurredAt).toBeNull();
});
it('handles both RFC 9553 and legacy flat address shapes', () => {
expect(extractContact('acc1', card({ addresses: { a: { full: 'Bahnhofstrasse 1, Zurich' } } })).body)
.toContain('Bahnhofstrasse 1, Zurich');
expect(extractContact('acc1', card({ addresses: { a: { street: 'Bahnhofstrasse 1', locality: 'Zurich' } } })).body)
.toContain('Bahnhofstrasse 1, Zurich');
});
});
describe('extractFile', () => {
const node = (overrides: Partial<FileNode> = {}): FileNode =>
({
id: 'F1', parentId: null, name: 'invoice.pdf', type: 'application/pdf',
blobId: 'b1', size: 1234, created: '2026-07-01T00:00:00Z',
modified: '2026-07-15T00:00:00Z', ...overrides,
}) as FileNode;
it('indexes metadata only and says so', () => {
const doc = extractFile('acc1', node(), { path: 'Finance/2026' });
expect(doc.title).toBe('invoice.pdf');
expect(doc.body).toContain('Finance/2026');
expect(doc.body).toContain('pdf');
expect(doc.metadata.contentIndexed).toBe(false);
expect(doc.metadata.mimeType).toBe('application/pdf');
expect(doc.metadata.size).toBe(1234);
});
it('uses `modified` (FileNode has no `updated`) and falls back to `created`', () => {
expect(extractFile('acc1', node()).occurredAt).toBe('2026-07-15T00:00:00Z');
expect(extractFile('acc1', node({ modified: undefined as unknown as string })).occurredAt)
.toBe('2026-07-01T00:00:00Z');
});
it('marks directories', () => {
const doc = extractFile('acc1', node({ name: 'Finance', type: 'd', blobId: null }));
expect(doc.metadata.isDirectory).toBe(true);
expect(doc.metadata.mimeType).toBeNull();
expect(doc.body).toContain('folder');
});
});
describe('buildFilePaths', () => {
it('resolves the PARENT chain, excluding the node itself', () => {
const nodes = [
{ id: 'root', parentId: null, name: 'Finance' },
{ id: 'year', parentId: 'root', name: '2026' },
{ id: 'file', parentId: 'year', name: 'invoice.pdf' },
] as FileNode[];
const paths = buildFilePaths(nodes);
expect(paths.get('file')).toBe('Finance/2026');
expect(paths.get('year')).toBe('Finance');
expect(paths.get('root')).toBe('');
});
it('truncates rather than failing when an ancestor is not in the set', () => {
const nodes = [{ id: 'file', parentId: 'missing', name: 'x.txt' }] as FileNode[];
expect(buildFilePaths(nodes).get('file')).toBe('');
});
it('terminates on a parent cycle', () => {
const nodes = [
{ id: 'a', parentId: 'b', name: 'A' },
{ id: 'b', parentId: 'a', name: 'B' },
] as FileNode[];
expect(() => buildFilePaths(nodes)).not.toThrow();
});
});