moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ComposioConnector } from '../../src/connectors/composio-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(cred?: { value: string; isExpired: boolean }): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === 'composio' && cred) return { ...cred, type: 'api_key' };
return null;
}),
get: vi.fn(() => 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;
}
describe('ComposioConnector', () => {
let connector: ComposioConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new ComposioConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and service', () => {
expect(connector.id).toBe('composio');
expect(connector.name).toBe('Composio (250+ services)');
expect(connector.service).toBe('composio.dev');
expect(connector.authType).toBe('api_key');
expect(connector.substrate).toBe('waggle');
});
it('has all 5 actions', () => {
expect(connector.actions).toHaveLength(5);
const names = connector.actions.map(a => a.name);
expect(names).toContain('list_integrations');
expect(names).toContain('list_actions');
expect(names).toContain('execute_action');
expect(names).toContain('list_connected_accounts');
expect(names).toContain('search_actions');
});
it('execute_action has high risk level', () => {
const executeAction = connector.actions.find(a => a.name === 'execute_action');
expect(executeAction).toBeDefined();
expect(executeAction!.riskLevel).toBe('high');
});
it('list/search actions have low risk level', () => {
const lowRiskActions = connector.actions.filter(a => a.name !== 'execute_action');
expect(lowRiskActions).toHaveLength(4);
for (const action of lowRiskActions) {
expect(action.riskLevel).toBe('low');
}
});
it('connect() retrieves API key from vault', async () => {
const vault = createMockVault({ value: 'cmp_test_key', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('composio');
});
it('execute returns error when not connected', async () => {
const vault = createMockVault(); // no credential
await connector.connect(vault);
const result = await connector.execute('list_integrations', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected without API key', async () => {
const vault = createMockVault(); // no credential
await connector.connect(vault);
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('composio');
});
it('healthCheck returns connected when API responds OK', async () => {
const vault = createMockVault({ value: 'cmp_test_key', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [] }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
expect(health.id).toBe('composio');
});
it('healthCheck returns error when API fails', async () => {
const vault = createMockVault({ value: 'cmp_test_key', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, text: async () => 'Unauthorized' }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('error');
expect(health.error).toContain('401');
});
it('execute(list_integrations) calls correct endpoint', async () => {
const vault = createMockVault({ value: 'cmp_test_key', isExpired: false });
await connector.connect(vault);
const mockData = { items: [{ id: 'int_1', name: 'GitHub' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('list_integrations', {});
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/integrations');
});
it('execute(list_actions) passes appName query param', async () => {
const vault = createMockVault({ value: 'cmp_test_key', isExpired: false });
await connector.connect(vault);
const mockActions = { items: [{ name: 'GITHUB_CREATE_ISSUE' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockActions }) as unknown as typeof fetch;
const result = await connector.execute('list_actions', { appName: 'github' });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockActions);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('appName=github');
});
it('execute(execute_action) POSTs to correct endpoint with params', async () => {
const vault = createMockVault({ value: 'cmp_test_key', isExpired: false });
await connector.connect(vault);
const mockResult = { execution_output: { status: 'success' } };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResult }) as unknown as typeof fetch;
const result = await connector.execute('execute_action', {
actionId: 'GITHUB_CREATE_ISSUE',
params: { title: 'Test issue', body: 'Test body' },
connectedAccountId: 'acc_123',
});
expect(result.success).toBe(true);
expect((result.data as Record<string, unknown>).actionId).toBe('GITHUB_CREATE_ISSUE');
expect((result.data as Record<string, unknown>).service).toBe('composio');
expect((result.data as Record<string, unknown>).result).toEqual(mockResult);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/actions/GITHUB_CREATE_ISSUE/execute');
expect(fetchCall[1].method).toBe('POST');
});
it('execute(execute_action) returns error without actionId', async () => {
const vault = createMockVault({ value: 'cmp_test_key', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('execute_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('actionId is required');
});
it('execute(search_actions) passes searchQuery param', async () => {
const vault = createMockVault({ value: 'cmp_test_key', isExpired: false });
await connector.connect(vault);
const mockResults = { items: [{ name: 'GMAIL_SEND_EMAIL' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResults }) as unknown as typeof fetch;
const result = await connector.execute('search_actions', { searchQuery: 'send email' });
expect(result.success).toBe(true);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('searchQuery=send+email');
});
it('execute() returns error for unknown action', async () => {
const vault = createMockVault({ value: 'cmp_test_key', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('toDefinition() maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('composio');
expect(def.tools).toContain('connector_composio_list_integrations');
expect(def.tools).toContain('connector_composio_execute_action');
expect(def.tools).toContain('connector_composio_search_actions');
expect(def.tools).toHaveLength(5);
expect(def.actions).toHaveLength(5);
expect(def.capabilities).toContain('read');
expect(def.capabilities).toContain('write');
expect(def.capabilities).toContain('search');
});
});

View File

@@ -0,0 +1,490 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HubSpotConnector } from '../../src/connectors/hubspot-connector.js';
import { SalesforceConnector } from '../../src/connectors/salesforce-connector.js';
import { PipedriveConnector } from '../../src/connectors/pipedrive-connector.js';
import { AirtableConnector } from '../../src/connectors/airtable-connector.js';
import { GitLabConnector } from '../../src/connectors/gitlab-connector.js';
import { BitbucketConnector } from '../../src/connectors/bitbucket-connector.js';
import { DropboxConnector } from '../../src/connectors/dropbox-connector.js';
import { PostgresConnector } from '../../src/connectors/postgres-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(connectorId: string, cred?: { value: string; isExpired: boolean }, extras?: Record<string, string>): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === connectorId && cred) return { ...cred, type: 'bearer' };
return null;
}),
get: vi.fn((key: string) => {
if (extras && extras[key]) return { value: extras[key] };
return 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;
}
// ── CRM Connectors ──────────────────────────────────────────
describe('HubSpotConnector', () => {
let connector: HubSpotConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new HubSpotConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct identity', () => {
expect(connector.id).toBe('hubspot');
expect(connector.name).toBe('HubSpot');
expect(connector.service).toBe('hubspot.com');
expect(connector.authType).toBe('bearer');
expect(connector.actions).toHaveLength(7);
});
it('actions include expected names', () => {
const names = connector.actions.map(a => a.name);
expect(names).toContain('list_contacts');
expect(names).toContain('get_contact');
expect(names).toContain('create_contact');
expect(names).toContain('search_contacts');
expect(names).toContain('list_deals');
expect(names).toContain('create_deal');
expect(names).toContain('list_companies');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_contacts', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('toDefinition maps tools correctly', () => {
const def = connector.toDefinition('connected');
expect(def.tools).toContain('connector_hubspot_list_contacts');
expect(def.tools).toContain('connector_hubspot_create_deal');
expect(def.tools).toHaveLength(7);
});
it('execute(list_contacts) returns data when connected', async () => {
const vault = createMockVault('hubspot', { value: 'test-token', isExpired: false });
await connector.connect(vault);
const mockData = { results: [{ id: '1', properties: { email: 'test@example.com' } }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('list_contacts', { limit: 5 });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
});
});
describe('SalesforceConnector', () => {
let connector: SalesforceConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new SalesforceConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct identity', () => {
expect(connector.id).toBe('salesforce');
expect(connector.name).toBe('Salesforce');
expect(connector.service).toBe('salesforce.com');
expect(connector.authType).toBe('bearer');
expect(connector.actions).toHaveLength(6);
});
it('actions include expected names', () => {
const names = connector.actions.map(a => a.name);
expect(names).toContain('search');
expect(names).toContain('list_contacts');
expect(names).toContain('get_record');
expect(names).toContain('create_record');
expect(names).toContain('update_record');
expect(names).toContain('list_opportunities');
});
it('execute returns error when not connected (no token)', async () => {
const result = await connector.execute('search', { query: 'SELECT Id FROM Account' });
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('execute returns error when no instance URL', async () => {
const vault = createMockVault('salesforce', { value: 'token123', isExpired: false });
await connector.connect(vault);
// Token is set but no instance_url
const result = await connector.execute('search', { query: 'SELECT Id FROM Account' });
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('toDefinition maps tools correctly', () => {
const def = connector.toDefinition('connected');
expect(def.tools).toContain('connector_salesforce_search');
expect(def.tools).toContain('connector_salesforce_create_record');
expect(def.tools).toHaveLength(6);
});
it('execute(search) works with instance URL', async () => {
const vault = createMockVault('salesforce', { value: 'token123', isExpired: false }, {
'connector:salesforce:instance_url': 'https://myco.salesforce.com',
});
await connector.connect(vault);
const mockData = { records: [{ Id: '001xx', Name: 'Test Account' }], totalSize: 1 };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('search', { query: 'SELECT Id, Name FROM Account LIMIT 1' });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
});
});
describe('PipedriveConnector', () => {
let connector: PipedriveConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new PipedriveConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct identity', () => {
expect(connector.id).toBe('pipedrive');
expect(connector.name).toBe('Pipedrive');
expect(connector.service).toBe('pipedrive.com');
expect(connector.authType).toBe('api_key');
expect(connector.actions).toHaveLength(7);
});
it('actions include expected names', () => {
const names = connector.actions.map(a => a.name);
expect(names).toContain('list_deals');
expect(names).toContain('get_deal');
expect(names).toContain('create_deal');
expect(names).toContain('search_deals');
expect(names).toContain('list_persons');
expect(names).toContain('create_person');
expect(names).toContain('list_activities');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_deals', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('toDefinition maps tools correctly', () => {
const def = connector.toDefinition('connected');
expect(def.tools).toContain('connector_pipedrive_list_deals');
expect(def.tools).toContain('connector_pipedrive_create_person');
expect(def.tools).toHaveLength(7);
});
it('execute(list_deals) returns data when connected', async () => {
const vault = createMockVault('pipedrive', { value: 'api-key-123', isExpired: false });
await connector.connect(vault);
const mockData = { success: true, data: [{ id: 1, title: 'Big Deal' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('list_deals', { limit: 10 });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
});
});
// ── Data/Storage Connectors ────────────────────────────────
describe('AirtableConnector', () => {
let connector: AirtableConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new AirtableConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct identity', () => {
expect(connector.id).toBe('airtable');
expect(connector.name).toBe('Airtable');
expect(connector.service).toBe('airtable.com');
expect(connector.authType).toBe('bearer');
expect(connector.actions).toHaveLength(6);
});
it('actions include expected names', () => {
const names = connector.actions.map(a => a.name);
expect(names).toContain('list_bases');
expect(names).toContain('list_records');
expect(names).toContain('get_record');
expect(names).toContain('create_record');
expect(names).toContain('update_record');
expect(names).toContain('search_records');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_bases', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('toDefinition maps tools correctly', () => {
const def = connector.toDefinition('connected');
expect(def.tools).toContain('connector_airtable_list_bases');
expect(def.tools).toContain('connector_airtable_create_record');
expect(def.tools).toHaveLength(6);
});
});
describe('GitLabConnector', () => {
let connector: GitLabConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new GitLabConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct identity', () => {
expect(connector.id).toBe('gitlab');
expect(connector.name).toBe('GitLab');
expect(connector.service).toBe('gitlab.com');
expect(connector.authType).toBe('bearer');
expect(connector.actions).toHaveLength(6);
});
it('actions include expected names', () => {
const names = connector.actions.map(a => a.name);
expect(names).toContain('list_projects');
expect(names).toContain('list_issues');
expect(names).toContain('create_issue');
expect(names).toContain('list_merge_requests');
expect(names).toContain('get_file');
expect(names).toContain('search_code');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_projects', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('toDefinition maps tools correctly', () => {
const def = connector.toDefinition('connected');
expect(def.tools).toContain('connector_gitlab_list_projects');
expect(def.tools).toContain('connector_gitlab_create_issue');
expect(def.tools).toHaveLength(6);
});
it('supports self-hosted via vault base_url', async () => {
const vault = createMockVault('gitlab', { value: 'glpat-test', isExpired: false }, {
'connector:gitlab:base_url': 'https://gitlab.mycompany.com/api/v4',
});
await connector.connect(vault);
const mockProjects = [{ id: 1, name: 'myproject' }];
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockProjects }) as unknown as typeof fetch;
const result = await connector.execute('list_projects', {});
expect(result.success).toBe(true);
// Verify the custom base URL was used
const callUrl = vi.mocked(globalThis.fetch).mock.calls[0][0] as string;
expect(callUrl).toContain('gitlab.mycompany.com');
});
});
describe('BitbucketConnector', () => {
let connector: BitbucketConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new BitbucketConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct identity', () => {
expect(connector.id).toBe('bitbucket');
expect(connector.name).toBe('Bitbucket');
expect(connector.service).toBe('bitbucket.org');
expect(connector.authType).toBe('bearer');
expect(connector.actions).toHaveLength(5);
});
it('actions include expected names', () => {
const names = connector.actions.map(a => a.name);
expect(names).toContain('list_repos');
expect(names).toContain('list_pull_requests');
expect(names).toContain('get_file');
expect(names).toContain('create_pull_request');
expect(names).toContain('list_issues');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_repos', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('toDefinition maps tools correctly', () => {
const def = connector.toDefinition('connected');
expect(def.tools).toContain('connector_bitbucket_list_repos');
expect(def.tools).toContain('connector_bitbucket_create_pull_request');
expect(def.tools).toHaveLength(5);
});
});
describe('DropboxConnector', () => {
let connector: DropboxConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new DropboxConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct identity', () => {
expect(connector.id).toBe('dropbox');
expect(connector.name).toBe('Dropbox');
expect(connector.service).toBe('dropbox.com');
expect(connector.authType).toBe('bearer');
expect(connector.actions).toHaveLength(5);
});
it('actions include expected names', () => {
const names = connector.actions.map(a => a.name);
expect(names).toContain('list_folder');
expect(names).toContain('get_file_metadata');
expect(names).toContain('search_files');
expect(names).toContain('download_file');
expect(names).toContain('upload_file');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_folder', { path: '' });
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('toDefinition maps tools correctly', () => {
const def = connector.toDefinition('connected');
expect(def.tools).toContain('connector_dropbox_list_folder');
expect(def.tools).toContain('connector_dropbox_upload_file');
expect(def.tools).toHaveLength(5);
});
it('healthCheck uses POST (Dropbox convention)', async () => {
const vault = createMockVault('dropbox', { value: 'sl.test-token', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ account_id: 'dbid:ABC', name: { display_name: 'Test' } }),
}) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
// Verify POST method was used
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[1].method).toBe('POST');
});
});
describe('PostgresConnector', () => {
let connector: PostgresConnector;
beforeEach(() => {
connector = new PostgresConnector();
});
it('has correct identity', () => {
expect(connector.id).toBe('postgres');
expect(connector.name).toBe('PostgreSQL');
expect(connector.service).toBe('local');
expect(connector.authType).toBe('api_key');
expect(connector.actions).toHaveLength(4);
});
it('actions include expected names', () => {
const names = connector.actions.map(a => a.name);
expect(names).toContain('query');
expect(names).toContain('execute');
expect(names).toContain('list_tables');
expect(names).toContain('describe_table');
});
it('risk levels are correct', () => {
const queryAction = connector.actions.find(a => a.name === 'query');
const executeAction = connector.actions.find(a => a.name === 'execute');
const listAction = connector.actions.find(a => a.name === 'list_tables');
const describeAction = connector.actions.find(a => a.name === 'describe_table');
expect(queryAction?.riskLevel).toBe('low');
expect(executeAction?.riskLevel).toBe('high');
expect(listAction?.riskLevel).toBe('low');
expect(describeAction?.riskLevel).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('query', { sql: 'SELECT 1' });
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('execute returns error when pg module is missing', async () => {
// Mock vault with connection string but pg module will fail to import
const vault = createMockVault('postgres', { value: 'postgresql://user:pass@localhost:5432/testdb', isExpired: false });
await connector.connect(vault);
// The dynamic import of 'pg' will likely fail in test env
// The connector should handle this gracefully
const result = await connector.execute('query', { sql: 'SELECT 1' });
expect(result.success).toBe(false);
// Either "pg module not installed" or some other error — both acceptable
expect(result.error).toBeTruthy();
});
it('toDefinition maps tools correctly', () => {
const def = connector.toDefinition('connected');
expect(def.tools).toContain('connector_postgres_query');
expect(def.tools).toContain('connector_postgres_execute');
expect(def.tools).toContain('connector_postgres_list_tables');
expect(def.tools).toContain('connector_postgres_describe_table');
expect(def.tools).toHaveLength(4);
});
});

View File

@@ -0,0 +1,488 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GmailConnector } from '../../src/connectors/gmail-connector.js';
import { GoogleDocsConnector } from '../../src/connectors/gdocs-connector.js';
import { GoogleDriveConnector } from '../../src/connectors/gdrive-connector.js';
import { GoogleSheetsConnector } from '../../src/connectors/gsheets-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(connectorId: string, cred?: { value: string; isExpired: boolean }): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === connectorId && cred) return { ...cred, type: 'bearer' };
return null;
}),
get: vi.fn(() => 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;
}
// ─── Gmail Connector ───
describe('GmailConnector', () => {
let connector: GmailConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new GmailConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and service', () => {
expect(connector.id).toBe('gmail');
expect(connector.name).toBe('Gmail');
expect(connector.service).toBe('gmail.com');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
});
it('has expected actions', () => {
const actionNames = connector.actions.map(a => a.name);
expect(actionNames).toContain('list_messages');
expect(actionNames).toContain('get_message');
expect(actionNames).toContain('send_message');
expect(actionNames).toContain('search_messages');
expect(actionNames).toContain('list_labels');
});
it('has at least 5 actions with required fields', () => {
expect(connector.actions.length).toBeGreaterThanOrEqual(5);
for (const action of connector.actions) {
expect(action.name).toBeTruthy();
expect(action.description).toBeTruthy();
expect(action.inputSchema).toBeDefined();
expect(['low', 'medium', 'high']).toContain(action.riskLevel);
}
});
it('has correct risk levels', () => {
const listMessages = connector.actions.find(a => a.name === 'list_messages');
const getMessage = connector.actions.find(a => a.name === 'get_message');
const sendMessage = connector.actions.find(a => a.name === 'send_message');
const searchMessages = connector.actions.find(a => a.name === 'search_messages');
const listLabels = connector.actions.find(a => a.name === 'list_labels');
expect(listMessages?.riskLevel).toBe('low');
expect(getMessage?.riskLevel).toBe('low');
expect(sendMessage?.riskLevel).toBe('medium');
expect(searchMessages?.riskLevel).toBe('low');
expect(listLabels?.riskLevel).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_messages', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected when no token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('gmail');
});
it('connect retrieves token from vault', async () => {
const vault = createMockVault('gmail', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('gmail');
});
it('healthCheck returns connected when API responds OK', async () => {
const vault = createMockVault('gmail', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ emailAddress: 'user@gmail.com' }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(list_messages) calls Gmail API', async () => {
const vault = createMockVault('gmail', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
const mockData = { messages: [{ id: '123', threadId: 'abc' }], resultSizeEstimate: 1 };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('list_messages', { maxResults: 5 });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
});
it('execute returns error for unknown action', async () => {
const vault = createMockVault('gmail', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('toDefinition maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('gmail');
expect(def.tools).toContain('connector_gmail_list_messages');
expect(def.tools).toContain('connector_gmail_send_message');
expect(def.actions.length).toBe(connector.actions.length);
});
});
// ─── Google Docs Connector ───
describe('GoogleDocsConnector', () => {
let connector: GoogleDocsConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new GoogleDocsConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and service', () => {
expect(connector.id).toBe('gdocs');
expect(connector.name).toBe('Google Docs');
expect(connector.service).toBe('docs.google.com');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
});
it('has expected actions', () => {
const actionNames = connector.actions.map(a => a.name);
expect(actionNames).toContain('get_document');
expect(actionNames).toContain('create_document');
expect(actionNames).toContain('update_document');
expect(actionNames).toContain('list_comments');
});
it('has at least 4 actions with required fields', () => {
expect(connector.actions.length).toBeGreaterThanOrEqual(4);
for (const action of connector.actions) {
expect(action.name).toBeTruthy();
expect(action.description).toBeTruthy();
expect(action.inputSchema).toBeDefined();
expect(['low', 'medium', 'high']).toContain(action.riskLevel);
}
});
it('has correct risk levels', () => {
const getDoc = connector.actions.find(a => a.name === 'get_document');
const createDoc = connector.actions.find(a => a.name === 'create_document');
const updateDoc = connector.actions.find(a => a.name === 'update_document');
const listComments = connector.actions.find(a => a.name === 'list_comments');
expect(getDoc?.riskLevel).toBe('low');
expect(createDoc?.riskLevel).toBe('medium');
expect(updateDoc?.riskLevel).toBe('medium');
expect(listComments?.riskLevel).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('get_document', { documentId: 'abc' });
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected when no token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('gdocs');
});
it('connect retrieves token from vault', async () => {
const vault = createMockVault('gdocs', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('gdocs');
});
it('healthCheck returns connected when API responds OK', async () => {
const vault = createMockVault('gdocs', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ user: { displayName: 'Test' } }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(get_document) calls Docs API', async () => {
const vault = createMockVault('gdocs', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
const mockData = { documentId: 'abc', title: 'Test Doc', body: {} };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('get_document', { documentId: 'abc' });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
});
it('execute returns error for unknown action', async () => {
const vault = createMockVault('gdocs', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('toDefinition maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('gdocs');
expect(def.tools).toContain('connector_gdocs_get_document');
expect(def.tools).toContain('connector_gdocs_create_document');
expect(def.actions.length).toBe(connector.actions.length);
});
});
// ─── Google Drive Connector ───
describe('GoogleDriveConnector', () => {
let connector: GoogleDriveConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new GoogleDriveConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and service', () => {
expect(connector.id).toBe('gdrive');
expect(connector.name).toBe('Google Drive');
expect(connector.service).toBe('drive.google.com');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
});
it('has expected actions', () => {
const actionNames = connector.actions.map(a => a.name);
expect(actionNames).toContain('list_files');
expect(actionNames).toContain('search_files');
expect(actionNames).toContain('get_file_metadata');
expect(actionNames).toContain('download_file');
expect(actionNames).toContain('upload_file');
expect(actionNames).toContain('create_folder');
});
it('has at least 6 actions with required fields', () => {
expect(connector.actions.length).toBeGreaterThanOrEqual(6);
for (const action of connector.actions) {
expect(action.name).toBeTruthy();
expect(action.description).toBeTruthy();
expect(action.inputSchema).toBeDefined();
expect(['low', 'medium', 'high']).toContain(action.riskLevel);
}
});
it('has correct risk levels', () => {
const listFiles = connector.actions.find(a => a.name === 'list_files');
const searchFiles = connector.actions.find(a => a.name === 'search_files');
const getMetadata = connector.actions.find(a => a.name === 'get_file_metadata');
const downloadFile = connector.actions.find(a => a.name === 'download_file');
const uploadFile = connector.actions.find(a => a.name === 'upload_file');
const createFolder = connector.actions.find(a => a.name === 'create_folder');
expect(listFiles?.riskLevel).toBe('low');
expect(searchFiles?.riskLevel).toBe('low');
expect(getMetadata?.riskLevel).toBe('low');
expect(downloadFile?.riskLevel).toBe('low');
expect(uploadFile?.riskLevel).toBe('medium');
expect(createFolder?.riskLevel).toBe('medium');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_files', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected when no token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('gdrive');
});
it('connect retrieves token from vault', async () => {
const vault = createMockVault('gdrive', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('gdrive');
});
it('healthCheck returns connected when API responds OK', async () => {
const vault = createMockVault('gdrive', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ user: { displayName: 'Test' } }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(list_files) calls Drive API', async () => {
const vault = createMockVault('gdrive', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
const mockData = { files: [{ id: 'f1', name: 'report.pdf' }], nextPageToken: null };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('list_files', { pageSize: 10 });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
});
it('execute returns error for unknown action', async () => {
const vault = createMockVault('gdrive', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('toDefinition maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('gdrive');
expect(def.tools).toContain('connector_gdrive_list_files');
expect(def.tools).toContain('connector_gdrive_upload_file');
expect(def.tools).toContain('connector_gdrive_create_folder');
expect(def.actions.length).toBe(connector.actions.length);
});
});
// ─── Google Sheets Connector ───
describe('GoogleSheetsConnector', () => {
let connector: GoogleSheetsConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new GoogleSheetsConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and service', () => {
expect(connector.id).toBe('gsheets');
expect(connector.name).toBe('Google Sheets');
expect(connector.service).toBe('sheets.google.com');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
});
it('has expected actions', () => {
const actionNames = connector.actions.map(a => a.name);
expect(actionNames).toContain('get_spreadsheet');
expect(actionNames).toContain('get_values');
expect(actionNames).toContain('update_values');
expect(actionNames).toContain('append_values');
expect(actionNames).toContain('create_spreadsheet');
});
it('has at least 5 actions with required fields', () => {
expect(connector.actions.length).toBeGreaterThanOrEqual(5);
for (const action of connector.actions) {
expect(action.name).toBeTruthy();
expect(action.description).toBeTruthy();
expect(action.inputSchema).toBeDefined();
expect(['low', 'medium', 'high']).toContain(action.riskLevel);
}
});
it('has correct risk levels', () => {
const getSpreadsheet = connector.actions.find(a => a.name === 'get_spreadsheet');
const getValues = connector.actions.find(a => a.name === 'get_values');
const updateValues = connector.actions.find(a => a.name === 'update_values');
const appendValues = connector.actions.find(a => a.name === 'append_values');
const createSpreadsheet = connector.actions.find(a => a.name === 'create_spreadsheet');
expect(getSpreadsheet?.riskLevel).toBe('low');
expect(getValues?.riskLevel).toBe('low');
expect(updateValues?.riskLevel).toBe('medium');
expect(appendValues?.riskLevel).toBe('medium');
expect(createSpreadsheet?.riskLevel).toBe('medium');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('get_spreadsheet', { spreadsheetId: 'abc' });
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected when no token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('gsheets');
});
it('connect retrieves token from vault', async () => {
const vault = createMockVault('gsheets', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('gsheets');
});
it('healthCheck returns connected when API responds OK', async () => {
const vault = createMockVault('gsheets', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ user: { displayName: 'Test' } }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(get_spreadsheet) calls Sheets API', async () => {
const vault = createMockVault('gsheets', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
const mockData = { spreadsheetId: 'abc', properties: { title: 'Budget' }, sheets: [] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('get_spreadsheet', { spreadsheetId: 'abc' });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
});
it('execute(get_values) calls Sheets values API', async () => {
const vault = createMockVault('gsheets', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
const mockData = { range: 'Sheet1!A1:D10', majorDimension: 'ROWS', values: [['a', 'b'], ['c', 'd']] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('get_values', { spreadsheetId: 'abc', range: 'Sheet1!A1:D10' });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
});
it('execute returns error for unknown action', async () => {
const vault = createMockVault('gsheets', { value: 'ya29.test_token', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('toDefinition maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('gsheets');
expect(def.tools).toContain('connector_gsheets_get_spreadsheet');
expect(def.tools).toContain('connector_gsheets_update_values');
expect(def.tools).toContain('connector_gsheets_create_spreadsheet');
expect(def.actions.length).toBe(connector.actions.length);
});
});

View File

@@ -0,0 +1,522 @@
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');
});
});

View File

@@ -0,0 +1,711 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MSTeamsConnector } from '../../src/connectors/ms-teams-connector.js';
import { OutlookConnector } from '../../src/connectors/outlook-connector.js';
import { OneDriveConnector } from '../../src/connectors/onedrive-connector.js';
import { OneNoteConnector } from '../../src/connectors/onenote-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(
connectorId: string,
cred?: { value: string; isExpired: boolean },
): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === connectorId && cred) return { ...cred, type: 'bearer' };
return null;
}),
get: vi.fn(() => 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;
}
// ── Microsoft Teams Connector ─────────────────────────────────────────
describe('MSTeamsConnector', () => {
let connector: MSTeamsConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new MSTeamsConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, service, and authType', () => {
expect(connector.id).toBe('ms-teams');
expect(connector.name).toBe('Microsoft Teams');
expect(connector.service).toBe('teams.microsoft.com');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
});
it('has expected number of actions', () => {
expect(connector.actions).toHaveLength(6);
expect(connector.actions.map(a => a.name)).toEqual([
'list_teams', 'list_channels', 'get_messages', 'send_message', 'list_chats', 'send_chat_message',
]);
});
it('action risk levels are correct', () => {
const risks = Object.fromEntries(connector.actions.map(a => [a.name, a.riskLevel]));
expect(risks.list_teams).toBe('low');
expect(risks.list_channels).toBe('low');
expect(risks.get_messages).toBe('low');
expect(risks.send_message).toBe('medium');
expect(risks.list_chats).toBe('low');
expect(risks.send_chat_message).toBe('medium');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_teams', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected without token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('ms-teams');
});
it('connect() retrieves token from vault', async () => {
const vault = createMockVault('ms-teams', { value: 'graph-token-123', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('ms-teams');
});
it('healthCheck() returns connected when Graph API responds OK', async () => {
const vault = createMockVault('ms-teams', { value: 'graph-token-123', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ displayName: 'User' }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('healthCheck() returns error when Graph API fails', async () => {
const vault = createMockVault('ms-teams', { value: 'graph-token-123', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401 }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('error');
expect(health.error).toContain('401');
});
it('execute(list_teams) calls Graph API', async () => {
const vault = createMockVault('ms-teams', { value: 'graph-token-123', isExpired: false });
await connector.connect(vault);
const mockTeams = { value: [{ id: 't1', displayName: 'Engineering' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockTeams }) as unknown as typeof fetch;
const result = await connector.execute('list_teams', {});
expect(result.success).toBe(true);
expect(result.data).toEqual(mockTeams);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/me/joinedTeams');
});
it('execute(send_message) posts to channel', async () => {
const vault = createMockVault('ms-teams', { value: 'graph-token-123', isExpired: false });
await connector.connect(vault);
const mockMsg = { id: 'msg1' };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockMsg }) as unknown as typeof fetch;
const result = await connector.execute('send_message', {
team_id: 't1', channel_id: 'c1', content: 'Hello Teams!',
});
expect(result.success).toBe(true);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/teams/t1/channels/c1/messages');
expect(fetchCall[1].method).toBe('POST');
});
it('execute() returns error for unknown action', async () => {
const vault = createMockVault('ms-teams', { value: 'graph-token-123', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('toDefinition() maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('ms-teams');
expect(def.tools).toContain('connector_ms-teams_list_teams');
expect(def.tools).toContain('connector_ms-teams_send_message');
expect(def.tools).toHaveLength(6);
expect(def.actions).toHaveLength(6);
});
});
// ── Outlook Connector ─────────────────────────────────────────────────
describe('OutlookConnector', () => {
let connector: OutlookConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new OutlookConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, service, and authType', () => {
expect(connector.id).toBe('outlook');
expect(connector.name).toBe('Outlook Calendar & Email');
expect(connector.service).toBe('outlook.office365.com');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
});
it('has expected number of actions', () => {
expect(connector.actions).toHaveLength(6);
expect(connector.actions.map(a => a.name)).toEqual([
'list_events', 'create_event', 'list_emails', 'send_email', 'search_emails', 'get_email',
]);
});
it('action risk levels are correct', () => {
const risks = Object.fromEntries(connector.actions.map(a => [a.name, a.riskLevel]));
expect(risks.list_events).toBe('low');
expect(risks.create_event).toBe('medium');
expect(risks.list_emails).toBe('low');
expect(risks.send_email).toBe('medium');
expect(risks.search_emails).toBe('low');
expect(risks.get_email).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_events', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected without token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('outlook');
});
it('connect() retrieves token from vault', async () => {
const vault = createMockVault('outlook', { value: 'graph-token-456', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('outlook');
});
it('healthCheck() returns connected when Graph API responds OK', async () => {
const vault = createMockVault('outlook', { value: 'graph-token-456', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ displayName: 'User' }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(list_events) calls Graph API', async () => {
const vault = createMockVault('outlook', { value: 'graph-token-456', isExpired: false });
await connector.connect(vault);
const mockEvents = { value: [{ id: 'ev1', subject: 'Standup' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockEvents }) as unknown as typeof fetch;
const result = await connector.execute('list_events', { $top: 10 });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockEvents);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/me/events');
});
it('execute(create_event) creates event with attendees', async () => {
const vault = createMockVault('outlook', { value: 'graph-token-456', isExpired: false });
await connector.connect(vault);
const mockEvent = { id: 'ev2', subject: 'Team Sync' };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockEvent }) as unknown as typeof fetch;
const result = await connector.execute('create_event', {
subject: 'Team Sync',
start: '2026-03-20T10:00:00',
end: '2026-03-20T11:00:00',
attendees: ['alice@example.com'],
});
expect(result.success).toBe(true);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/me/events');
expect(fetchCall[1].method).toBe('POST');
const body = JSON.parse(fetchCall[1].body);
expect(body.subject).toBe('Team Sync');
expect(body.attendees).toHaveLength(1);
expect(body.attendees[0].emailAddress.address).toBe('alice@example.com');
});
it('execute(send_email) sends email', async () => {
const vault = createMockVault('outlook', { value: 'graph-token-456', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, text: async () => '' }) as unknown as typeof fetch;
const result = await connector.execute('send_email', {
to: ['bob@example.com'],
subject: 'Hello',
body: '<p>Hi Bob!</p>',
});
expect(result.success).toBe(true);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/me/sendMail');
expect(fetchCall[1].method).toBe('POST');
});
it('execute(search_emails) searches with $search', async () => {
const vault = createMockVault('outlook', { value: 'graph-token-456', isExpired: false });
await connector.connect(vault);
const mockResults = { value: [{ id: 'm1', subject: 'Project Update' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResults }) as unknown as typeof fetch;
const result = await connector.execute('search_emails', { query: 'project' });
expect(result.success).toBe(true);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/me/messages');
// URLSearchParams encodes $ as %24
expect(decodeURIComponent(fetchCall[0])).toContain('$search');
});
it('execute() returns error for unknown action', async () => {
const vault = createMockVault('outlook', { value: 'graph-token-456', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('toDefinition() maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('outlook');
expect(def.tools).toContain('connector_outlook_list_events');
expect(def.tools).toContain('connector_outlook_send_email');
expect(def.tools).toHaveLength(6);
expect(def.actions).toHaveLength(6);
});
});
// ── OneDrive Connector ────────────────────────────────────────────────
describe('OneDriveConnector', () => {
let connector: OneDriveConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new OneDriveConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, service, and authType', () => {
expect(connector.id).toBe('onedrive');
expect(connector.name).toBe('OneDrive');
expect(connector.service).toBe('onedrive.live.com');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
});
it('has expected number of actions', () => {
expect(connector.actions).toHaveLength(5);
expect(connector.actions.map(a => a.name)).toEqual([
'list_files', 'get_file', 'search_files', 'upload_file', 'list_recent',
]);
});
it('action risk levels are correct', () => {
const risks = Object.fromEntries(connector.actions.map(a => [a.name, a.riskLevel]));
expect(risks.list_files).toBe('low');
expect(risks.get_file).toBe('low');
expect(risks.search_files).toBe('low');
expect(risks.upload_file).toBe('medium');
expect(risks.list_recent).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_files', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected without token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('onedrive');
});
it('connect() retrieves token from vault', async () => {
const vault = createMockVault('onedrive', { value: 'graph-token-789', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('onedrive');
});
it('healthCheck() returns connected when Graph API responds OK', async () => {
const vault = createMockVault('onedrive', { value: 'graph-token-789', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ driveType: 'personal' }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('healthCheck() returns error when Graph API fails', async () => {
const vault = createMockVault('onedrive', { value: 'graph-token-789', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 403 }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('error');
expect(health.error).toContain('403');
});
it('execute(list_files) calls root children endpoint', async () => {
const vault = createMockVault('onedrive', { value: 'graph-token-789', isExpired: false });
await connector.connect(vault);
const mockFiles = { value: [{ id: 'f1', name: 'document.docx' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockFiles }) as unknown as typeof fetch;
const result = await connector.execute('list_files', {});
expect(result.success).toBe(true);
expect(result.data).toEqual(mockFiles);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/me/drive/root/children');
});
it('execute(list_files) with folder_path uses path-based endpoint', async () => {
const vault = createMockVault('onedrive', { value: 'graph-token-789', isExpired: false });
await connector.connect(vault);
const mockFiles = { value: [{ id: 'f2', name: 'report.xlsx' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockFiles }) as unknown as typeof fetch;
const result = await connector.execute('list_files', { folder_path: 'Documents/Work' });
expect(result.success).toBe(true);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/me/drive/root:/Documents/Work:/children');
});
it('execute(search_files) searches via Graph API', async () => {
const vault = createMockVault('onedrive', { value: 'graph-token-789', isExpired: false });
await connector.connect(vault);
const mockResults = { value: [{ id: 'f3', name: 'notes.txt' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResults }) as unknown as typeof fetch;
const result = await connector.execute('search_files', { query: 'notes' });
expect(result.success).toBe(true);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/me/drive/root/search');
expect(fetchCall[0]).toContain('notes');
});
it('execute(upload_file) uploads via PUT', async () => {
const vault = createMockVault('onedrive', { value: 'graph-token-789', isExpired: false });
await connector.connect(vault);
const mockFile = { id: 'f4', name: 'notes.txt' };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockFile }) as unknown as typeof fetch;
const result = await connector.execute('upload_file', {
path: 'Documents/notes.txt',
content: 'Hello World',
});
expect(result.success).toBe(true);
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/me/drive/root:/Documents/notes.txt:/content');
expect(fetchCall[1].method).toBe('PUT');
});
it('execute(get_file) downloads file content', async () => {
const vault = createMockVault('onedrive', { value: 'graph-token-789', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
text: async () => 'file content here',
}) as unknown as typeof fetch;
const result = await connector.execute('get_file', { item_id: 'f1' });
expect(result.success).toBe(true);
expect((result.data as Record<string, unknown>).content).toBe('file content here');
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('/me/drive/items/f1/content');
});
it('execute() returns error for unknown action', async () => {
const vault = createMockVault('onedrive', { value: 'graph-token-789', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('toDefinition() maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('onedrive');
expect(def.tools).toContain('connector_onedrive_list_files');
expect(def.tools).toContain('connector_onedrive_upload_file');
expect(def.tools).toHaveLength(5);
expect(def.actions).toHaveLength(5);
});
});
// ── OneNote Connector (E-6) ────────────────────────────────────────────
describe('OneNoteConnector', () => {
let connector: OneNoteConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new OneNoteConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, service, and authType', () => {
expect(connector.id).toBe('onenote');
expect(connector.name).toBe('Microsoft OneNote');
expect(connector.service).toBe('onenote.com');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
expect(connector.category).toBe('productivity');
});
it('exposes the harvest-focused action surface', () => {
expect(connector.actions).toHaveLength(5);
expect(connector.actions.map((a) => a.name)).toEqual([
'list_notebooks',
'list_sections',
'list_pages',
'get_page',
'search_pages',
]);
});
it('every action is low risk (read-only surface)', () => {
for (const action of connector.actions) {
expect(action.riskLevel).toBe('low');
}
});
it('list_sections requires notebook_id', () => {
const action = connector.actions.find((a) => a.name === 'list_sections')!;
expect(action.inputSchema.required).toContain('notebook_id');
});
it('get_page requires page_id', () => {
const action = connector.actions.find((a) => a.name === 'get_page')!;
expect(action.inputSchema.required).toContain('page_id');
});
it('search_pages requires query', () => {
const action = connector.actions.find((a) => a.name === 'search_pages')!;
expect(action.inputSchema.required).toContain('query');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_notebooks', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
expect(result.error).toContain('Notes.Read');
});
it('healthCheck returns disconnected without token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('onenote');
});
it('connect() retrieves token from vault', async () => {
const vault = createMockVault('onenote', { value: 'graph-token-onenote', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('onenote');
});
it('healthCheck() probes /me/onenote/notebooks to exercise the Notes.Read scope', async () => {
const vault = createMockVault('onenote', { value: 'graph-token', isExpired: false });
await connector.connect(vault);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ value: [] }),
});
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toContain('/me/onenote/notebooks');
expect(calledUrl).toContain('$top=1');
});
it('execute(list_notebooks) calls Graph API with OData params', async () => {
const vault = createMockVault('onenote', { value: 'graph-token', isExpired: false });
await connector.connect(vault);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ value: [{ id: 'n1', displayName: 'Marko Notebook' }] }),
});
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
const result = await connector.execute('list_notebooks', { $top: 10, $orderby: 'displayName' });
expect(result.success).toBe(true);
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toContain('/me/onenote/notebooks');
expect(calledUrl).toContain('%24top=10');
expect(calledUrl).toContain('%24orderby=displayName');
});
it('execute(list_sections) binds notebook_id into the URL path (not query)', async () => {
const vault = createMockVault('onenote', { value: 'graph-token', isExpired: false });
await connector.connect(vault);
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ value: [] }) });
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
await connector.execute('list_sections', { notebook_id: 'nb-1' });
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toContain('/me/onenote/notebooks/nb-1/sections');
// notebook_id must NOT appear in the query string.
expect(calledUrl).not.toContain('notebook_id=');
});
it('execute(list_sections) errors when notebook_id is missing', async () => {
const vault = createMockVault('onenote', { value: 'graph-token', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('list_sections', {});
expect(result.success).toBe(false);
expect(result.error).toContain('notebook_id');
});
it('execute(list_pages) without section_id lists user-wide pages', async () => {
const vault = createMockVault('onenote', { value: 'graph-token', isExpired: false });
await connector.connect(vault);
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ value: [] }) });
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
await connector.execute('list_pages', { $top: 5 });
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toContain('/me/onenote/pages');
// section-scoped URL must NOT appear.
expect(calledUrl).not.toContain('/sections/');
});
it('execute(list_pages) with section_id binds it into the URL path', async () => {
const vault = createMockVault('onenote', { value: 'graph-token', isExpired: false });
await connector.connect(vault);
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ value: [] }) });
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
await connector.execute('list_pages', { section_id: 's-7' });
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toContain('/me/onenote/sections/s-7/pages');
expect(calledUrl).not.toContain('section_id=');
});
it('execute(get_page) returns HTML body for harvest ingestion', async () => {
const vault = createMockVault('onenote', { value: 'graph-token', isExpired: false });
await connector.connect(vault);
const fakeHtml = '<html><body><h1>Note</h1><p>Body</p></body></html>';
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () => fakeHtml,
headers: { get: (k: string) => (k === 'content-type' ? 'text/html' : null) },
});
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
const result = await connector.execute('get_page', { page_id: 'p-1' });
expect(result.success).toBe(true);
const data = result.data as { html: string; contentType: string | null };
expect(data.html).toBe(fakeHtml);
expect(data.contentType).toBe('text/html');
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toContain('/me/onenote/pages/p-1/content');
});
it('execute(get_page) appends includeIDs=true when requested', async () => {
const vault = createMockVault('onenote', { value: 'graph-token', isExpired: false });
await connector.connect(vault);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () => '',
headers: { get: () => null },
});
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
await connector.execute('get_page', { page_id: 'p-1', includeIDs: true });
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toContain('includeIDs=true');
});
it('execute(search_pages) wraps query in quotes for phrase search', async () => {
const vault = createMockVault('onenote', { value: 'graph-token', isExpired: false });
await connector.connect(vault);
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ value: [] }) });
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
await connector.execute('search_pages', { query: 'kvark roadmap', $top: 10 });
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toContain('/me/onenote/pages');
// $search="kvark roadmap" — URL-encoded as %22kvark+roadmap%22 or %22kvark%20roadmap%22.
expect(calledUrl).toMatch(/%24search=%22kvark[+%20]roadmap%22/);
expect(calledUrl).toContain('%24top=10');
});
it('rejects unknown actions', async () => {
const vault = createMockVault('onenote', { value: 'graph-token', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('made-up-action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
});

View File

@@ -0,0 +1,439 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LinearConnector } from '../../src/connectors/linear-connector.js';
import { AsanaConnector } from '../../src/connectors/asana-connector.js';
import { TrelloConnector } from '../../src/connectors/trello-connector.js';
import { MondayConnector } from '../../src/connectors/monday-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(connectorId: string, cred?: { value: string; isExpired: boolean }, extras?: Record<string, string>): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === connectorId && cred) return { ...cred, type: 'bearer' };
return null;
}),
get: vi.fn((key: string) => {
if (extras && extras[key]) return { value: extras[key] };
return 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;
}
// ─── Linear Connector ───
describe('LinearConnector', () => {
let connector: LinearConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new LinearConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and service', () => {
expect(connector.id).toBe('linear');
expect(connector.name).toBe('Linear');
expect(connector.service).toBe('linear.app');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
});
it('has at least 3 actions with required fields', () => {
expect(connector.actions.length).toBeGreaterThanOrEqual(3);
for (const action of connector.actions) {
expect(action.name).toBeTruthy();
expect(action.description).toBeTruthy();
expect(action.inputSchema).toBeDefined();
expect(['low', 'medium', 'high']).toContain(action.riskLevel);
}
});
it('has correct risk levels for actions', () => {
const listIssues = connector.actions.find(a => a.name === 'list_issues');
const createIssue = connector.actions.find(a => a.name === 'create_issue');
const searchIssues = connector.actions.find(a => a.name === 'search_issues');
expect(listIssues?.riskLevel).toBe('low');
expect(createIssue?.riskLevel).toBe('medium');
expect(searchIssues?.riskLevel).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_issues', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected when no token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('linear');
});
it('connect retrieves token from vault', async () => {
const vault = createMockVault('linear', { value: 'lin_api_test123', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('linear');
});
it('healthCheck returns connected when API responds OK', async () => {
const vault = createMockVault('linear', { value: 'lin_api_test123', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: { viewer: { id: '1', name: 'User' } } }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(list_issues) calls GraphQL API', async () => {
const vault = createMockVault('linear', { value: 'lin_api_test123', isExpired: false });
await connector.connect(vault);
const mockData = { data: { issues: { nodes: [{ id: '1', title: 'Test' }] } } };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('list_issues', {});
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData.data);
});
it('execute returns error for unknown action', async () => {
const vault = createMockVault('linear', { value: 'lin_api_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');
});
it('toDefinition maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('linear');
expect(def.tools).toContain('connector_linear_list_issues');
expect(def.tools).toContain('connector_linear_create_issue');
expect(def.actions.length).toBe(connector.actions.length);
});
});
// ─── Asana Connector ───
describe('AsanaConnector', () => {
let connector: AsanaConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new AsanaConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and service', () => {
expect(connector.id).toBe('asana');
expect(connector.name).toBe('Asana');
expect(connector.service).toBe('asana.com');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
});
it('has at least 3 actions with required fields', () => {
expect(connector.actions.length).toBeGreaterThanOrEqual(3);
for (const action of connector.actions) {
expect(action.name).toBeTruthy();
expect(action.description).toBeTruthy();
expect(action.inputSchema).toBeDefined();
expect(['low', 'medium', 'high']).toContain(action.riskLevel);
}
});
it('has correct risk levels for actions', () => {
const listTasks = connector.actions.find(a => a.name === 'list_tasks');
const createTask = connector.actions.find(a => a.name === 'create_task');
const searchTasks = connector.actions.find(a => a.name === 'search_tasks');
expect(listTasks?.riskLevel).toBe('low');
expect(createTask?.riskLevel).toBe('medium');
expect(searchTasks?.riskLevel).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_tasks', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected when no token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('asana');
});
it('connect retrieves token from vault', async () => {
const vault = createMockVault('asana', { value: 'asana_pat_test123', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('asana');
});
it('healthCheck returns connected when API responds OK', async () => {
const vault = createMockVault('asana', { value: 'asana_pat_test123', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: { gid: '1', name: 'User' } }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(list_tasks) calls REST API', async () => {
const vault = createMockVault('asana', { value: 'asana_pat_test123', isExpired: false });
await connector.connect(vault);
const mockData = { data: [{ gid: '1', name: 'Task 1' }] };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('list_tasks', { project: 'proj123' });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
});
it('execute returns error for unknown action', async () => {
const vault = createMockVault('asana', { value: 'asana_pat_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');
});
it('toDefinition maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('asana');
expect(def.tools).toContain('connector_asana_list_tasks');
expect(def.tools).toContain('connector_asana_create_task');
expect(def.actions.length).toBe(connector.actions.length);
});
});
// ─── Trello Connector ───
describe('TrelloConnector', () => {
let connector: TrelloConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new TrelloConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and service', () => {
expect(connector.id).toBe('trello');
expect(connector.name).toBe('Trello');
expect(connector.service).toBe('trello.com');
expect(connector.authType).toBe('api_key');
expect(connector.substrate).toBe('waggle');
});
it('has at least 3 actions with required fields', () => {
expect(connector.actions.length).toBeGreaterThanOrEqual(3);
for (const action of connector.actions) {
expect(action.name).toBeTruthy();
expect(action.description).toBeTruthy();
expect(action.inputSchema).toBeDefined();
expect(['low', 'medium', 'high']).toContain(action.riskLevel);
}
});
it('has correct risk levels for actions', () => {
const listBoards = connector.actions.find(a => a.name === 'list_boards');
const createCard = connector.actions.find(a => a.name === 'create_card');
const searchCards = connector.actions.find(a => a.name === 'search_cards');
expect(listBoards?.riskLevel).toBe('low');
expect(createCard?.riskLevel).toBe('medium');
expect(searchCards?.riskLevel).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_boards', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected when no token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('trello');
});
it('connect retrieves credentials from vault', async () => {
const vault = createMockVault('trello', { value: 'trello_token_test', isExpired: false }, {
'connector:trello:api_key': 'trello_key_test',
});
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('trello');
expect(vault.get).toHaveBeenCalledWith('connector:trello:api_key');
});
it('healthCheck returns connected when API responds OK', async () => {
const vault = createMockVault('trello', { value: 'trello_token_test', isExpired: false }, {
'connector:trello:api_key': 'trello_key_test',
});
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: '1', username: 'user' }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(list_boards) calls REST API with auth params', async () => {
const vault = createMockVault('trello', { value: 'trello_token_test', isExpired: false }, {
'connector:trello:api_key': 'trello_key_test',
});
await connector.connect(vault);
const mockBoards = [{ id: '1', name: 'My Board' }];
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockBoards }) as unknown as typeof fetch;
const result = await connector.execute('list_boards', {});
expect(result.success).toBe(true);
expect(result.data).toEqual(mockBoards);
// Verify auth params are in the URL
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('key=trello_key_test');
expect(fetchCall[0]).toContain('token=trello_token_test');
});
it('execute returns error for unknown action', async () => {
const vault = createMockVault('trello', { value: 'trello_token_test', isExpired: false }, {
'connector:trello:api_key': 'trello_key_test',
});
await connector.connect(vault);
const result = await connector.execute('unknown_action', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('toDefinition maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('trello');
expect(def.tools).toContain('connector_trello_list_boards');
expect(def.tools).toContain('connector_trello_create_card');
expect(def.actions.length).toBe(connector.actions.length);
});
});
// ─── Monday.com Connector ───
describe('MondayConnector', () => {
let connector: MondayConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new MondayConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and service', () => {
expect(connector.id).toBe('monday');
expect(connector.name).toBe('Monday.com');
expect(connector.service).toBe('monday.com');
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
});
it('has at least 3 actions with required fields', () => {
expect(connector.actions.length).toBeGreaterThanOrEqual(3);
for (const action of connector.actions) {
expect(action.name).toBeTruthy();
expect(action.description).toBeTruthy();
expect(action.inputSchema).toBeDefined();
expect(['low', 'medium', 'high']).toContain(action.riskLevel);
}
});
it('has correct risk levels for actions', () => {
const listBoards = connector.actions.find(a => a.name === 'list_boards');
const createItem = connector.actions.find(a => a.name === 'create_item');
const searchItems = connector.actions.find(a => a.name === 'search_items');
expect(listBoards?.riskLevel).toBe('low');
expect(createItem?.riskLevel).toBe('medium');
expect(searchItems?.riskLevel).toBe('low');
});
it('execute returns error when not connected', async () => {
const result = await connector.execute('list_boards', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck returns disconnected when no token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('monday');
});
it('connect retrieves token from vault', async () => {
const vault = createMockVault('monday', { value: 'monday_api_test123', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('monday');
});
it('healthCheck returns connected when API responds OK', async () => {
const vault = createMockVault('monday', { value: 'monday_api_test123', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: { me: { id: '1', name: 'User' } } }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(list_boards) calls GraphQL API', async () => {
const vault = createMockVault('monday', { value: 'monday_api_test123', isExpired: false });
await connector.connect(vault);
const mockData = { data: { boards: [{ id: '1', name: 'Sprint Board' }] } };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockData }) as unknown as typeof fetch;
const result = await connector.execute('list_boards', {});
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData.data);
});
it('execute returns error for unknown action', async () => {
const vault = createMockVault('monday', { value: 'monday_api_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');
});
it('toDefinition maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('monday');
expect(def.tools).toContain('connector_monday_list_boards');
expect(def.tools).toContain('connector_monday_create_item');
expect(def.actions.length).toBe(connector.actions.length);
});
});

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { DiscordConnector } from '../../src/connectors/discord-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(cred?: { value: string; isExpired: boolean }): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === 'discord' && cred) return { ...cred, type: 'bearer' };
return null;
}),
get: vi.fn(() => 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;
}
describe('DiscordConnector', () => {
let connector: DiscordConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new DiscordConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('has correct id, name, and service', () => {
expect(connector.id).toBe('discord');
expect(connector.name).toBe('Discord');
expect(connector.service).toBe('discord.com');
});
it('has at least 5 actions', () => {
expect(connector.actions.length).toBeGreaterThanOrEqual(5);
});
it('implements WaggleConnector interface', () => {
expect(connector.authType).toBe('bearer');
expect(connector.substrate).toBe('waggle');
expect(connector.actions).toHaveLength(6);
});
it('connect() retrieves token from vault', async () => {
const vault = createMockVault({ value: 'bot-test-token', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('discord');
});
it('execute() returns error when not connected', async () => {
const result = await connector.execute('list_guilds', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('healthCheck() returns disconnected when no token', async () => {
const health = await connector.healthCheck();
expect(health.status).toBe('disconnected');
expect(health.id).toBe('discord');
});
it('healthCheck() returns connected when API responds ok', async () => {
const vault = createMockVault({ value: 'bot-test-token', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => ({ id: '123', username: 'waggle-bot' }),
}) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(send_message) sends message to channel', async () => {
const vault = createMockVault({ value: 'bot-test-token', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => ({ id: '987654321', content: 'Hello!' }),
}) as unknown as typeof fetch;
const result = await connector.execute('send_message', { channel_id: '123456', content: 'Hello!' });
expect(result.success).toBe(true);
});
it('execute() returns error for unknown action', async () => {
const vault = createMockVault({ value: 'bot-test-token', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('uses Bot prefix in Authorization header', async () => {
const vault = createMockVault({ value: 'my-bot-token', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => ([]),
}) as unknown as typeof fetch;
await connector.execute('list_guilds', {});
const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0];
expect(fetchCall[0]).toContain('discord.com/api/v10');
expect(fetchCall[1].headers.Authorization).toBe('Bot my-bot-token');
});
it('toDefinition() maps tools correctly', () => {
const def = connector.toDefinition('connected');
expect(def.tools).toEqual([
'connector_discord_list_guilds',
'connector_discord_list_channels',
'connector_discord_get_messages',
'connector_discord_send_message',
'connector_discord_search_messages',
'connector_discord_get_guild_info',
]);
});
it('risk levels are correct (list/get = low, send = medium)', () => {
const actionMap = new Map(connector.actions.map(a => [a.name, a.riskLevel]));
expect(actionMap.get('list_guilds')).toBe('low');
expect(actionMap.get('list_channels')).toBe('low');
expect(actionMap.get('get_messages')).toBe('low');
expect(actionMap.get('send_message')).toBe('medium');
expect(actionMap.get('search_messages')).toBe('low');
expect(actionMap.get('get_guild_info')).toBe('low');
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EmailConnector } from '../../src/connectors/email-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(cred?: { value: string }): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === 'email' && cred) return { ...cred, type: 'api_key', isExpired: false };
return null;
}),
get: vi.fn(() => 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;
}
describe('EmailConnector', () => {
let connector: EmailConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new EmailConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('implements WaggleConnector interface', () => {
expect(connector.id).toBe('email');
expect(connector.name).toBe('Email (SendGrid)');
expect(connector.authType).toBe('api_key');
expect(connector.actions).toHaveLength(3);
});
it('connect() retrieves API key from vault', async () => {
const vault = createMockVault({ value: 'SG.test_key' });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('email');
});
it('healthCheck() validates API key', async () => {
const vault = createMockVault({ value: 'SG.test_key' });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => ({ username: 'waggle' }),
}) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(send_email) sends email (mocked)', async () => {
const vault = createMockVault({ value: 'SG.test_key' });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
status: 202, ok: true, headers: new Map([['X-Message-Id', 'msg-123']]),
text: async () => '',
}) as unknown as typeof fetch;
const result = await connector.execute('send_email', {
to: 'user@example.com',
subject: 'Test',
body: 'Hello from Waggle!',
});
expect(result.success).toBe(true);
expect((result.data as Record<string, unknown>).sent).toBe(true);
});
it('execute(send_email) requires to, subject, body params', async () => {
const vault = createMockVault({ value: 'SG.test_key' });
await connector.connect(vault);
// Missing 'to' — the connector will still call the API but SendGrid would reject
// The connector trusts the agent to provide required params per inputSchema
globalThis.fetch = vi.fn().mockResolvedValue({
status: 400, ok: false, text: async () => 'Missing to',
}) as unknown as typeof fetch;
const result = await connector.execute('send_email', { subject: 'Test', body: 'Hello' });
expect(result.success).toBe(false);
});
it('execute(send_template) sends template email', async () => {
const vault = createMockVault({ value: 'SG.test_key' });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
status: 202, ok: true, headers: new Map(),
text: async () => '',
}) as unknown as typeof fetch;
const result = await connector.execute('send_template', {
to: 'user@example.com',
template_id: 'd-abc123',
variables: { name: 'Test User' },
});
expect(result.success).toBe(true);
});
it('execute(check_delivery) returns delivery status', async () => {
const vault = createMockVault({ value: 'SG.test_key' });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => ({ status: 'delivered', events: [] }),
}) as unknown as typeof fetch;
const result = await connector.execute('check_delivery', { message_id: 'msg-123' });
expect(result.success).toBe(true);
expect((result.data as Record<string, unknown>).status).toBe('delivered');
});
it('rate limiter rejects after max daily sends', async () => {
const vault = createMockVault({ value: 'SG.test_key' });
await connector.connect(vault);
// Artificially set send count to max (reach into private rate-limit state)
const rateState = connector as unknown as { dailySendCount: number; dailyResetDate: string };
rateState.dailySendCount = 100;
rateState.dailyResetDate = new Date().toISOString().slice(0, 10);
const result = await connector.execute('send_email', {
to: 'user@example.com', subject: 'Test', body: 'Hello',
});
expect(result.success).toBe(false);
expect(result.error).toContain('Daily email limit');
});
it('all send actions are riskLevel high', () => {
const sendActions = connector.actions.filter(a => a.name.startsWith('send'));
expect(sendActions).toHaveLength(2);
for (const action of sendActions) {
expect(action.riskLevel).toBe('high');
}
});
});

View File

@@ -0,0 +1,232 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GoogleCalendarConnector } from '../../src/connectors/gcal-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(opts?: {
accessToken?: string;
refreshToken?: string;
expiresAt?: string;
clientId?: string;
clientSecret?: string;
}): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === 'gcal' && opts?.accessToken) {
return {
value: opts.accessToken,
type: 'oauth2',
isExpired: false,
refreshToken: opts.refreshToken,
expiresAt: opts.expiresAt,
};
}
return null;
}),
get: vi.fn((name: string) => {
if (name === 'connector:gcal:client_id' && opts?.clientId) return { value: opts.clientId };
if (name === 'connector:gcal:client_secret' && opts?.clientSecret) return { value: opts.clientSecret };
return 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;
}
describe('GoogleCalendarConnector', () => {
let connector: GoogleCalendarConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new GoogleCalendarConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('implements WaggleConnector interface', () => {
expect(connector.id).toBe('gcal');
expect(connector.name).toBe('Google Calendar');
expect(connector.authType).toBe('oauth2');
expect(connector.actions).toHaveLength(4);
});
it('connect() retrieves OAuth tokens from vault', async () => {
const vault = createMockVault({
accessToken: 'ya29.test_token',
refreshToken: 'rt_test',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
});
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('gcal');
});
it('healthCheck() validates access token', async () => {
const vault = createMockVault({ accessToken: 'ya29.test_token' });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => ({ items: [] }),
}) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('healthCheck() auto-refreshes expired access token', async () => {
const vault = createMockVault({
accessToken: 'ya29.expired',
refreshToken: 'rt_test',
expiresAt: '2020-01-01T00:00:00.000Z', // Expired
clientId: 'client_123',
clientSecret: 'secret_456',
});
await connector.connect(vault);
globalThis.fetch = vi.fn()
// First call: token refresh
.mockResolvedValueOnce({
ok: true, json: async () => ({
access_token: 'ya29.refreshed',
expires_in: 3600,
}),
})
// Second call: calendar list (health check)
.mockResolvedValueOnce({
ok: true, json: async () => ({ items: [] }),
}) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
// Verify tokens were stored back in vault
expect(vault.setConnectorCredential).toHaveBeenCalledWith('gcal', expect.objectContaining({
type: 'oauth2',
value: 'ya29.refreshed',
}));
});
it('execute(list_events) returns events', async () => {
const vault = createMockVault({ accessToken: 'ya29.test_token' });
await connector.connect(vault);
const mockEvents = { items: [{ summary: 'Meeting', start: { dateTime: '2026-03-18T10:00:00Z' } }] };
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => mockEvents,
}) as unknown as typeof fetch;
const result = await connector.execute('list_events', {});
expect(result.success).toBe(true);
expect((result.data as Record<string, unknown>).items).toHaveLength(1);
});
it('execute(create_event) creates event (medium risk)', async () => {
const vault = createMockVault({ accessToken: 'ya29.test_token' });
await connector.connect(vault);
const mockEvent = { id: 'evt_123', summary: 'Team standup' };
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => mockEvent,
}) as unknown as typeof fetch;
const result = await connector.execute('create_event', {
summary: 'Team standup',
start: '2026-03-19T09:00:00Z',
end: '2026-03-19T09:15:00Z',
});
expect(result.success).toBe(true);
expect((result.data as Record<string, unknown>).id).toBe('evt_123');
});
it('execute(find_free_time) returns available slots', async () => {
const vault = createMockVault({ accessToken: 'ya29.test_token' });
await connector.connect(vault);
const mockFreeBusy = { calendars: { primary: { busy: [] } } };
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => mockFreeBusy,
}) as unknown as typeof fetch;
const result = await connector.execute('find_free_time', {
duration: 30,
timeMin: '2026-03-19T08:00:00Z',
timeMax: '2026-03-19T18:00:00Z',
});
expect(result.success).toBe(true);
});
it('toDefinition() correctly reports OAuth2 auth type', () => {
const def = connector.toDefinition('connected');
expect(def.authType).toBe('oauth2');
expect(def.tools).toContain('connector_gcal_list_events');
expect(def.tools).toContain('connector_gcal_create_event');
expect(def.tools).toHaveLength(4);
});
});
describe('OAuth2 token refresh', () => {
let connector: GoogleCalendarConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new GoogleCalendarConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('stores new access + refresh tokens back in vault', async () => {
const vault = createMockVault({
accessToken: 'ya29.expired',
refreshToken: 'rt_test',
expiresAt: '2020-01-01T00:00:00.000Z',
clientId: 'client_123',
clientSecret: 'secret_456',
});
await connector.connect(vault);
globalThis.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true, json: async () => ({
access_token: 'ya29.new',
expires_in: 3600,
refresh_token: 'rt_new',
}),
})
.mockResolvedValueOnce({
ok: true, json: async () => ({ items: [] }),
}) as unknown as typeof fetch;
await connector.execute('list_events', {});
expect(vault.setConnectorCredential).toHaveBeenCalledWith('gcal', expect.objectContaining({
value: 'ya29.new',
refreshToken: 'rt_new',
}));
});
it('returns error when refresh token is invalid', async () => {
const vault = createMockVault({
accessToken: 'ya29.expired',
refreshToken: 'rt_invalid',
expiresAt: '2020-01-01T00:00:00.000Z',
clientId: 'client_123',
clientSecret: 'secret_456',
});
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false, status: 400, text: async () => 'invalid_grant',
}) as unknown as typeof fetch;
const result = await connector.execute('list_events', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Token refresh failed');
});
});

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GitHubConnector } from '../../src/connectors/github-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(cred?: { value: string; isExpired: boolean }): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === 'github' && cred) return { ...cred, type: 'bearer' };
return null;
}),
get: vi.fn(() => 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;
}
describe('GitHubConnector', () => {
let connector: GitHubConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new GitHubConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('implements WaggleConnector interface', () => {
expect(connector.id).toBe('github');
expect(connector.name).toBe('GitHub');
expect(connector.authType).toBe('bearer');
expect(connector.actions.length).toBe(7);
});
it('connect() retrieves token from vault', async () => {
const vault = createMockVault({ value: 'ghp_test123', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('github');
});
it('healthCheck() returns connected when API responds OK', async () => {
const vault = createMockVault({ value: 'ghp_test123', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ login: 'user' }) }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
expect(health.id).toBe('github');
});
it('healthCheck() returns error when API fails', async () => {
const vault = createMockVault({ value: 'ghp_test123', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, text: async () => 'Unauthorized' }) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('error');
expect(health.error).toContain('401');
});
it('execute(list_repos) returns repo list', async () => {
const vault = createMockVault({ value: 'ghp_test123', isExpired: false });
await connector.connect(vault);
const mockRepos = [{ name: 'waggle', full_name: 'user/waggle' }];
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockRepos }) as unknown as typeof fetch;
const result = await connector.execute('list_repos', { per_page: 10 });
expect(result.success).toBe(true);
expect(result.data).toEqual(mockRepos);
});
it('execute(create_issue) creates issue', async () => {
const vault = createMockVault({ value: 'ghp_test123', isExpired: false });
await connector.connect(vault);
const mockIssue = { number: 42, title: 'Bug report' };
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockIssue }) as unknown as typeof fetch;
const result = await connector.execute('create_issue', {
owner: 'user', repo: 'waggle', title: 'Bug report',
});
expect(result.success).toBe(true);
expect(result.data).toEqual(mockIssue);
});
it('execute() returns error for unknown action', async () => {
const vault = createMockVault({ value: 'ghp_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');
});
it('toDefinition() maps correctly', () => {
const def = connector.toDefinition('connected');
expect(def.id).toBe('github');
expect(def.tools).toContain('connector_github_list_repos');
expect(def.tools).toContain('connector_github_create_issue');
expect(def.tools).toHaveLength(7);
expect(def.actions).toHaveLength(7);
});
});

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JiraConnector } from '../../src/connectors/jira-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(opts?: { token?: string; email?: string; baseUrl?: string }): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === 'jira' && opts?.token) return { value: opts.token, type: 'bearer', isExpired: false };
return null;
}),
get: vi.fn((name: string) => {
if (name === 'connector:jira:email' && opts?.email) return { value: opts.email };
if (name === 'connector:jira:base_url' && opts?.baseUrl) return { value: opts.baseUrl };
return 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;
}
describe('JiraConnector', () => {
let connector: JiraConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new JiraConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('implements WaggleConnector interface', () => {
expect(connector.id).toBe('jira');
expect(connector.name).toBe('Jira');
expect(connector.actions).toHaveLength(5);
});
it('connect() builds basic auth from email + API token', async () => {
const vault = createMockVault({
token: 'jira-api-token',
email: 'user@example.com',
baseUrl: 'https://mycompany.atlassian.net',
});
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('jira');
expect(vault.get).toHaveBeenCalledWith('connector:jira:email');
});
it('healthCheck() returns connected when API responds OK', async () => {
const vault = createMockVault({
token: 'jira-api-token',
email: 'user@example.com',
baseUrl: 'https://mycompany.atlassian.net',
});
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => ({ displayName: 'Test User' }),
}) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(create_issue) creates issue', async () => {
const vault = createMockVault({
token: 'jira-api-token',
email: 'user@example.com',
baseUrl: 'https://mycompany.atlassian.net',
});
await connector.connect(vault);
const mockIssue = { key: 'PROJ-42', id: '10042' };
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => mockIssue,
}) as unknown as typeof fetch;
const result = await connector.execute('create_issue', {
project: 'PROJ',
summary: 'Test issue',
issuetype: 'Bug',
});
expect(result.success).toBe(true);
expect(result.data).toEqual(mockIssue);
});
it('execute(transition_issue) transitions issue', async () => {
const vault = createMockVault({
token: 'jira-api-token',
email: 'user@example.com',
baseUrl: 'https://mycompany.atlassian.net',
});
await connector.connect(vault);
// First call: get transitions. Second call: do transition.
globalThis.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true, json: async () => ({ transitions: [{ id: '31', name: 'Done' }, { id: '21', name: 'In Progress' }] }),
})
.mockResolvedValueOnce({
ok: true, json: async () => ({}), text: async () => '',
}) as unknown as typeof fetch;
const result = await connector.execute('transition_issue', {
issueKey: 'PROJ-42',
transitionName: 'Done',
});
expect(result.success).toBe(true);
});
it('execute() returns error when not connected', async () => {
const result = await connector.execute('list_issues', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Not connected');
});
it('toDefinition() maps correctly', () => {
const def = connector.toDefinition('disconnected');
expect(def.id).toBe('jira');
expect(def.status).toBe('disconnected');
expect(def.tools).toContain('connector_jira_create_issue');
expect(def.tools).toContain('connector_jira_transition_issue');
expect(def.tools).toHaveLength(5);
});
});

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SlackConnector } from '../../src/connectors/slack-connector.js';
import type { VaultStore } from '@waggle/core';
function createMockVault(cred?: { value: string; isExpired: boolean }): VaultStore {
return {
getConnectorCredential: vi.fn((id: string) => {
if (id === 'slack' && cred) return { ...cred, type: 'bearer' };
return null;
}),
get: vi.fn(() => 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;
}
describe('SlackConnector', () => {
let connector: SlackConnector;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
connector = new SlackConnector();
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('implements WaggleConnector interface', () => {
expect(connector.id).toBe('slack');
expect(connector.name).toBe('Slack');
expect(connector.authType).toBe('bearer');
expect(connector.actions).toHaveLength(4);
});
it('connect() retrieves token from vault', async () => {
const vault = createMockVault({ value: 'xoxb-test-token', isExpired: false });
await connector.connect(vault);
expect(vault.getConnectorCredential).toHaveBeenCalledWith('slack');
});
it('healthCheck() returns connected when auth.test succeeds', async () => {
const vault = createMockVault({ value: 'xoxb-test-token', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => ({ ok: true, user: 'waggle-bot' }),
}) as unknown as typeof fetch;
const health = await connector.healthCheck();
expect(health.status).toBe('connected');
});
it('execute(send_message) sends message', async () => {
const vault = createMockVault({ value: 'xoxb-test-token', isExpired: false });
await connector.connect(vault);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, json: async () => ({ ok: true, ts: '1234567890.123456' }),
}) as unknown as typeof fetch;
const result = await connector.execute('send_message', { channel: '#general', text: 'Hello!' });
expect(result.success).toBe(true);
});
it('execute() returns error for unknown action', async () => {
const vault = createMockVault({ value: 'xoxb-test-token', isExpired: false });
await connector.connect(vault);
const result = await connector.execute('unknown', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown action');
});
it('toDefinition() maps tools correctly', () => {
const def = connector.toDefinition('connected');
expect(def.tools).toEqual([
'connector_slack_list_channels',
'connector_slack_read_channel',
'connector_slack_search_messages',
'connector_slack_send_message',
]);
});
});