This commit is contained in:
208
packages/agent/tests/connectors/connectors-composio.test.ts
Normal file
208
packages/agent/tests/connectors/connectors-composio.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
490
packages/agent/tests/connectors/connectors-crm-data.test.ts
Normal file
490
packages/agent/tests/connectors/connectors-crm-data.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
488
packages/agent/tests/connectors/connectors-google.test.ts
Normal file
488
packages/agent/tests/connectors/connectors-google.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
522
packages/agent/tests/connectors/connectors-knowledge.test.ts
Normal file
522
packages/agent/tests/connectors/connectors-knowledge.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
711
packages/agent/tests/connectors/connectors-microsoft.test.ts
Normal file
711
packages/agent/tests/connectors/connectors-microsoft.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
439
packages/agent/tests/connectors/connectors-pm.test.ts
Normal file
439
packages/agent/tests/connectors/connectors-pm.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
137
packages/agent/tests/connectors/discord-connector.test.ts
Normal file
137
packages/agent/tests/connectors/discord-connector.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
144
packages/agent/tests/connectors/email-connector.test.ts
Normal file
144
packages/agent/tests/connectors/email-connector.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
232
packages/agent/tests/connectors/gcal-connector.test.ts
Normal file
232
packages/agent/tests/connectors/gcal-connector.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
112
packages/agent/tests/connectors/github-connector.test.ts
Normal file
112
packages/agent/tests/connectors/github-connector.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
131
packages/agent/tests/connectors/jira-connector.test.ts
Normal file
131
packages/agent/tests/connectors/jira-connector.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
89
packages/agent/tests/connectors/slack-connector.test.ts
Normal file
89
packages/agent/tests/connectors/slack-connector.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user