Files
waggle-os/packages/agent/tests/connectors/connectors-knowledge.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

523 lines
21 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { NotionConnector } from '../../src/connectors/notion-connector.js';
import { ConfluenceConnector } from '../../src/connectors/confluence-connector.js';
import { ObsidianConnector } from '../../src/connectors/obsidian-connector.js';
import type { VaultStore } from '@waggle/core';
/** Shape of ConnectorResult.data fields asserted by these knowledge-connector tests. */
type KnowledgeData = {
created?: boolean;
updated?: boolean;
content?: string;
name?: string;
notes?: { name: string }[];
results?: { name: string }[];
};
function createMockVault(
connectorId: string,
cred?: { value: string; isExpired: boolean },
extras?: Record<string, { value: string }>,
): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === connectorId && cred) return { ...cred, type: 'api_key' };
return null;
}),
get: vi.fn((key: string) => extras?.[key] ?? null),
set: vi.fn(),
delete: vi.fn(),
list: vi.fn(() => []),
has: vi.fn(() => false),
setConnectorCredential: vi.fn(),
migrateFromConfig: vi.fn(() => 0),
} as unknown as VaultStore;
}
// ── Notion Connector ──────────────────────────────────────────────────
describe('NotionConnector', () => {
let connector: NotionConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new NotionConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and actions', () => {
expect(connector.id).toBe('notion');
expect(connector.name).toBe('Notion');
expect(connector.service).toBe('notion.so');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
expect(connector.actions).toHaveLength(7);
expect(connector.actions.map(a => a.name)).toEqual([
'search_pages', 'get_page', 'list_databases', 'query_database',
'create_page', 'update_page', 'get_block_children',
]);
});
it('action risk levels are correct', () => {
const risks = Object.fromEntries(connector.actions.map(a => [a.name, a.riskLevel]));
expect(risks.search_pages).toBe('low');
expect(risks.get_page).toBe('low');
expect(risks.list_databases).toBe('low');
expect(risks.query_database).toBe('low');
expect(risks.create_page).toBe('medium');
expect(risks.update_page).toBe('medium');
expect(risks.get_block_children).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('search_pages', { query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('connect() retrieves token from vault', async () => {
const vault = createMockVault('notion', { value: 'ntn_test123', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('notion');
});
it('healthCheck() returns connected when API responds OK', async () => {
const vault = createMockVault('notion', { value: 'ntn_test123', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ type: 'bot' }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
expect(health.id).toBe('notion');
});
it('execute(search_pages) calls POST /search', async () => {
const vault = createMockVault('notion', { value: 'ntn_test123', isExpired: false });
await connector.connect(vault);
const mockResults = { results: [{ id: 'page-1', object: 'page' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResults }) as unknown as typeof fetch;
const result = await connector.execute('search_pages', { query: 'project plan' });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockResults);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/search');
expect(fetchCall[1].method).toBe('POST');
});
it('execute(get_page) calls GET /pages/{id}', async () => {
const vault = createMockVault('notion', { value: 'ntn_test123', isExpired: false });
await connector.connect(vault);
const mockPage = { id: 'page-1', object: 'page' };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockPage }) as unknown as typeof fetch;
const result = await connector.execute('get_page', { page_id: 'page-1' });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockPage);
});
it('toDefinition() maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('notion');
expect(def.tools).toContain('connector_notion_search_pages');
expect(def.tools).toContain('connector_notion_create_page');
expect(def.tools).toHaveLength(7);
});
it('execute() returns error for unknown action', async () => {
const vault = createMockVault('notion', { value: 'ntn_test123', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
});
// ── Confluence Connector ──────────────────────────────────────────────
describe('ConfluenceConnector', () => {
let connector: ConfluenceConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new ConfluenceConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and actions', () => {
expect(connector.id).toBe('confluence');
expect(connector.name).toBe('Confluence');
expect(connector.service).toBe('atlassian.net');
expect(connector.authType).toBe('basic');
expect(connector.substrate).toBe('waggle');
expect(connector.actions).toHaveLength(5);
expect(connector.actions.map(a => a.name)).toEqual([
'search_content', 'get_page', 'list_spaces', 'create_page', 'update_page',
]);
});
it('action risk levels are correct', () => {
const risks = Object.fromEntries(connector.actions.map(a => [a.name, a.riskLevel]));
expect(risks.search_content).toBe('low');
expect(risks.get_page).toBe('low');
expect(risks.list_spaces).toBe('low');
expect(risks.create_page).toBe('medium');
expect(risks.update_page).toBe('medium');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('search_content', { cql: 'type=page' });
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('connect() retrieves credentials and domain from vault', async () => {
const vault = createMockVault('confluence', { value: 'api-token-123', isExpired: false }, {
'connector:confluence:email': { value: 'user@example.com' },
'connector:confluence:domain': { value: 'mycompany' },
});
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('confluence');
expect(vault.get).toHaveBeenCalledWith('connector:confluence:email');
expect(vault.get).toHaveBeenCalledWith('connector:confluence:domain');
});
it('healthCheck() returns connected when API responds OK', async () => {
const vault = createMockVault('confluence', { value: 'api-token-123', isExpired: false }, {
'connector:confluence:email': { value: 'user@example.com' },
'connector:confluence:domain': { value: 'mycompany' },
});
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ results: [] }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
expect(health.id).toBe('confluence');
});
it('healthCheck() returns disconnected when no domain configured', async () => {
const vault = createMockVault('confluence', { value: 'api-token-123', isExpired: false }, {
'connector:confluence:email': { value: 'user@example.com' },
// no domain entry
});
await connector.connect(vault);
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
});
it('execute(search_content) calls GET /search with cql', async () => {
const vault = createMockVault('confluence', { value: 'api-token-123', isExpired: false }, {
'connector:confluence:email': { value: 'user@example.com' },
'connector:confluence:domain': { value: 'mycompany' },
});
await connector.connect(vault);
const mockResults = { results: [{ id: '123', title: 'Test Page' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResults }) as unknown as typeof fetch;
const result = await connector.execute('search_content', { cql: 'type=page AND text~"test"' });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockResults);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('mycompany.atlassian.net/wiki/api/v2/search');
expect(fetchCall[0]).toContain('cql=');
});
it('toDefinition() maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('confluence');
expect(def.tools).toContain('connector_confluence_search_content');
expect(def.tools).toContain('connector_confluence_create_page');
expect(def.tools).toHaveLength(5);
});
it('execute() returns error for unknown action', async () => {
const vault = createMockVault('confluence', { value: 'api-token-123', isExpired: false }, {
'connector:confluence:email': { value: 'user@example.com' },
'connector:confluence:domain': { value: 'mycompany' },
});
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
});
// ── Obsidian Connector ────────────────────────────────────────────────
describe('ObsidianConnector', () => {
let connector: ObsidianConnector;
let tmpDir: string;
beforeEach(() => {
connector = new ObsidianConnector();
// Create a temp directory as a mock Obsidian vault
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-obsidian-test-'));
});
afterEach(() => {
// Clean up temp directory
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('has correct id, name, and actions', () => {
expect(connector.id).toBe('obsidian');
expect(connector.name).toBe('Obsidian');
expect(connector.service).toBe('local');
expect(connector.authType).toBe('api_key');
expect(connector.substrate).toBe('waggle');
expect(connector.actions).toHaveLength(6);
expect(connector.actions.map(a => a.name)).toEqual([
'search_notes', 'get_note', 'list_notes', 'create_note', 'update_note', 'list_folders',
]);
});
it('action risk levels are correct', () => {
const risks = Object.fromEntries(connector.actions.map(a => [a.name, a.riskLevel]));
expect(risks.search_notes).toBe('low');
expect(risks.get_note).toBe('low');
expect(risks.list_notes).toBe('low');
expect(risks.create_note).toBe('medium');
expect(risks.update_note).toBe('medium');
expect(risks.list_folders).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('search_notes', { query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('connect() retrieves vault path from vault', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('obsidian');
});
it('healthCheck() returns connected when directory exists', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
expect(health.id).toBe('obsidian');
});
it('healthCheck() returns error when directory does not exist', async () => {
const vault = createMockVault('obsidian', { value: path.join(tmpDir, 'nonexistent'), isExpired: false });
await connector.connect(vault);
const health = await connector.healthCheck();
expect(health.status).toBe('error');
expect(health.error).toBeDefined();
});
it('create_note creates a new file', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
const result = await connector.execute('create_note', {
path: 'test-note.md',
content: '# Hello World\n\nThis is a test note.',
});
expect(result.success).toBe(true);
expect((result.data as KnowledgeData).created).toBe(true);
// Verify the file exists
const filePath = path.join(tmpDir, 'test-note.md');
expect(fs.existsSync(filePath)).toBe(true);
expect(fs.readFileSync(filePath, 'utf-8')).toBe('# Hello World\n\nThis is a test note.');
});
it('create_note creates parent directories', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
const result = await connector.execute('create_note', {
path: 'Projects/subfolder/deep-note.md',
content: 'Deep content',
});
expect(result.success).toBe(true);
expect(fs.existsSync(path.join(tmpDir, 'Projects', 'subfolder', 'deep-note.md'))).toBe(true);
});
it('create_note rejects duplicate', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
fs.writeFileSync(path.join(tmpDir, 'existing.md'), 'old content');
const result = await connector.execute('create_note', {
path: 'existing.md',
content: 'new content',
});
expect(result.success).toBe(false);
expect(result.error).toContain('already exists');
});
it('get_note reads file content', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
fs.writeFileSync(path.join(tmpDir, 'read-me.md'), '# Test\nContent here');
const result = await connector.execute('get_note', { path: 'read-me.md' });
expect(result.success).toBe(true);
expect((result.data as KnowledgeData).content).toBe('# Test\nContent here');
expect((result.data as KnowledgeData).name).toBe('read-me.md');
});
it('get_note returns error for missing file', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
const result = await connector.execute('get_note', { path: 'nonexistent.md' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('update_note overwrites file content', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
fs.writeFileSync(path.join(tmpDir, 'update-me.md'), 'old content');
const result = await connector.execute('update_note', {
path: 'update-me.md',
content: 'new content',
});
expect(result.success).toBe(true);
expect((result.data as KnowledgeData).updated).toBe(true);
expect(fs.readFileSync(path.join(tmpDir, 'update-me.md'), 'utf-8')).toBe('new content');
});
it('list_notes returns all markdown files', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
fs.writeFileSync(path.join(tmpDir, 'note1.md'), 'content 1');
fs.writeFileSync(path.join(tmpDir, 'note2.md'), 'content 2');
fs.writeFileSync(path.join(tmpDir, 'not-markdown.txt'), 'ignored');
const result = await connector.execute('list_notes', {});
expect(result.success).toBe(true);
const notes = (result.data as { notes: { name: string }[] }).notes;
expect(notes).toHaveLength(2);
expect(notes.map((n) => n.name).sort()).toEqual(['note1.md', 'note2.md']);
});
it('list_notes includes subfolder files', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
fs.mkdirSync(path.join(tmpDir, 'Projects'));
fs.writeFileSync(path.join(tmpDir, 'root.md'), 'root');
fs.writeFileSync(path.join(tmpDir, 'Projects', 'sub.md'), 'sub');
const result = await connector.execute('list_notes', {});
expect(result.success).toBe(true);
expect((result.data as KnowledgeData).notes).toHaveLength(2);
});
it('list_notes skips hidden directories', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
fs.mkdirSync(path.join(tmpDir, '.obsidian'));
fs.writeFileSync(path.join(tmpDir, '.obsidian', 'config.md'), 'hidden');
fs.writeFileSync(path.join(tmpDir, 'visible.md'), 'visible');
const result = await connector.execute('list_notes', {});
expect(result.success).toBe(true);
expect((result.data as KnowledgeData).notes).toHaveLength(1);
expect((result.data as KnowledgeData).notes[0].name).toBe('visible.md');
});
it('search_notes finds by filename', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
fs.writeFileSync(path.join(tmpDir, 'project-plan.md'), 'Some content');
fs.writeFileSync(path.join(tmpDir, 'meeting-notes.md'), 'Other content');
const result = await connector.execute('search_notes', { query: 'project' });
expect(result.success).toBe(true);
expect((result.data as KnowledgeData).results).toHaveLength(1);
expect((result.data as KnowledgeData).results[0].name).toBe('project-plan.md');
});
it('search_notes finds by content', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
fs.writeFileSync(path.join(tmpDir, 'note-a.md'), 'This is about JavaScript');
fs.writeFileSync(path.join(tmpDir, 'note-b.md'), 'This is about TypeScript and Waggle');
const result = await connector.execute('search_notes', { query: 'waggle' });
expect(result.success).toBe(true);
expect((result.data as KnowledgeData).results).toHaveLength(1);
expect((result.data as KnowledgeData).results[0].name).toBe('note-b.md');
});
it('list_folders returns subdirectories', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
fs.mkdirSync(path.join(tmpDir, 'Projects'));
fs.mkdirSync(path.join(tmpDir, 'Archive'));
fs.mkdirSync(path.join(tmpDir, '.obsidian')); // hidden, should be excluded
const result = await connector.execute('list_folders', {});
expect(result.success).toBe(true);
const folders = (result.data as { folders: { name: string }[] }).folders;
expect(folders).toHaveLength(2);
expect(folders.map((f) => f.name).sort()).toEqual(['Archive', 'Projects']);
});
it('rejects path traversal', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
const result = await connector.execute('get_note', { path: '../../etc/passwd' });
expect(result.success).toBe(false);
expect(result.error).toContain('path traversal');
});
it('toDefinition() maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('obsidian');
expect(def.tools).toContain('connector_obsidian_search_notes');
expect(def.tools).toContain('connector_obsidian_create_note');
expect(def.tools).toHaveLength(6);
});
it('execute() returns error for unknown action', async () => {
const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
});