UNPKG

@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
/* 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'); }); }); });