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(); }); });