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('
Hello
');
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('one
two
')).toBe('one\ntwo');
expect(htmlToText('a
b')).toBe('a\nb');
expect(htmlToText('R&D <tag> "q" x')).toBe('R&D "q" x');
expect(htmlToText('€10 €20')).toBe('€10 €20');
});
it('ignores comments and out-of-range numeric entities without throwing', () => {
expect(htmlToText('ab')).toBe('a b');
expect(() => htmlToText(' ')).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 {
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: 'html loses' } },
});
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: 'hello
world
' } },
});
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 {
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: 'agenda
',
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 =>
({ 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 =>
({
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();
});
});