Phase 1: shared blog-tools + a2ui-tools with AI SDK tool(), MCPServer dedup
This commit is contained in:
648
tests/engine/blog-tools.test.ts
Normal file
648
tests/engine/blog-tools.test.ts
Normal file
@@ -0,0 +1,648 @@
|
||||
/**
|
||||
* 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([samplePost]);
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
it('includes ambiguity hints when category also exists as tag', async () => {
|
||||
vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce([]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user