@bernierllc/content-type-blog-post
Version:
Blog post content type with rich TipTap editor, SEO metadata, database storage, and web publishing
372 lines (305 loc) • 13.9 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 { BlogPostContentType } from '../src/BlogPostContentType';
import { BlogPostMetadata } from '../src/types';
import { MockDatabaseClient, createSampleBlogPost } from './test-utils';
describe('BlogPostContentType', () => {
let blogPostType: BlogPostContentType;
let dbClient: MockDatabaseClient;
beforeEach(async () => {
dbClient = new MockDatabaseClient();
blogPostType = new BlogPostContentType({ dbClient });
await blogPostType.initializeDatabase();
});
afterEach(() => {
dbClient.clear();
});
describe('initializeDatabase', () => {
it('should initialize database successfully', async () => {
const result = await blogPostType.initializeDatabase();
expect(result.success).toBe(true);
});
it('should fail if no database client provided', async () => {
const typeWithoutDb = new BlogPostContentType();
const result = await typeWithoutDb.initializeDatabase();
expect(result.success).toBe(false);
expect(result.error).toBe('Database client not provided');
});
});
describe('create', () => {
it('should create blog post with complete metadata', async () => {
const metadata = createSampleBlogPost();
const result = await blogPostType.create(metadata);
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(typeof result.data).toBe('string');
});
it('should auto-calculate word count and reading time', async () => {
const metadata = createSampleBlogPost({
content: '<p>' + 'word '.repeat(200) + '</p>',
});
const createResult = await blogPostType.create(metadata);
expect(createResult.success).toBe(true);
const readResult = await blogPostType.read(createResult.data!);
expect(readResult.success).toBe(true);
expect(readResult.data?.wordCount).toBe(200);
expect(readResult.data?.readingTime).toBe(1); // 200 words / 200 wpm = 1 min
});
it('should ensure unique slugs', async () => {
const metadata1 = createSampleBlogPost({ slug: 'test-post' });
const metadata2 = createSampleBlogPost({ slug: 'test-post' });
const result1 = await blogPostType.create(metadata1);
const result2 = await blogPostType.create(metadata2);
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
const post1 = await blogPostType.read(result1.data!);
const post2 = await blogPostType.read(result2.data!);
expect(post1.data?.slug).toBe('test-post');
expect(post2.data?.slug).toBe('test-post-1');
});
it('should validate metadata before creating', async () => {
const invalidMetadata = {
content: '',
title: '',
slug: '',
seo: {},
author: {},
tags: [],
categories: [],
status: 'draft',
createdAt: new Date(),
updatedAt: new Date(),
} as any;
const result = await blogPostType.create(invalidMetadata);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it('should fail gracefully without database client', async () => {
const typeWithoutDb = new BlogPostContentType();
const metadata = createSampleBlogPost();
const result = await typeWithoutDb.create(metadata);
expect(result.success).toBe(false);
expect(result.error).toBe('Database client not configured');
});
});
describe('read', () => {
it('should read blog post by ID', async () => {
const metadata = createSampleBlogPost();
const createResult = await blogPostType.create(metadata);
expect(createResult.success).toBe(true);
const readResult = await blogPostType.read(createResult.data!);
expect(readResult.success).toBe(true);
expect(readResult.data?.title).toBe(metadata.title);
expect(readResult.data?.slug).toBe(metadata.slug);
expect(readResult.data?.content).toBe(metadata.content);
});
it('should return error for non-existent ID', async () => {
const result = await blogPostType.read('non-existent-id');
expect(result.success).toBe(false);
expect(result.error).toBe('Blog post not found');
});
it('should fail gracefully without database client', async () => {
const typeWithoutDb = new BlogPostContentType();
const result = await typeWithoutDb.read('some-id');
expect(result.success).toBe(false);
expect(result.error).toBe('Database client not configured');
});
});
describe('readBySlug', () => {
it('should read blog post by slug', async () => {
const metadata = createSampleBlogPost({ slug: 'unique-slug' });
const createResult = await blogPostType.create(metadata);
expect(createResult.success).toBe(true);
const readResult = await blogPostType.readBySlug('unique-slug');
expect(readResult.success).toBe(true);
expect(readResult.data?.slug).toBe('unique-slug');
expect(readResult.data?.title).toBe(metadata.title);
});
it('should return error for non-existent slug', async () => {
const result = await blogPostType.readBySlug('non-existent-slug');
expect(result.success).toBe(false);
expect(result.error).toBe('Blog post not found');
});
});
describe('update', () => {
it('should update blog post successfully', async () => {
const metadata = createSampleBlogPost();
const createResult = await blogPostType.create(metadata);
expect(createResult.success).toBe(true);
const updates: Partial<BlogPostMetadata> = {
title: 'Updated Title',
content: '<p>Updated content</p>',
};
const updateResult = await blogPostType.update(createResult.data!, updates);
expect(updateResult.success).toBe(true);
const readResult = await blogPostType.read(createResult.data!);
expect(readResult.success).toBe(true);
expect(readResult.data?.title).toBe('Updated Title');
expect(readResult.data?.content).toBe('<p>Updated content</p>');
});
it('should recalculate word count and reading time on content update', async () => {
const metadata = createSampleBlogPost();
const createResult = await blogPostType.create(metadata);
const updates = {
content: '<p>' + 'word '.repeat(400) + '</p>',
};
await blogPostType.update(createResult.data!, updates);
const readResult = await blogPostType.read(createResult.data!);
expect(readResult.data?.wordCount).toBe(400);
expect(readResult.data?.readingTime).toBe(2); // 400 / 200 = 2 min
});
it('should return error for non-existent post', async () => {
const result = await blogPostType.update('non-existent-id', { title: 'New Title' });
expect(result.success).toBe(false);
expect(result.error).toBe('Blog post not found');
});
});
describe('publish', () => {
it('should publish blog post with complete SEO', async () => {
const metadata = createSampleBlogPost();
const createResult = await blogPostType.create(metadata);
expect(createResult.success).toBe(true);
const publishResult = await blogPostType.publish(createResult.data!);
expect(publishResult.success).toBe(true);
expect(publishResult.data).toBe('/blog/test-blog-post');
const readResult = await blogPostType.read(createResult.data!);
expect(readResult.data?.status).toBe('published');
expect(readResult.data?.publishedAt).toBeDefined();
});
it('should fail publishing without complete SEO', async () => {
const metadata = createSampleBlogPost({
seo: {
metaTitle: '',
metaDescription: '',
keywords: [],
ogType: 'article',
},
});
const createResult = await blogPostType.create(metadata);
const publishResult = await blogPostType.publish(createResult.data!);
expect(publishResult.success).toBe(false);
expect(publishResult.error).toContain('SEO validation failed');
});
it('should generate correct URL with base URL', async () => {
const typeWithBaseUrl = new BlogPostContentType({
dbClient,
baseUrl: 'https://example.com',
});
await typeWithBaseUrl.initializeDatabase();
const metadata = createSampleBlogPost({ slug: 'my-post' });
const createResult = await typeWithBaseUrl.create(metadata);
const publishResult = await typeWithBaseUrl.publish(createResult.data!);
expect(publishResult.data).toBe('https://example.com/blog/my-post');
});
it('should return error for non-existent post', async () => {
const result = await blogPostType.publish('non-existent-id');
expect(result.success).toBe(false);
expect(result.error).toBe('Blog post not found');
});
});
describe('delete', () => {
it('should delete blog post successfully', async () => {
const metadata = createSampleBlogPost();
const createResult = await blogPostType.create(metadata);
expect(createResult.success).toBe(true);
const deleteResult = await blogPostType.delete(createResult.data!);
expect(deleteResult.success).toBe(true);
const readResult = await blogPostType.read(createResult.data!);
expect(readResult.success).toBe(false);
expect(readResult.error).toBe('Blog post not found');
});
it('should succeed even if post does not exist', async () => {
const result = await blogPostType.delete('non-existent-id');
expect(result.success).toBe(true);
});
});
describe('list', () => {
beforeEach(async () => {
// Create sample posts
await blogPostType.create(createSampleBlogPost({ slug: 'draft-1', status: 'draft' }));
await blogPostType.create(createSampleBlogPost({ slug: 'draft-2', status: 'draft' }));
await blogPostType.create(
createSampleBlogPost({ slug: 'published-1', status: 'published' })
);
});
it('should list all posts without filters', async () => {
const result = await blogPostType.list();
expect(result.success).toBe(true);
expect(result.data).toHaveLength(3);
});
it('should filter by status', async () => {
const draftsResult = await blogPostType.list({ status: 'draft' });
expect(draftsResult.success).toBe(true);
expect(draftsResult.data).toHaveLength(2);
const publishedResult = await blogPostType.list({ status: 'published' });
expect(publishedResult.success).toBe(true);
expect(publishedResult.data).toHaveLength(1);
});
it('should filter by tags', async () => {
await blogPostType.create(
createSampleBlogPost({ slug: 'tagged-1', tags: ['tech', 'tutorial'] })
);
await blogPostType.create(createSampleBlogPost({ slug: 'tagged-2', tags: ['tech'] }));
await blogPostType.create(createSampleBlogPost({ slug: 'tagged-3', tags: ['news'] }));
const result = await blogPostType.list({ tags: ['tech'] });
expect(result.success).toBe(true);
expect(result.data?.length).toBeGreaterThanOrEqual(2);
});
it('should filter by categories', async () => {
await blogPostType.create(
createSampleBlogPost({ slug: 'cat-1', categories: ['development'] })
);
await blogPostType.create(
createSampleBlogPost({ slug: 'cat-2', categories: ['development'] })
);
await blogPostType.create(createSampleBlogPost({ slug: 'cat-3', categories: ['design'] }));
const result = await blogPostType.list({ categories: ['development'] });
expect(result.success).toBe(true);
expect(result.data?.length).toBeGreaterThanOrEqual(2);
});
it('should respect limit and offset', async () => {
const limitResult = await blogPostType.list({ limit: 2 });
expect(limitResult.success).toBe(true);
expect(limitResult.data).toHaveLength(2);
const offsetResult = await blogPostType.list({ limit: 2, offset: 2 });
expect(offsetResult.success).toBe(true);
expect(offsetResult.data).toHaveLength(1);
});
it('should order by created_at DESC', async () => {
const result = await blogPostType.list();
expect(result.success).toBe(true);
if (result.data && result.data.length > 1) {
const dates = result.data.map((post) => post.createdAt.getTime());
for (let i = 0; i < dates.length - 1; i++) {
expect(dates[i]).toBeGreaterThanOrEqual(dates[i + 1]);
}
}
});
});
describe('configuration', () => {
it('should use default table name', () => {
const type = new BlogPostContentType({ dbClient });
expect(type.config.storage.table).toBe('blog_posts');
});
it('should use custom table name', () => {
const type = new BlogPostContentType({ dbClient, tableName: 'custom_posts' });
expect(type.config.storage.table).toBe('custom_posts');
});
it('should use default URL pattern', () => {
const type = new BlogPostContentType({ dbClient });
expect(type.config.publishing.urlPattern).toBe('/blog/{slug}');
});
it('should configure TipTap extensions', () => {
const type = new BlogPostContentType({ dbClient });
expect(type.config.editor.extensions).toContain('starter-kit');
expect(type.config.editor.extensions).toContain('link');
expect(type.config.editor.extensions).toContain('image');
expect(type.config.editor.extensions).toContain('code-block-lowlight');
expect(type.config.editor.extensions).toContain('table');
});
});
});