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>
222 lines
7.9 KiB
TypeScript
222 lines
7.9 KiB
TypeScript
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);
|
|
});
|
|
});
|