Files
bDS/tests/engine/blog-tools.test.ts

649 lines
24 KiB
TypeScript

/**
* Unit tests for ai/blog-tools.ts — 16 blog data tools.
* Tests exercise the real createBlogTools() with mocked engine dependencies.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createBlogTools, buildAmbiguityHints, type BlogToolDeps } from '../../src/main/engine/ai/blog-tools';
// ---------------------------------------------------------------------------
// Mock factory — creates a BlogToolDeps with all methods stubbed
// ---------------------------------------------------------------------------
function createMockDeps(): BlogToolDeps {
return {
postEngine: {
getPost: vi.fn(),
getAllPosts: vi.fn(),
getPostsFiltered: vi.fn(),
searchPostsFiltered: vi.fn(),
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
updatePost: vi.fn(),
getBlogStats: vi.fn(),
getDashboardStats: vi.fn(),
},
mediaEngine: {
getMedia: vi.fn(),
getAllMedia: vi.fn(),
getMediaFiltered: vi.fn(),
updateMedia: vi.fn(),
getThumbnailDataUrl: vi.fn(),
},
postMediaEngine: {
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
getLinkedPostsForMedia: vi.fn().mockResolvedValue([]),
},
};
}
// ---------------------------------------------------------------------------
// Sample data
// ---------------------------------------------------------------------------
const samplePost = {
id: 'post-1', projectId: 'proj-1', title: 'Hello World', slug: 'hello-world',
excerpt: 'A first post', content: '# Hello\n\nWorld', status: 'published' as const,
author: 'gb', createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
tags: ['intro'], categories: ['article'],
};
const sampleMedia = {
id: 'media-1', filename: 'photo.webp', originalName: 'photo.jpg',
mimeType: 'image/webp', size: 12345, width: 800, height: 600,
title: 'Photo', alt: 'A photo', caption: 'Nice photo',
author: 'gb', createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
tags: ['landscape'],
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('Blog Tools — createBlogTools', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns all 16 tools', () => {
const names = Object.keys(tools);
expect(names).toHaveLength(16);
expect(names).toContain('check_term');
expect(names).toContain('search_posts');
expect(names).toContain('read_post');
expect(names).toContain('list_posts');
expect(names).toContain('get_media');
expect(names).toContain('list_media');
expect(names).toContain('update_post_metadata');
expect(names).toContain('update_media_metadata');
expect(names).toContain('list_tags');
expect(names).toContain('list_categories');
expect(names).toContain('get_blog_stats');
expect(names).toContain('view_image');
expect(names).toContain('get_post_backlinks');
expect(names).toContain('get_post_outlinks');
expect(names).toContain('get_post_media');
expect(names).toContain('get_media_posts');
});
it('each tool has description and inputSchema', () => {
for (const [name, t] of Object.entries(tools)) {
expect(t.description, `${name} missing description`).toBeTruthy();
expect(t.inputSchema, `${name} missing inputSchema`).toBeDefined();
}
});
});
// ---------------------------------------------------------------------------
// check_term
// ---------------------------------------------------------------------------
describe('Blog Tools — check_term', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('finds a term as both category and tag', async () => {
vi.mocked(deps.postEngine.getCategoriesWithCounts).mockResolvedValueOnce([
{ category: 'Travel', count: 5 },
]);
vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([
{ tag: 'travel', count: 3 },
]);
const result = await tools.check_term.execute!({ term: 'travel' }, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal });
expect(result).toEqual({
success: true,
term: 'travel',
asCategory: true,
categoryPostCount: 5,
asTag: true,
tagPostCount: 3,
});
});
it('returns false when term not found', async () => {
const result = await tools.check_term.execute!({ term: 'nonexistent' }, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal });
expect(result).toMatchObject({
success: true,
asCategory: false,
categoryPostCount: 0,
asTag: false,
tagPostCount: 0,
});
});
});
// ---------------------------------------------------------------------------
// search_posts
// ---------------------------------------------------------------------------
describe('Blog Tools — search_posts', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns error when month without year', async () => {
const result = await tools.search_posts.execute!(
{ query: 'test', month: 3 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') });
});
it('calls searchPostsFiltered with correct filter', async () => {
vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce({ posts: [samplePost], total: 5 });
const result = await tools.search_posts.execute!(
{ query: 'hello', category: 'article', year: 2025 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.searchPostsFiltered).toHaveBeenCalledWith(
'hello',
{ categories: ['article'], year: 2025 },
{ offset: 0, limit: 10 },
);
expect(result).toMatchObject({ success: true, count: 1, totalMatches: 5, hasMore: false });
});
it('includes ambiguity hints when category also exists as tag', async () => {
vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce({ posts: [], total: 0 });
vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([
{ tag: 'article', count: 2 },
]);
const result = await tools.search_posts.execute!(
{ query: 'test', category: 'article' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toHaveProperty('hints');
expect((result as Record<string, unknown>).hints).toEqual(
expect.arrayContaining([expect.stringContaining('also exists as a tag')]),
);
});
});
// ---------------------------------------------------------------------------
// read_post
// ---------------------------------------------------------------------------
describe('Blog Tools — read_post', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns post with backlinks and outlinks', async () => {
vi.mocked(deps.postEngine.getPost).mockResolvedValueOnce(samplePost);
vi.mocked(deps.postEngine.getLinkedBy).mockResolvedValueOnce([
{ id: 'post-2', title: 'Related', slug: 'related' },
]);
const result = await tools.read_post.execute!(
{ postId: 'post-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({
success: true,
post: {
id: 'post-1',
title: 'Hello World',
content: '# Hello\n\nWorld',
backlinks: [{ id: 'post-2', title: 'Related' }],
},
});
});
it('returns error for nonexistent post', async () => {
vi.mocked(deps.postEngine.getPost).mockResolvedValueOnce(null);
const result = await tools.read_post.execute!(
{ postId: 'nope' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: 'Post not found' });
});
});
// ---------------------------------------------------------------------------
// list_posts
// ---------------------------------------------------------------------------
describe('Blog Tools — list_posts', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns error when month without year', async () => {
const result = await tools.list_posts.execute!(
{ month: 6 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') });
});
it('uses getAllPosts when no filters', async () => {
vi.mocked(deps.postEngine.getDashboardStats).mockResolvedValueOnce({
totalPosts: 100, draftCount: 10, publishedCount: 85, archivedCount: 5,
});
vi.mocked(deps.postEngine.getAllPosts).mockResolvedValueOnce({
items: [samplePost], total: 100, hasMore: true,
});
const result = await tools.list_posts.execute!(
{},
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.getAllPosts).toHaveBeenCalledWith({ limit: 20, offset: 0 });
expect(result).toMatchObject({ success: true, total: 100, filteredTotal: 100 });
});
it('uses getPostsFiltered when filters present', async () => {
vi.mocked(deps.postEngine.getDashboardStats).mockResolvedValueOnce({
totalPosts: 100, draftCount: 10, publishedCount: 85, archivedCount: 5,
});
vi.mocked(deps.postEngine.getPostsFiltered).mockResolvedValueOnce([samplePost]);
const result = await tools.list_posts.execute!(
{ status: 'published', year: 2025 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.getPostsFiltered).toHaveBeenCalledWith({
status: 'published', year: 2025,
});
expect(result).toMatchObject({ success: true, filteredTotal: 1 });
});
});
// ---------------------------------------------------------------------------
// get_media / list_media
// ---------------------------------------------------------------------------
describe('Blog Tools — get_media', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns media metadata', async () => {
vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(sampleMedia);
const result = await tools.get_media.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, media: { id: 'media-1', filename: 'photo.webp' } });
});
it('returns error for missing media', async () => {
vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(null);
const result = await tools.get_media.execute!(
{ mediaId: 'nope' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: 'Media not found' });
});
});
describe('Blog Tools — list_media', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns all media when no filters', async () => {
vi.mocked(deps.mediaEngine.getAllMedia).mockResolvedValueOnce([sampleMedia]);
const result = await tools.list_media.execute!(
{},
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, total: 1 });
});
it('filters by MIME type', async () => {
const pdfMedia = { ...sampleMedia, id: 'media-2', mimeType: 'application/pdf' };
vi.mocked(deps.mediaEngine.getAllMedia).mockResolvedValueOnce([sampleMedia, pdfMedia]);
const result = await tools.list_media.execute!(
{ mimeTypeFilter: 'image/' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, filteredTotal: 1, total: 2 });
});
it('uses getMediaFiltered when year is provided', async () => {
vi.mocked(deps.mediaEngine.getMediaFiltered).mockResolvedValueOnce([sampleMedia]);
await tools.list_media.execute!(
{ year: 2025 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.mediaEngine.getMediaFiltered).toHaveBeenCalledWith({ year: 2025 });
});
it('returns error when month without year', async () => {
const result = await tools.list_media.execute!(
{ month: 3 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') });
});
});
// ---------------------------------------------------------------------------
// update_post_metadata / update_media_metadata
// ---------------------------------------------------------------------------
describe('Blog Tools — update_post_metadata', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('calls updatePost with provided fields', async () => {
await tools.update_post_metadata.execute!(
{ postId: 'post-1', title: 'New Title', tags: ['updated'] },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { title: 'New Title', tags: ['updated'] });
});
it('returns error when no updates provided', async () => {
const result = await tools.update_post_metadata.execute!(
{ postId: 'post-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: 'No updates provided' });
});
});
describe('Blog Tools — update_media_metadata', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('calls updateMedia with provided fields', async () => {
await tools.update_media_metadata.execute!(
{ mediaId: 'media-1', alt: 'New alt', tags: ['nature'] },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { alt: 'New alt', tags: ['nature'] });
});
it('returns error when no updates provided', async () => {
const result = await tools.update_media_metadata.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: 'No updates provided' });
});
});
// ---------------------------------------------------------------------------
// list_tags / list_categories
// ---------------------------------------------------------------------------
describe('Blog Tools — list_tags & list_categories', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('list_tags returns tags with counts', async () => {
vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([
{ tag: 'travel', count: 10 },
{ tag: 'food', count: 5 },
]);
const result = await tools.list_tags.execute!({}, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal });
expect(result).toMatchObject({ success: true, count: 2 });
});
it('list_categories returns categories with counts', async () => {
vi.mocked(deps.postEngine.getCategoriesWithCounts).mockResolvedValueOnce([
{ category: 'article', count: 20 },
]);
const result = await tools.list_categories.execute!({}, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal });
expect(result).toMatchObject({ success: true, count: 1 });
});
});
// ---------------------------------------------------------------------------
// get_blog_stats
// ---------------------------------------------------------------------------
describe('Blog Tools — get_blog_stats', () => {
it('returns comprehensive stats', async () => {
const deps = createMockDeps();
const tools = createBlogTools(deps);
vi.mocked(deps.postEngine.getBlogStats).mockResolvedValueOnce({
totalPosts: 50, draftCount: 5, publishedCount: 40, archivedCount: 5,
oldestPostDate: new Date('2020-01-01'), newestPostDate: new Date('2025-06-01'),
postsPerYear: { 2020: 10, 2021: 10, 2022: 10, 2023: 10, 2024: 10 },
tagCount: 25, categoryCount: 4,
});
vi.mocked(deps.mediaEngine.getAllMedia).mockResolvedValueOnce([sampleMedia, sampleMedia]);
const result = await tools.get_blog_stats.execute!({}, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal });
expect(result).toMatchObject({
success: true,
totalPosts: 50,
totalMedia: 2,
tagCount: 25,
});
});
});
// ---------------------------------------------------------------------------
// view_image
// ---------------------------------------------------------------------------
describe('Blog Tools — view_image', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns base64 image data', async () => {
vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(sampleMedia);
vi.mocked(deps.mediaEngine.getThumbnailDataUrl).mockResolvedValueOnce(
'data:image/webp;base64,iVBORw0KGgo',
);
const result = await tools.view_image.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({
__isImageResult: true,
success: true,
base64: 'iVBORw0KGgo',
mediaType: 'image/webp',
});
});
it('rejects non-image media', async () => {
vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce({
...sampleMedia, mimeType: 'application/pdf',
});
const result = await tools.view_image.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('Only images') });
});
it('returns error when thumbnail unavailable', async () => {
vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(sampleMedia);
vi.mocked(deps.mediaEngine.getThumbnailDataUrl).mockResolvedValueOnce(null);
const result = await tools.view_image.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('Thumbnail not available') });
});
});
// ---------------------------------------------------------------------------
// Link tools
// ---------------------------------------------------------------------------
describe('Blog Tools — link tools', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('get_post_backlinks returns linked-by posts', async () => {
vi.mocked(deps.postEngine.getLinkedBy).mockResolvedValueOnce([
{ id: 'post-2', title: 'Ref', slug: 'ref' },
]);
const result = await tools.get_post_backlinks.execute!(
{ postId: 'post-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, linkedBy: [{ id: 'post-2' }] });
});
it('get_post_outlinks returns links-to posts', async () => {
vi.mocked(deps.postEngine.getLinksTo).mockResolvedValueOnce([
{ id: 'post-3', title: 'Target', slug: 'target' },
]);
const result = await tools.get_post_outlinks.execute!(
{ postId: 'post-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, linksTo: [{ id: 'post-3' }] });
});
it('get_post_media returns linked media', async () => {
vi.mocked(deps.postMediaEngine.getLinkedMediaDataForPost).mockResolvedValueOnce([{
id: 'link-1', projectId: 'proj-1', postId: 'post-1', mediaId: 'media-1',
sortOrder: 0, createdAt: new Date(),
media: sampleMedia,
}]);
const result = await tools.get_post_media.execute!(
{ postId: 'post-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, media: [{ id: 'media-1' }] });
});
it('get_media_posts returns linked posts', async () => {
vi.mocked(deps.postMediaEngine.getLinkedPostsForMedia).mockResolvedValueOnce([{
id: 'link-1', projectId: 'proj-1', postId: 'post-1', mediaId: 'media-1',
sortOrder: 0, createdAt: new Date(),
}]);
vi.mocked(deps.postEngine.getPost).mockResolvedValueOnce(samplePost);
const result = await tools.get_media_posts.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, posts: [{ id: 'post-1' }] });
});
});
// ---------------------------------------------------------------------------
// buildAmbiguityHints (shared helper)
// ---------------------------------------------------------------------------
describe('buildAmbiguityHints', () => {
it('returns hint when category also exists as tag', async () => {
const engine = {
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([{ tag: 'travel', count: 3 }]),
};
const hints = await buildAmbiguityHints(engine, 'travel', undefined);
expect(hints).toHaveLength(1);
expect(hints[0]).toContain('also exists as a tag');
});
it('returns hint when tag also exists as category', async () => {
const engine = {
getCategoriesWithCounts: vi.fn().mockResolvedValue([{ category: 'photo', count: 5 }]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
};
const hints = await buildAmbiguityHints(engine, undefined, ['photo']);
expect(hints).toHaveLength(1);
expect(hints[0]).toContain('also exists as a category');
});
it('returns empty when no overlaps', async () => {
const engine = {
getCategoriesWithCounts: vi.fn().mockResolvedValue([{ category: 'article', count: 10 }]),
getTagsWithCounts: vi.fn().mockResolvedValue([{ tag: 'travel', count: 3 }]),
};
const hints = await buildAmbiguityHints(engine, 'article', ['travel']);
expect(hints).toHaveLength(0);
});
it('returns empty when no category or tags given', async () => {
const engine = {
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
};
const hints = await buildAmbiguityHints(engine, undefined, undefined);
expect(hints).toHaveLength(0);
});
});