@bernierllc/content-type-blog-post
Version:
Blog post content type with rich TipTap editor, SEO metadata, database storage, and web publishing
288 lines (245 loc) • 8.44 kB
text/typescript
/*
Copyright (c) 2025 Bernier LLC
This file is licensed to the client under a limited-use license.
The client may use and modify this code *only within the scope of the project it was delivered for*.
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
*/
import { BlogPostMetadata } from '../src/types';
/**
* Mock database client for testing
*/
export class MockDatabaseClient {
private data: Map<string, any> = new Map();
private idCounter = 1;
async query(sql: string, params?: any[]): Promise<any> {
const sqlLower = sql.toLowerCase();
// Handle CREATE TABLE
if (sqlLower.includes('create table')) {
return { rows: [] };
}
// Handle CREATE INDEX
if (sqlLower.includes('create index')) {
return { rows: [] };
}
// Handle INSERT
if (sqlLower.includes('insert into')) {
return this.handleInsert(sql, params);
}
// Handle UPDATE
if (sqlLower.includes('update') && sqlLower.includes('set')) {
return this.handleUpdate(sql, params);
}
// Handle DELETE
if (sqlLower.includes('delete from')) {
return this.handleDelete(sql, params);
}
// Handle SELECT
if (sqlLower.includes('select')) {
return this.handleSelect(sql, params);
}
return { rows: [] };
}
private handleInsert(sql: string, params?: any[]): any {
const id = `test-id-${this.idCounter++}`;
// Parse column names from SQL: INSERT INTO table (col1, col2, ...) VALUES
const columnMatch = sql.match(/\(([^)]+)\)\s+VALUES/i);
const columns = columnMatch
? columnMatch[1].split(',').map((c) => c.trim())
: [];
// Create record with defaults
const record: any = {
id,
title: undefined,
slug: undefined,
content: undefined,
excerpt: undefined,
meta_title: undefined,
meta_description: undefined,
keywords: undefined,
og_image: undefined,
canonical_url: undefined,
author_name: undefined,
author_email: undefined,
author_avatar: undefined,
author_bio: undefined,
tags: undefined,
categories: undefined,
status: undefined,
published_at: undefined,
scheduled_for: undefined,
featured_image: undefined,
reading_time: undefined,
word_count: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// Map params to columns
if (params) {
columns.forEach((col, index) => {
if (index < params.length) {
record[col] = params[index];
}
});
}
this.data.set(id, record);
return { rows: [{ id }] };
}
private handleUpdate(sql: string, params?: any[]): any {
if (!params || params.length === 0) {
return { rows: [] };
}
// ID is always the last parameter
const id = params[params.length - 1];
const record = this.data.get(id);
if (!record) {
return { rows: [] };
}
// Parse column names from SET clause: SET col1 = $1, col2 = $2, ...
// Use 's' flag to match across newlines in multiline SQL
const setMatch = sql.match(/SET\s+(.+?)\s+WHERE/is);
if (!setMatch) {
return { rows: [] };
}
const setPairs = setMatch[1].split(',').map((pair) => pair.trim());
// Update the record
const updated = { ...record };
let paramIndex = 0;
setPairs.forEach((pair) => {
const [col, value] = pair.split('=').map((s) => s.trim());
// Check if value is a parameter placeholder ($1, $2, etc.)
if (value.startsWith('$')) {
// Parameterized value - use params array
if (paramIndex < params.length - 1 && params[paramIndex] !== undefined) {
updated[col] = params[paramIndex];
}
paramIndex++;
} else if (value === 'NOW()') {
// NOW() function - use current timestamp
updated[col] = new Date().toISOString();
} else {
// Literal value - remove quotes and use directly
const literalValue = value.replace(/^'|'$/g, '');
updated[col] = literalValue;
}
});
this.data.set(id, updated);
return { rows: [] };
}
private handleDelete(_sql: string, params?: any[]): any {
if (!params || params.length === 0) {
return { rows: [] };
}
const id = params[0];
this.data.delete(id);
return { rows: [] };
}
private handleSelect(sql: string, params?: any[]): any {
const sqlLower = sql.toLowerCase();
// Handle SELECT by ID
if (sqlLower.includes('where id =')) {
const id = params?.[0];
const record = this.data.get(id);
return { rows: record ? [record] : [] };
}
// Handle SELECT by slug
if (sqlLower.includes('where slug =')) {
const slug = params?.[0];
const record = Array.from(this.data.values()).find((r) => r.slug === slug);
return { rows: record ? [record] : [] };
}
// Handle SELECT slug LIKE (for uniqueness check)
if (sqlLower.includes('where slug like')) {
const slugPattern = params?.[0]?.replace('%', '');
const records = Array.from(this.data.values()).filter((r) =>
r.slug?.startsWith(slugPattern)
);
return { rows: records };
}
// Handle SELECT with filters (WHERE 1=1 AND ...)
if (sqlLower.includes('where 1=1')) {
let records = Array.from(this.data.values());
let paramIndex = 0;
// Filter by status
if (sqlLower.includes('status =') && params && params.length > paramIndex) {
const status = params[paramIndex];
records = records.filter((r) => r.status === status);
paramIndex++;
}
// Filter by tags (array overlap)
if (sqlLower.includes('tags &&') && params && params.length > paramIndex) {
const searchTags = params[paramIndex];
records = records.filter((r) => {
const recordTags = r.tags || [];
return searchTags.some((tag: string) => recordTags.includes(tag));
});
paramIndex++;
}
// Filter by categories (array overlap)
if (sqlLower.includes('categories &&') && params && params.length > paramIndex) {
const searchCategories = params[paramIndex];
records = records.filter((r) => {
const recordCategories = r.categories || [];
return searchCategories.some((cat: string) => recordCategories.includes(cat));
});
paramIndex++;
}
// Sort by created_at DESC
if (sqlLower.includes('order by created_at desc')) {
records.sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
}
// Handle OFFSET
if (sqlLower.includes('offset') && params) {
const offsetParamIndex = sqlLower.includes('limit') ? paramIndex + 1 : paramIndex;
if (params.length > offsetParamIndex) {
const offset = params[offsetParamIndex];
records = records.slice(offset);
}
}
// Handle LIMIT (after OFFSET)
if (sqlLower.includes('limit') && params && params.length > paramIndex) {
const limit = params[paramIndex];
records = records.slice(0, limit);
}
return { rows: records };
}
return { rows: [] };
}
clear(): void {
this.data.clear();
this.idCounter = 1;
}
}
/**
* Create sample blog post metadata for testing
*/
export function createSampleBlogPost(overrides?: Partial<BlogPostMetadata>): BlogPostMetadata {
return {
content: '<h1>Test Blog Post</h1><p>This is a test post with <strong>rich text</strong>.</p>',
title: 'Test Blog Post',
slug: 'test-blog-post',
excerpt: 'This is a test post',
seo: {
metaTitle: 'Test Blog Post - Example Site',
metaDescription:
'This is a comprehensive test blog post demonstrating rich text editing and SEO metadata.',
keywords: ['test', 'blog', 'example'],
ogImage: 'https://example.com/og-image.jpg',
ogType: 'article',
},
author: {
name: 'Test Author',
email: 'author@example.com',
avatar: 'https://example.com/avatar.jpg',
bio: 'Test author bio',
},
tags: ['technology', 'tutorial'],
categories: ['guides'],
status: 'draft',
createdAt: new Date(),
updatedAt: new Date(),
wordCount: 0,
...overrides,
};
}