PSA/shared/lib/utils/contentConversion.test.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

202 lines
7.6 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { convertHtmlToBlockNote, convertMarkdownToBlocks } from './contentConversion';
describe('convertHtmlToBlockNote', () => {
it('should handle empty input', async () => {
expect(await convertHtmlToBlockNote('')).toEqual([]);
// @ts-ignore
expect(await convertHtmlToBlockNote(null)).toEqual([]);
// @ts-ignore
expect(await convertHtmlToBlockNote(undefined)).toEqual([]);
});
it('should convert simple paragraphs', async () => {
const html = '<p>Hello World</p>';
const result = await convertHtmlToBlockNote(html);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('paragraph');
const text = result[0].content?.find((c: any) => c.type === 'text');
expect(text?.text).toBe('Hello World');
});
it('should convert headings', async () => {
const html = '<h1>Title 1</h1><h2>Title 2</h2><h3>Title 3</h3>';
const result = await convertHtmlToBlockNote(html);
expect(result).toHaveLength(3);
expect(result[0]).toMatchObject({ type: 'heading', props: expect.objectContaining({ level: 1 }) });
expect(result[1]).toMatchObject({ type: 'heading', props: expect.objectContaining({ level: 2 }) });
expect(result[2]).toMatchObject({ type: 'heading', props: expect.objectContaining({ level: 3 }) });
});
it('should convert lists', async () => {
const html = `
<ul><li>Item 1</li><li>Item 2</li></ul>
<ol><li>Ordered 1</li><li>Ordered 2</li></ol>
`;
const result = await convertHtmlToBlockNote(html);
expect(result.length).toBeGreaterThanOrEqual(4);
const bulletItem = result.find(b => b.type === 'bulletListItem');
const numberedItem = result.find(b => b.type === 'numberedListItem');
expect(bulletItem).toBeDefined();
expect(numberedItem).toBeDefined();
});
it('should handle inline styles (bold, italic)', async () => {
const html = '<p><strong>Bold</strong> and <em>Italic</em></p>';
const result = await convertHtmlToBlockNote(html);
const paragraph = result[0];
const content = paragraph.content || [];
const boldSegment = content.find((c: any) => c.text === 'Bold');
expect(boldSegment?.styles).toMatchObject({ bold: true });
const italicSegment = content.find((c: any) => c.text === 'Italic');
expect(italicSegment?.styles).toMatchObject({ italic: true });
});
it('should handle links', async () => {
const html = '<p>Click <a href="https://example.com">here</a></p>';
const result = await convertHtmlToBlockNote(html);
const content = result[0].content || [];
const linkSegment = content.find((c: any) => c.type === 'link');
expect(linkSegment).toBeDefined();
expect(linkSegment?.href).toBe('https://example.com');
});
it('should convert standalone images to image blocks', async () => {
const html = '<p><img src="https://example.com/image.png" alt="My Image" /></p>';
const result = await convertHtmlToBlockNote(html);
const imageBlock = result.find(b => b.type === 'image');
expect(imageBlock).toBeDefined();
expect(imageBlock?.props?.url).toBe('https://example.com/image.png');
});
it('should strip Outlook style tags and comments', async () => {
const html = `
<html><head>
<style>p.MsoNormal{font-size:12pt;font-family:Aptos;}</style>
</head><body>
<!-- /* Font Definitions */ @font-face {font-family:Helvetica;} -->
<p class="MsoNormal">Actual content</p>
</body></html>
`;
const result = await convertHtmlToBlockNote(html);
// Should contain the paragraph with actual content
const paragraph = result.find(b => b.type === 'paragraph');
expect(paragraph).toBeDefined();
const text = paragraph?.content?.find((c: any) => c.type === 'text');
expect(text?.text).toContain('Actual content');
// Should NOT contain CSS text
const allText = result
.flatMap(b => (b.content || []))
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text)
.join(' ');
expect(allText).not.toContain('font-family');
expect(allText).not.toContain('MsoNormal');
expect(allText).not.toContain('@font-face');
});
it('should strip Outlook conditional comments with CSS inside style tags', async () => {
// Outlook/Word emails wrap CSS in <!--[if ...]> ... <![endif]--> and
// also use <!-- ... --> inside <style> tags
const html = `
<html><head>
<!--[if gte mso 9]><xml><o:OfficeDocumentSettings><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]-->
<!--[if !mso]><style>v\\:* {behavior:url(#default#VML);}</style><![endif]-->
<style>
<!--
/* Font Definitions */
@font-face {font-family:"Cambria Math"; panose-1:2 4 5 3 5 4 6 3 2 4;}
@font-face {font-family:Calibri; panose-1:2 15 5 2 2 2 4 3 2 4;}
/* Style Definitions */
p.MsoNormal, li.MsoNormal, div.MsoNormal {margin:0in; font-size:11.0pt; font-family:"Calibri",sans-serif; color:windowtext;}
span.EmailStyle17 {mso-style-type:personal-compose; font-family:"Calibri",sans-serif;}
-->
</style>
</head><body>
<div class=WordSection1>
<p class=MsoNormal>Hello from Outlook</p>
</div>
</body></html>
`;
const result = await convertHtmlToBlockNote(html);
const allText = result
.flatMap(b => (b.content || []))
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text)
.join(' ');
expect(allText).toContain('Hello from Outlook');
expect(allText).not.toContain('font-family');
expect(allText).not.toContain('@font-face');
expect(allText).not.toContain('Cambria Math');
expect(allText).not.toContain('MsoNormal');
expect(allText).not.toContain('OfficeDocumentSettings');
});
it('should strip Outlook conditional PIs without comment markers', async () => {
// Some Outlook emails use <![if ...]>...<![endif]> without -- prefix
const html = `
<html><head>
<![if !supportMisalignedColumns]>
<style>
.MsoTableGrid { border-collapse:collapse; }
</style>
<![endif]>
</head><body>
<p>Content after PI</p>
</body></html>
`;
const result = await convertHtmlToBlockNote(html);
const allText = result
.flatMap(b => (b.content || []))
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text)
.join(' ');
expect(allText).toContain('Content after PI');
expect(allText).not.toContain('MsoTableGrid');
expect(allText).not.toContain('border-collapse');
});
it('should parse HTML tables into table blocks', async () => {
const html = `
<table border="1">
<tr><th></th><th>Column 1</th><th>Column 2</th><th>Column3</th></tr>
<tr><td>Row 1</td><td>some</td><td>text</td><td>to</td></tr>
<tr><td>Row 2</td><td>fill</td><td>in</td><td>cells</td></tr>
</table>
`;
const result = await convertHtmlToBlockNote(html);
const tableBlock = result.find(b => b.type === 'table');
expect(tableBlock).toBeDefined();
// Table should have structured content, not flat text
const content = tableBlock?.content as any;
expect(content).toBeDefined();
expect(content.rows).toBeDefined();
expect(content.rows.length).toBeGreaterThanOrEqual(2);
});
});
describe('convertMarkdownToBlocks', () => {
it('should handle images split across lines (wrapped markdown)', () => {
const markdown = '![Ad]\n(https://example.com/long-url)';
const result = convertMarkdownToBlocks(markdown);
const imageBlock = result.find(b => b.type === 'image');
expect(imageBlock).toBeDefined();
expect(imageBlock?.props?.url).toBe('https://example.com/long-url');
expect(imageBlock?.props?.name).toBe('Ad');
});
});