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): 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('binds list_issues filters as GraphQL variables', async () => { const vault = createMockVault('linear', { value: 'lin_api_test123', isExpired: false }); await connector.connect(vault); const teamInjection = '__WAGGLE_TEAM__" } }) { viewer { id } } #'; const stateInjection = '__WAGGLE_STATE__" } }) { viewer { name } } #'; const firstInjection = '__WAGGLE_FIRST__) { viewer { id } } #'; const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: { issues: { nodes: [] } } }), }); globalThis.fetch = fetchMock as unknown as typeof fetch; const result = await connector.execute('list_issues', { teamId: teamInjection, state: stateInjection, first: firstInjection, }); expect(result.success).toBe(true); const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); expect(body.query).not.toContain('__WAGGLE_'); expect(body.variables).toEqual({ first: firstInjection, filter: { team: { id: { eq: teamInjection } }, state: { name: { eq: stateInjection } }, }, }); }); it('binds list limits and preserves default issue filters', async () => { const vault = createMockVault('linear', { value: 'lin_api_test123', isExpired: false }); await connector.connect(vault); const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: {} }), }); globalThis.fetch = fetchMock as unknown as typeof fetch; const calls: Array<[string, Record, Record]> = [ ['list_issues', {}, { first: 50 }], ['list_projects', {}, { first: 50 }], ['list_teams', {}, { first: 50 }], ['list_projects', { first: '__WAGGLE_PROJECT_FIRST__' }, { first: '__WAGGLE_PROJECT_FIRST__' }], ['list_teams', { first: '__WAGGLE_TEAM_FIRST__' }, { first: '__WAGGLE_TEAM_FIRST__' }], ]; for (const [action, params, expectedVariables] of calls) { const callIndex = fetchMock.mock.calls.length; const result = await connector.execute(action, params); expect(result.success).toBe(true); const body = JSON.parse((fetchMock.mock.calls[callIndex][1] as RequestInit).body as string); expect(body.query).not.toContain('__WAGGLE_'); expect(body.variables).toEqual(expectedVariables); if (action === 'list_issues') expect(body.query).not.toContain('$filter'); } }); 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('binds every action value as an exact GraphQL variable', async () => { const vault = createMockVault('monday', { value: 'monday_api_test123', isExpired: false }); await connector.connect(vault); const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: {} }) }); globalThis.fetch = fetchMock as unknown as typeof fetch; const cases: Array<[ string, Record, Record, ]> = [ [ 'list_boards', { limit: '__WAGGLE_BOARD_LIMIT__) { users { id } } #', page: '__WAGGLE_BOARD_PAGE__) { users { email } } #', board_kind: 'private', }, { limit: '__WAGGLE_BOARD_LIMIT__) { users { id } } #', page: '__WAGGLE_BOARD_PAGE__) { users { email } } #', boardKind: 'private', }, ], [ 'list_items', { boardId: '__WAGGLE_BOARD_ID__]) { users { id } } #', groupId: '__WAGGLE_GROUP_ID__"]) { users { email } } #', limit: '__WAGGLE_ITEM_LIMIT__) { users { id } } #', }, { boardId: '__WAGGLE_BOARD_ID__]) { users { id } } #', groupId: '__WAGGLE_GROUP_ID__"]) { users { email } } #', limit: '__WAGGLE_ITEM_LIMIT__) { users { id } } #', }, ], [ 'create_item', { boardId: '__WAGGLE_CREATE_BOARD__', itemName: '__WAGGLE_ITEM_NAME__") { users { id } } #', groupId: '__WAGGLE_CREATE_GROUP__") { users { email } } #', columnValues: '__WAGGLE_CREATE_COLUMNS__") { users { id } } #', }, { boardId: '__WAGGLE_CREATE_BOARD__', itemName: '__WAGGLE_ITEM_NAME__") { users { id } } #', groupId: '__WAGGLE_CREATE_GROUP__") { users { email } } #', columnValues: '__WAGGLE_CREATE_COLUMNS__") { users { id } } #', }, ], [ 'update_item', { boardId: '__WAGGLE_UPDATE_BOARD__', itemId: '__WAGGLE_UPDATE_ITEM__', columnValues: '__WAGGLE_UPDATE_COLUMNS__") { users { id } } #', }, { boardId: '__WAGGLE_UPDATE_BOARD__', itemId: '__WAGGLE_UPDATE_ITEM__', columnValues: '__WAGGLE_UPDATE_COLUMNS__") { users { id } } #', }, ], [ 'search_items', { query: '__WAGGLE_SEARCH_QUERY__"]) { users { email } } #', limit: '__WAGGLE_SEARCH_LIMIT__) { users { id } } #', }, { query: '__WAGGLE_SEARCH_QUERY__"]) { users { email } } #', limit: '__WAGGLE_SEARCH_LIMIT__) { users { id } } #', }, ], ]; for (const [action, params, expectedVariables] of cases) { const callIndex = fetchMock.mock.calls.length; const result = await connector.execute(action, params); expect(result.success).toBe(true); const body = JSON.parse((fetchMock.mock.calls[callIndex][1] as RequestInit).body as string); expect(body.query).not.toContain('__WAGGLE_'); expect(body.query).not.toContain('private'); expect(body.variables).toEqual(expectedVariables); } }); it('preserves defaults and optional Monday action branches', async () => { const vault = createMockVault('monday', { value: 'monday_api_test123', isExpired: false }); await connector.connect(vault); const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: {} }) }); globalThis.fetch = fetchMock as unknown as typeof fetch; const cases: Array<[ string, Record, Record, string[], ]> = [ ['list_boards', {}, { limit: 25, page: 1 }, ['$boardKind']], [ 'list_items', { boardId: '__WAGGLE_NO_GROUP_BOARD__' }, { boardId: '__WAGGLE_NO_GROUP_BOARD__', limit: 50 }, ['$groupId'], ], [ 'create_item', { boardId: '__WAGGLE_REQUIRED_BOARD__', itemName: '__WAGGLE_REQUIRED_NAME__' }, { boardId: '__WAGGLE_REQUIRED_BOARD__', itemName: '__WAGGLE_REQUIRED_NAME__' }, ['$groupId', '$columnValues'], ], [ 'search_items', { query: '__WAGGLE_DEFAULT_SEARCH__' }, { limit: 25, query: '__WAGGLE_DEFAULT_SEARCH__' }, [], ], ]; for (const [action, params, expectedVariables, omittedDefinitions] of cases) { const callIndex = fetchMock.mock.calls.length; const result = await connector.execute(action, params); expect(result.success).toBe(true); const body = JSON.parse((fetchMock.mock.calls[callIndex][1] as RequestInit).body as string); expect(body.query).not.toContain('__WAGGLE_'); expect(body.variables).toEqual(expectedVariables); for (const omitted of omittedDefinitions) expect(body.query).not.toContain(omitted); if (action === 'list_boards') { expect(body.query).toContain('limit: $limit, page: $page'); expect(body.query).not.toContain('limit: 25'); expect(body.query).not.toContain('page: 1'); } } }); it('binds valid board kinds and rejects all other values before issuing GraphQL', async () => { const vault = createMockVault('monday', { value: 'monday_api_test123', isExpired: false }); await connector.connect(vault); const fetchMock = vi.fn(); globalThis.fetch = fetchMock as unknown as typeof fetch; for (const boardKind of ['public', 'private', 'share']) { fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ data: {} }) }); const callIndex = fetchMock.mock.calls.length; const result = await connector.execute('list_boards', { board_kind: boardKind }); expect(result.success).toBe(true); const body = JSON.parse((fetchMock.mock.calls[callIndex][1] as RequestInit).body as string); expect(body.query).not.toContain(boardKind); expect(body.variables).toEqual({ limit: 25, page: 1, boardKind }); } for (const invalidKind of [ 'private) { users { id email } } #', 'workspace', 42, null, { toString: 1 }, ]) { const result = await connector.execute('list_boards', { board_kind: invalidKind }); expect(result.success).toBe(false); expect(result.error).toContain('Invalid board_kind'); } expect(fetchMock).toHaveBeenCalledTimes(3); }); 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); }); });