Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
221
tests/unit/tiptap-serializer.test.ts
Normal file
221
tests/unit/tiptap-serializer.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateTipTapDocument,
|
||||
tipTapToPdfmeTemplate,
|
||||
substituteVariables,
|
||||
buildContentInputsFromDoc,
|
||||
type TipTapNode,
|
||||
} from '@/lib/pdf/tiptap-to-pdfme';
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeDoc(...children: TipTapNode[]): TipTapNode {
|
||||
return { type: 'doc', content: children };
|
||||
}
|
||||
|
||||
function paragraph(text: string): TipTapNode {
|
||||
return {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
function heading(level: number, text: string): TipTapNode {
|
||||
return {
|
||||
type: 'heading',
|
||||
attrs: { level },
|
||||
content: [{ type: 'text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
function bulletList(...items: string[]): TipTapNode {
|
||||
return {
|
||||
type: 'bulletList',
|
||||
content: items.map((item) => ({
|
||||
type: 'listItem',
|
||||
content: [paragraph(item)],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── validateTipTapDocument ───────────────────────────────────────────────────
|
||||
|
||||
describe('validateTipTapDocument', () => {
|
||||
it('returns empty array for a valid doc with only a paragraph', () => {
|
||||
const doc = makeDoc(paragraph('Hello world'));
|
||||
expect(validateTipTapDocument(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for a doc with heading + paragraph', () => {
|
||||
const doc = makeDoc(heading(1, 'Title'), paragraph('Body text'));
|
||||
expect(validateTipTapDocument(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for a doc with bulletList', () => {
|
||||
const doc = makeDoc(bulletList('Item 1', 'Item 2'));
|
||||
expect(validateTipTapDocument(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns ["blockquote"] when doc contains a blockquote', () => {
|
||||
const doc = makeDoc(
|
||||
paragraph('Before'),
|
||||
{ type: 'blockquote', content: [paragraph('Quoted')] },
|
||||
);
|
||||
const errors = validateTipTapDocument(doc);
|
||||
expect(errors).toContain('blockquote');
|
||||
});
|
||||
|
||||
it('returns ["codeBlock"] when doc contains a codeBlock', () => {
|
||||
const doc = makeDoc(
|
||||
paragraph('Before'),
|
||||
{ type: 'codeBlock', content: [{ type: 'text', text: 'const x = 1;' }] },
|
||||
);
|
||||
const errors = validateTipTapDocument(doc);
|
||||
expect(errors).toContain('codeBlock');
|
||||
});
|
||||
|
||||
it('returns multiple unsupported types without duplicates', () => {
|
||||
const doc = makeDoc(
|
||||
{ type: 'blockquote', content: [] },
|
||||
{ type: 'codeBlock', content: [] },
|
||||
{ type: 'blockquote', content: [] }, // duplicate — should only appear once
|
||||
);
|
||||
const errors = validateTipTapDocument(doc);
|
||||
expect(errors).toContain('blockquote');
|
||||
expect(errors).toContain('codeBlock');
|
||||
expect(errors.filter((e) => e === 'blockquote')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('detects unsupported nodes nested inside valid nodes', () => {
|
||||
const doc = makeDoc({
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'blockquote', content: [] }],
|
||||
});
|
||||
expect(validateTipTapDocument(doc)).toContain('blockquote');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── tipTapToPdfmeTemplate ────────────────────────────────────────────────────
|
||||
|
||||
describe('tipTapToPdfmeTemplate', () => {
|
||||
it('returns a template with a schemas array', () => {
|
||||
const doc = makeDoc(paragraph('Hello'));
|
||||
const template = tipTapToPdfmeTemplate(doc);
|
||||
expect(template).toHaveProperty('schemas');
|
||||
expect(Array.isArray(template.schemas)).toBe(true);
|
||||
});
|
||||
|
||||
it('produces one schema field per paragraph', () => {
|
||||
const doc = makeDoc(paragraph('One'), paragraph('Two'), paragraph('Three'));
|
||||
const template = tipTapToPdfmeTemplate(doc);
|
||||
const allFields = template.schemas.flat();
|
||||
expect(allFields).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('heading + paragraph → 2 schema fields', () => {
|
||||
const doc = makeDoc(heading(1, 'Title'), paragraph('Body'));
|
||||
const template = tipTapToPdfmeTemplate(doc);
|
||||
const allFields = template.schemas.flat();
|
||||
expect(allFields).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('bulletList with 3 items → 3 schema fields', () => {
|
||||
const doc = makeDoc(bulletList('A', 'B', 'C'));
|
||||
const template = tipTapToPdfmeTemplate(doc);
|
||||
const allFields = template.schemas.flat();
|
||||
expect(allFields).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('all schema fields have type "text"', () => {
|
||||
const doc = makeDoc(heading(2, 'Sub'), paragraph('Para'), bulletList('Item'));
|
||||
const template = tipTapToPdfmeTemplate(doc);
|
||||
const allFields = template.schemas.flat() as Array<{ type: string }>;
|
||||
for (const field of allFields) {
|
||||
expect(field.type).toBe('text');
|
||||
}
|
||||
});
|
||||
|
||||
it('all schema fields have a name property', () => {
|
||||
const doc = makeDoc(paragraph('p1'), paragraph('p2'));
|
||||
const template = tipTapToPdfmeTemplate(doc);
|
||||
const allFields = template.schemas.flat() as Array<{ name: string }>;
|
||||
for (const field of allFields) {
|
||||
expect(typeof field.name).toBe('string');
|
||||
expect(field.name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('field count matches content node count (round-trip check)', () => {
|
||||
const nodeCount = 4;
|
||||
const doc = makeDoc(
|
||||
heading(1, 'H1'),
|
||||
paragraph('Para 1'),
|
||||
paragraph('Para 2'),
|
||||
bulletList('Bullet'),
|
||||
);
|
||||
const template = tipTapToPdfmeTemplate(doc);
|
||||
const allFields = template.schemas.flat();
|
||||
expect(allFields).toHaveLength(nodeCount);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── substituteVariables ──────────────────────────────────────────────────────
|
||||
|
||||
describe('substituteVariables', () => {
|
||||
it('replaces a single variable token', () => {
|
||||
const result = substituteVariables('Hello {{client.name}}', { 'client.name': 'Alice' });
|
||||
expect(result).toBe('Hello Alice');
|
||||
});
|
||||
|
||||
it('replaces multiple variable tokens', () => {
|
||||
const result = substituteVariables('{{client.name}} at {{port.name}}', {
|
||||
'client.name': 'Alice',
|
||||
'port.name': 'Port Nimara',
|
||||
});
|
||||
expect(result).toBe('Alice at Port Nimara');
|
||||
});
|
||||
|
||||
it('leaves unmatched tokens as-is', () => {
|
||||
const result = substituteVariables('Hello {{client.name}}', {});
|
||||
expect(result).toBe('Hello {{client.name}}');
|
||||
});
|
||||
|
||||
it('handles whitespace inside token braces', () => {
|
||||
const result = substituteVariables('Hello {{ client.name }}', { 'client.name': 'Bob' });
|
||||
expect(result).toBe('Hello Bob');
|
||||
});
|
||||
|
||||
it('replaces the same token multiple times', () => {
|
||||
const result = substituteVariables('{{x}} and {{x}}', { x: 'yes' });
|
||||
expect(result).toBe('yes and yes');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildContentInputsFromDoc ────────────────────────────────────────────────
|
||||
|
||||
describe('buildContentInputsFromDoc', () => {
|
||||
it('returns an array of records keyed by schema field names', () => {
|
||||
const doc = makeDoc(paragraph('Hello'), paragraph('World'));
|
||||
const template = tipTapToPdfmeTemplate(doc);
|
||||
const inputs = buildContentInputsFromDoc(doc, template);
|
||||
|
||||
expect(Array.isArray(inputs)).toBe(true);
|
||||
expect(inputs).toHaveLength(template.schemas.length);
|
||||
|
||||
const allFieldNames = (template.schemas.flat() as Array<{ name: string }>).map((f) => f.name);
|
||||
const allInputKeys = inputs.flatMap((record) => Object.keys(record));
|
||||
for (const name of allFieldNames) {
|
||||
expect(allInputKeys).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('input count matches schema field count', () => {
|
||||
const doc = makeDoc(heading(1, 'H'), paragraph('P1'), paragraph('P2'));
|
||||
const template = tipTapToPdfmeTemplate(doc);
|
||||
const inputs = buildContentInputsFromDoc(doc, template);
|
||||
|
||||
const totalFields = template.schemas.reduce((acc, page) => acc + page.length, 0);
|
||||
const totalInputs = inputs.reduce((acc, record) => acc + Object.keys(record).length, 0);
|
||||
expect(totalInputs).toBe(totalFields);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user