diff --git a/tests/api/client.test.js b/tests/api/client.test.js index 3d229839..b319551d 100644 --- a/tests/api/client.test.js +++ b/tests/api/client.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert'; -import { describe, it } from 'node:test'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import { createApiClient, DEFAULT_API_URL } from '../../src/api/client.js'; describe('api/client', () => { @@ -108,4 +108,150 @@ describe('api/client', () => { assert.ok(userAgent.includes('(api)')); }); }); + + describe('request', () => { + let originalFetch; + let mockFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + mockFetch = mock.fn(); + globalThis.fetch = mockFetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('makes request to correct URL', async () => { + let client = createApiClient({ + token: 'test-token', + baseUrl: 'https://api.test', + }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: true, + json: async () => ({ data: 'result' }), + })); + + let result = await client.request('/api/builds'); + + assert.deepStrictEqual(result, { data: 'result' }); + assert.strictEqual(mockFetch.mock.calls.length, 1); + + let [url, options] = mockFetch.mock.calls[0].arguments; + assert.strictEqual(url, 'https://api.test/api/builds'); + assert.ok(options.headers.Authorization.includes('test-token')); + }); + + it('passes through fetch options', async () => { + let client = createApiClient({ + token: 'test-token', + baseUrl: 'https://api.test', + }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: true, + json: async () => ({}), + })); + + await client.request('/api/builds', { + method: 'POST', + body: JSON.stringify({ name: 'test' }), + headers: { 'Content-Type': 'application/json' }, + }); + + let [, options] = mockFetch.mock.calls[0].arguments; + assert.strictEqual(options.method, 'POST'); + assert.strictEqual(options.headers['Content-Type'], 'application/json'); + }); + + it('throws AuthError for 401 response', async () => { + let client = createApiClient({ + token: 'test-token', + baseUrl: 'https://api.test', + }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 401, + headers: new Map(), + text: async () => 'Unauthorized', + })); + + await assert.rejects( + () => client.request('/api/protected'), + error => { + assert.strictEqual(error.name, 'AuthError'); + return true; + } + ); + }); + + it('throws VizzlyError for server errors', async () => { + let client = createApiClient({ + token: 'test-token', + baseUrl: 'https://api.test', + }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 500, + headers: new Map(), + text: async () => 'Internal Server Error', + })); + + await assert.rejects( + () => client.request('/api/test'), + error => { + assert.strictEqual(error.name, 'VizzlyError'); + return true; + } + ); + }); + + it('throws VizzlyError for 403 forbidden', async () => { + let client = createApiClient({ + token: 'test-token', + baseUrl: 'https://api.test', + }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 403, + headers: new Map(), + text: async () => 'Forbidden', + })); + + await assert.rejects( + () => client.request('/api/admin'), + error => { + assert.strictEqual(error.name, 'VizzlyError'); + return true; + } + ); + }); + + it('throws VizzlyError for 404 not found', async () => { + let client = createApiClient({ + token: 'test-token', + baseUrl: 'https://api.test', + }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 404, + headers: new Map(), + text: async () => 'Not Found', + })); + + await assert.rejects( + () => client.request('/api/missing'), + error => { + assert.strictEqual(error.name, 'VizzlyError'); + return true; + } + ); + }); + }); }); diff --git a/tests/auth/client.test.js b/tests/auth/client.test.js new file mode 100644 index 00000000..027e12dc --- /dev/null +++ b/tests/auth/client.test.js @@ -0,0 +1,294 @@ +import assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; +import { createAuthClient } from '../../src/auth/client.js'; + +/** + * Create a mock Headers object matching the fetch API spec + * @param {Object} headers - Header key-value pairs + * @returns {Object} Mock headers with get() method + */ +function createMockHeaders(headers = {}) { + return { + get: key => headers[key.toLowerCase()] || null, + }; +} + +describe('auth/client', () => { + let originalFetch; + let mockFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + mockFetch = mock.fn(); + globalThis.fetch = mockFetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('createAuthClient', () => { + it('creates client with default user agent', () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + assert.ok(client.request); + assert.ok(client.authenticatedRequest); + assert.ok(client.getBaseUrl); + assert.ok(client.getUserAgent); + }); + + it('returns configured base URL', () => { + let client = createAuthClient({ baseUrl: 'https://custom.api' }); + + assert.strictEqual(client.getBaseUrl(), 'https://custom.api'); + }); + + it('uses custom user agent when provided', () => { + let client = createAuthClient({ + baseUrl: 'https://api.test', + userAgent: 'custom-agent/1.0', + }); + + assert.strictEqual(client.getUserAgent(), 'custom-agent/1.0'); + }); + + it('builds default user agent with auth command', () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + let userAgent = client.getUserAgent(); + assert.ok(userAgent.includes('vizzly-cli')); + assert.ok(userAgent.includes('auth')); + }); + }); + + describe('request', () => { + it('makes unauthenticated request to endpoint', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: true, + json: async () => ({ success: true }), + })); + + let result = await client.request('/api/device/code'); + + assert.deepStrictEqual(result, { success: true }); + assert.strictEqual(mockFetch.mock.calls.length, 1); + + let [url, options] = mockFetch.mock.calls[0].arguments; + assert.strictEqual(url, 'https://api.test/api/device/code'); + assert.ok(options.headers['User-Agent']); + }); + + it('passes through fetch options', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: true, + json: async () => ({}), + })); + + await client.request('/api/login', { + method: 'POST', + body: JSON.stringify({ email: 'test@example.com' }), + headers: { 'Content-Type': 'application/json' }, + }); + + let [, options] = mockFetch.mock.calls[0].arguments; + assert.strictEqual(options.method, 'POST'); + assert.strictEqual(options.headers['Content-Type'], 'application/json'); + }); + + it('throws AuthError for 401 response', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 401, + headers: createMockHeaders({ 'content-type': 'application/json' }), + json: async () => ({ error: 'Invalid credentials' }), + })); + + await assert.rejects( + () => client.request('/api/login'), + error => { + assert.strictEqual(error.name, 'AuthError'); + assert.strictEqual(error.message, 'Invalid credentials'); + return true; + } + ); + }); + + it('throws VizzlyError for 429 rate limit', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 429, + headers: createMockHeaders({ 'content-type': 'application/json' }), + json: async () => ({}), + })); + + await assert.rejects( + () => client.request('/api/login'), + error => { + assert.strictEqual(error.code, 'RATE_LIMIT_ERROR'); + assert.ok(error.message.includes('Too many login attempts')); + return true; + } + ); + }); + + it('throws VizzlyError for server errors', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: createMockHeaders({ 'content-type': 'text/plain' }), + text: async () => 'Server error', + })); + + await assert.rejects( + () => client.request('/api/test'), + error => { + assert.strictEqual(error.code, 'AUTH_REQUEST_ERROR'); + assert.ok(error.message.includes('500')); + return true; + } + ); + }); + + it('handles response body parse errors', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: { + get: () => 'application/json', + }, + json: async () => { + throw new Error('Parse error'); + }, + })); + + await assert.rejects( + () => client.request('/api/test'), + error => { + assert.strictEqual(error.code, 'AUTH_REQUEST_ERROR'); + return true; + } + ); + }); + }); + + describe('authenticatedRequest', () => { + it('makes authenticated request with bearer token', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: true, + json: async () => ({ user: { id: 'user_123' } }), + })); + + let result = await client.authenticatedRequest( + '/api/auth/cli/whoami', + 'access_token_123' + ); + + assert.deepStrictEqual(result, { user: { id: 'user_123' } }); + + let [url, options] = mockFetch.mock.calls[0].arguments; + assert.strictEqual(url, 'https://api.test/api/auth/cli/whoami'); + assert.strictEqual( + options.headers.Authorization, + 'Bearer access_token_123' + ); + }); + + it('passes through fetch options for authenticated requests', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: true, + json: async () => ({}), + })); + + await client.authenticatedRequest('/api/update', 'token', { + method: 'PUT', + body: JSON.stringify({ name: 'Updated' }), + headers: { 'Content-Type': 'application/json' }, + }); + + let [, options] = mockFetch.mock.calls[0].arguments; + assert.strictEqual(options.method, 'PUT'); + assert.strictEqual(options.headers['Content-Type'], 'application/json'); + assert.strictEqual(options.headers.Authorization, 'Bearer token'); + }); + + it('throws AuthError for 401 expired token', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 401, + headers: createMockHeaders({ 'content-type': 'application/json' }), + json: async () => ({ error: 'Token expired' }), + })); + + await assert.rejects( + () => client.authenticatedRequest('/api/protected', 'expired_token'), + error => { + assert.strictEqual(error.name, 'AuthError'); + assert.ok(error.message.includes('invalid or expired')); + return true; + } + ); + }); + + it('throws VizzlyError for other errors', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 403, + headers: createMockHeaders({ 'content-type': 'application/json' }), + json: async () => ({ error: 'Forbidden' }), + })); + + await assert.rejects( + () => client.authenticatedRequest('/api/admin', 'token'), + error => { + assert.strictEqual(error.code, 'API_REQUEST_ERROR'); + assert.ok(error.message.includes('403')); + return true; + } + ); + }); + + it('handles text response body on error', async () => { + let client = createAuthClient({ baseUrl: 'https://api.test' }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: { + get: () => 'text/plain', + }, + text: async () => 'Plain text error', + })); + + await assert.rejects( + () => client.authenticatedRequest('/api/test', 'token'), + error => { + assert.ok(error.message.includes('500')); + return true; + } + ); + }); + }); +}); diff --git a/tests/project/operations.test.js b/tests/project/operations.test.js new file mode 100644 index 00000000..61050f10 --- /dev/null +++ b/tests/project/operations.test.js @@ -0,0 +1,655 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { + createMapping, + createProjectToken, + getMapping, + getProject, + getProjectWithApiToken, + getProjectWithOAuth, + getRecentBuilds, + getRecentBuildsWithApiToken, + getRecentBuildsWithOAuth, + listMappings, + listProjects, + listProjectsWithApiToken, + listProjectsWithOAuth, + listProjectTokens, + removeMapping, + revokeProjectToken, + switchProject, +} from '../../src/project/operations.js'; + +describe('project/operations', () => { + describe('listMappings', () => { + it('returns array of mappings with directory included', async () => { + let store = createMockMappingStore({ + '/path/a': { projectSlug: 'proj-a', token: 'tok-a' }, + '/path/b': { projectSlug: 'proj-b', token: 'tok-b' }, + }); + + let result = await listMappings(store); + + assert.strictEqual(result.length, 2); + assert.deepStrictEqual(result[0], { + directory: '/path/a', + projectSlug: 'proj-a', + token: 'tok-a', + }); + }); + + it('returns empty array for no mappings', async () => { + let store = createMockMappingStore({}); + + let result = await listMappings(store); + + assert.deepStrictEqual(result, []); + }); + }); + + describe('getMapping', () => { + it('returns mapping for directory', async () => { + let store = createMockMappingStore({}); + store.getMapping = async _dir => ({ projectSlug: 'proj', token: 'tok' }); + + let result = await getMapping(store, '/my/path'); + + assert.deepStrictEqual(result, { projectSlug: 'proj', token: 'tok' }); + }); + + it('returns null for missing directory', async () => { + let store = createMockMappingStore({}); + store.getMapping = async () => null; + + let result = await getMapping(store, '/unknown'); + + assert.strictEqual(result, null); + }); + }); + + describe('createMapping', () => { + it('creates and returns mapping with directory', async () => { + let savedDir = null; + let _savedData = null; + + let store = { + saveMapping: async (dir, data) => { + savedDir = dir; + _savedData = data; + }, + }; + + let result = await createMapping(store, '/my/path', { + projectSlug: 'proj', + organizationSlug: 'org', + token: 'tok', + }); + + assert.strictEqual(savedDir, '/my/path'); + assert.deepStrictEqual(result, { + directory: '/my/path', + projectSlug: 'proj', + organizationSlug: 'org', + token: 'tok', + }); + }); + + it('throws for invalid directory', async () => { + let store = { saveMapping: async () => {} }; + + await assert.rejects( + () => createMapping(store, '', { projectSlug: 'p' }), + { + code: 'INVALID_DIRECTORY', + } + ); + }); + + it('throws for missing project data', async () => { + let store = { saveMapping: async () => {} }; + + await assert.rejects(() => createMapping(store, '/path', {}), { + code: 'INVALID_PROJECT_DATA', + }); + }); + }); + + describe('removeMapping', () => { + it('removes mapping for directory', async () => { + let deletedDir = null; + let store = { + deleteMapping: async dir => { + deletedDir = dir; + }, + }; + + await removeMapping(store, '/my/path'); + + assert.strictEqual(deletedDir, '/my/path'); + }); + + it('throws for invalid directory', async () => { + let store = { deleteMapping: async () => {} }; + + await assert.rejects(() => removeMapping(store, ''), { + code: 'INVALID_DIRECTORY', + }); + }); + }); + + describe('switchProject', () => { + it('creates mapping with project data', async () => { + let savedDir = null; + let savedData = null; + + let store = { + saveMapping: async (dir, data) => { + savedDir = dir; + savedData = data; + }, + }; + + let result = await switchProject(store, '/path', 'proj', 'org', 'tok'); + + assert.strictEqual(savedDir, '/path'); + assert.deepStrictEqual(savedData, { + projectSlug: 'proj', + organizationSlug: 'org', + token: 'tok', + }); + assert.strictEqual(result.projectSlug, 'proj'); + }); + }); + + describe('listProjectsWithOAuth', () => { + it('fetches projects for all organizations', async () => { + let client = createMockOAuthClient({ + '/api/auth/cli/whoami': { + organizations: [ + { slug: 'org1', name: 'Org 1' }, + { slug: 'org2', name: 'Org 2' }, + ], + }, + '/api/project': { projects: [{ id: 'p1', name: 'Project 1' }] }, + }); + + let result = await listProjectsWithOAuth(client); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].organizationSlug, 'org1'); + }); + + it('returns empty array when no organizations', async () => { + let client = createMockOAuthClient({ + '/api/auth/cli/whoami': { organizations: [] }, + }); + + let result = await listProjectsWithOAuth(client); + + assert.deepStrictEqual(result, []); + }); + + it('silently skips failed org requests and returns successful ones', async () => { + let client = { + authenticatedRequest: async (endpoint, options) => { + if (endpoint === '/api/auth/cli/whoami') { + return { + organizations: [ + { slug: 'failing-org', name: 'Failing Org' }, + { slug: 'successful-org', name: 'Successful Org' }, + ], + }; + } + // Fail requests for 'failing-org', succeed for 'successful-org' + if (options?.headers?.['X-Organization'] === 'failing-org') { + throw new Error('Network error for failing-org'); + } + return { + projects: [{ id: 'p1', name: 'Project from successful org' }], + }; + }, + }; + + let result = await listProjectsWithOAuth(client); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].organizationSlug, 'successful-org'); + assert.strictEqual(result[0].name, 'Project from successful org'); + }); + }); + + describe('listProjectsWithApiToken', () => { + it('fetches projects using API client', async () => { + let client = createMockApiClient({ + '/api/project': { projects: [{ id: 'p1' }, { id: 'p2' }] }, + }); + + let result = await listProjectsWithApiToken(client); + + assert.strictEqual(result.length, 2); + }); + }); + + describe('listProjects', () => { + it('uses OAuth first when available', async () => { + let oauthCalled = false; + let apiCalled = false; + + let oauthClient = { + authenticatedRequest: async () => { + oauthCalled = true; + return { organizations: [] }; + }, + }; + + let apiClient = { + request: async () => { + apiCalled = true; + return { projects: [] }; + }, + }; + + await listProjects({ oauthClient, apiClient }); + + assert.strictEqual(oauthCalled, true); + assert.strictEqual(apiCalled, false); + }); + + it('falls back to API token when OAuth fails', async () => { + let apiCalled = false; + + let oauthClient = { + authenticatedRequest: async () => { + throw new Error('OAuth failed'); + }, + }; + + let apiClient = { + request: async () => { + apiCalled = true; + return { projects: [{ id: 'p1' }] }; + }, + }; + + let result = await listProjects({ oauthClient, apiClient }); + + assert.strictEqual(apiCalled, true); + assert.strictEqual(result.length, 1); + }); + + it('returns empty array when API token fails', async () => { + let apiClient = { + request: async () => { + throw new Error('API failed'); + }, + }; + + let result = await listProjects({ apiClient }); + + assert.deepStrictEqual(result, []); + }); + + it('returns empty array when no clients available', async () => { + let result = await listProjects({}); + + assert.deepStrictEqual(result, []); + }); + }); + + describe('getProjectWithOAuth', () => { + it('fetches project with org header', async () => { + let capturedHeaders = null; + let client = { + authenticatedRequest: async (_endpoint, options) => { + capturedHeaders = options.headers; + return { project: { id: 'p1', name: 'Project' } }; + }, + }; + + let result = await getProjectWithOAuth(client, 'my-project', 'my-org'); + + assert.deepStrictEqual(capturedHeaders, { 'X-Organization': 'my-org' }); + assert.strictEqual(result.id, 'p1'); + }); + }); + + describe('getProjectWithApiToken', () => { + it('fetches project using API client', async () => { + let client = createMockApiClient({ + '/api/project/my-project': { + project: { id: 'p1', slug: 'my-project' }, + }, + }); + + let result = await getProjectWithApiToken(client, 'my-project', 'my-org'); + + assert.strictEqual(result.id, 'p1'); + }); + }); + + describe('getProject', () => { + it('uses OAuth first when available', async () => { + let oauthClient = { + authenticatedRequest: async () => ({ + project: { id: 'p1', name: 'OAuth Project' }, + }), + }; + + let result = await getProject({ + oauthClient, + projectSlug: 'proj', + organizationSlug: 'org', + }); + + assert.strictEqual(result.name, 'OAuth Project'); + }); + + it('falls back to API token when OAuth fails', async () => { + let oauthClient = { + authenticatedRequest: async () => { + throw new Error('OAuth failed'); + }, + }; + + let apiClient = { + request: async () => ({ + project: { id: 'p1', name: 'API Project' }, + }), + }; + + let result = await getProject({ + oauthClient, + apiClient, + projectSlug: 'proj', + organizationSlug: 'org', + }); + + assert.strictEqual(result.name, 'API Project'); + }); + + it('throws wrapped error when API token fails', async () => { + let apiClient = { + request: async () => { + throw new Error('API failed'); + }, + }; + + await assert.rejects( + () => + getProject({ + apiClient, + projectSlug: 'proj', + organizationSlug: 'org', + }), + { code: 'PROJECT_FETCH_FAILED' } + ); + }); + + it('throws no auth error when no clients available', async () => { + await assert.rejects( + () => + getProject({ + projectSlug: 'proj', + organizationSlug: 'org', + }), + { code: 'NO_AUTH_SERVICE' } + ); + }); + }); + + describe('getRecentBuildsWithOAuth', () => { + it('fetches builds with org header', async () => { + let capturedEndpoint = null; + let client = { + authenticatedRequest: async endpoint => { + capturedEndpoint = endpoint; + return { builds: [{ id: 'b1' }, { id: 'b2' }] }; + }, + }; + + let result = await getRecentBuildsWithOAuth( + client, + 'my-project', + 'my-org', + { limit: 5 } + ); + + assert.ok(capturedEndpoint.includes('limit=5')); + assert.strictEqual(result.length, 2); + }); + }); + + describe('getRecentBuildsWithApiToken', () => { + it('fetches builds using API client', async () => { + let client = { + request: async () => ({ + builds: [{ id: 'b1' }], + }), + }; + + let result = await getRecentBuildsWithApiToken( + client, + 'my-project', + 'my-org' + ); + + assert.strictEqual(result.length, 1); + }); + }); + + describe('getRecentBuilds', () => { + it('uses OAuth first when available', async () => { + let oauthClient = { + authenticatedRequest: async () => ({ + builds: [{ id: 'oauth-build' }], + }), + }; + + let result = await getRecentBuilds({ + oauthClient, + projectSlug: 'proj', + organizationSlug: 'org', + }); + + assert.strictEqual(result[0].id, 'oauth-build'); + }); + + it('falls back to API token when OAuth fails', async () => { + let oauthClient = { + authenticatedRequest: async () => { + throw new Error('OAuth failed'); + }, + }; + + let apiClient = { + request: async () => ({ + builds: [{ id: 'api-build' }], + }), + }; + + let result = await getRecentBuilds({ + oauthClient, + apiClient, + projectSlug: 'proj', + organizationSlug: 'org', + }); + + assert.strictEqual(result[0].id, 'api-build'); + }); + + it('returns empty array when API token fails', async () => { + let apiClient = { + request: async () => { + throw new Error('API failed'); + }, + }; + + let result = await getRecentBuilds({ + apiClient, + projectSlug: 'proj', + organizationSlug: 'org', + }); + + assert.deepStrictEqual(result, []); + }); + + it('returns empty array when no clients available', async () => { + let result = await getRecentBuilds({ + projectSlug: 'proj', + organizationSlug: 'org', + }); + + assert.deepStrictEqual(result, []); + }); + }); + + describe('createProjectToken', () => { + it('creates token and returns extracted result', async () => { + let capturedBody = null; + let client = { + request: async (_endpoint, options) => { + capturedBody = JSON.parse(options.body); + return { token: { id: 't1', value: 'vzt_xxx' } }; + }, + }; + + let result = await createProjectToken(client, 'proj', 'org', { + name: 'My Token', + description: 'Test token', + }); + + assert.deepStrictEqual(capturedBody, { + name: 'My Token', + description: 'Test token', + }); + assert.strictEqual(result.id, 't1'); + }); + + it('throws when no API client', async () => { + await assert.rejects( + () => createProjectToken(null, 'proj', 'org', { name: 'tok' }), + { code: 'NO_API_SERVICE' } + ); + }); + + it('throws wrapped error on failure', async () => { + let client = { + request: async () => { + throw new Error('Create failed'); + }, + }; + + await assert.rejects( + () => createProjectToken(client, 'proj', 'org', { name: 'tok' }), + { code: 'TOKEN_CREATE_FAILED' } + ); + }); + }); + + describe('listProjectTokens', () => { + it('lists tokens for project', async () => { + let client = { + request: async () => ({ + tokens: [{ id: 't1' }, { id: 't2' }], + }), + }; + + let result = await listProjectTokens(client, 'proj', 'org'); + + assert.strictEqual(result.length, 2); + }); + + it('throws when no API client', async () => { + await assert.rejects(() => listProjectTokens(null, 'proj', 'org'), { + code: 'NO_API_SERVICE', + }); + }); + + it('throws wrapped error on failure', async () => { + let client = { + request: async () => { + throw new Error('Fetch failed'); + }, + }; + + await assert.rejects(() => listProjectTokens(client, 'proj', 'org'), { + code: 'TOKENS_FETCH_FAILED', + }); + }); + }); + + describe('revokeProjectToken', () => { + it('revokes token by ID', async () => { + let capturedEndpoint = null; + let capturedMethod = null; + let client = { + request: async (endpoint, options) => { + capturedEndpoint = endpoint; + capturedMethod = options.method; + }, + }; + + await revokeProjectToken(client, 'proj', 'org', 'tok_123'); + + assert.ok(capturedEndpoint.includes('tok_123')); + assert.strictEqual(capturedMethod, 'DELETE'); + }); + + it('throws when no API client', async () => { + await assert.rejects( + () => revokeProjectToken(null, 'proj', 'org', 'tok_123'), + { code: 'NO_API_SERVICE' } + ); + }); + + it('throws wrapped error on failure', async () => { + let client = { + request: async () => { + throw new Error('Revoke failed'); + }, + }; + + await assert.rejects( + () => revokeProjectToken(client, 'proj', 'org', 'tok_123'), + { code: 'TOKEN_REVOKE_FAILED' } + ); + }); + }); +}); + +// Test helpers + +function createMockMappingStore(mappings) { + return { + getMappings: async () => mappings, + getMapping: async dir => mappings[dir] || null, + saveMapping: async () => {}, + deleteMapping: async () => {}, + }; +} + +function createMockOAuthClient(responses) { + return { + authenticatedRequest: async (endpoint, _options = {}) => { + for (let key of Object.keys(responses)) { + if (endpoint.includes(key)) { + return responses[key]; + } + } + throw new Error(`No mock response for ${endpoint}`); + }, + }; +} + +function createMockApiClient(responses) { + return { + request: async (endpoint, _options = {}) => { + for (let key of Object.keys(responses)) { + if (endpoint.includes(key)) { + return responses[key]; + } + } + throw new Error(`No mock response for ${endpoint}`); + }, + }; +} diff --git a/tests/server-manager/index.test.js b/tests/server-manager/index.test.js new file mode 100644 index 00000000..a4fca92b --- /dev/null +++ b/tests/server-manager/index.test.js @@ -0,0 +1,308 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { buildServerInterface } from '../../src/server-manager/core.js'; +import { createServerManager } from '../../src/server-manager/index.js'; +import { + getTddResults, + startServer, + stopServer, +} from '../../src/server-manager/operations.js'; + +describe('server-manager/index', () => { + describe('createServerManager', () => { + it('creates manager with start, stop, getTddResults, and server', () => { + let manager = createServerManager({ server: { port: 47392 } }); + + assert.ok(manager.start); + assert.ok(manager.stop); + assert.ok(manager.getTddResults); + assert.ok('server' in manager); + }); + + describe('start', () => { + it('starts TDD server when tddMode is true', async () => { + let httpServerStarted = false; + let tddHandlerCreated = false; + let serverJsonWritten = false; + + let mockHandler = { + initialize: async () => {}, + tddService: {}, + }; + + let mockDeps = { + createHttpServer: () => ({ + start: async () => { + httpServerStarted = true; + }, + }), + createTddHandler: () => { + tddHandlerCreated = true; + return mockHandler; + }, + createApiHandler: () => ({}), + createApiClient: () => ({}), + fs: { + mkdirSync: () => {}, + writeFileSync: () => { + serverJsonWritten = true; + }, + }, + }; + + // Inject deps by creating manager with custom startServer + let manager = createServerManagerWithDeps( + { server: { port: 47392 } }, + {}, + mockDeps + ); + + await manager.start('build-123', true, false); + + assert.strictEqual(httpServerStarted, true); + assert.strictEqual(tddHandlerCreated, true); + assert.strictEqual(serverJsonWritten, true); + }); + + it('starts API server when tddMode is false with apiKey', async () => { + let apiHandlerCreated = false; + + let mockDeps = { + createHttpServer: () => ({ + start: async () => {}, + }), + createTddHandler: () => ({ + initialize: async () => {}, + }), + createApiHandler: () => { + apiHandlerCreated = true; + return {}; + }, + createApiClient: () => ({}), + fs: { + mkdirSync: () => {}, + writeFileSync: () => {}, + }, + }; + + let manager = createServerManagerWithDeps( + { server: { port: 47392 }, apiKey: 'test-key' }, + {}, + mockDeps + ); + + await manager.start('build-123', false, false); + + assert.strictEqual(apiHandlerCreated, true); + }); + }); + + describe('stop', () => { + it('stops http server and cleans up handler', async () => { + let httpServerStopped = false; + let handlerCleaned = false; + let serverJsonRemoved = false; + + let mockHandler = { + initialize: async () => {}, + tddService: {}, + cleanup: () => { + handlerCleaned = true; + }, + }; + + let mockDeps = { + createHttpServer: () => ({ + start: async () => {}, + stop: async () => { + httpServerStopped = true; + }, + }), + createTddHandler: () => mockHandler, + createApiHandler: () => ({}), + createApiClient: () => ({}), + fs: { + mkdirSync: () => {}, + writeFileSync: () => {}, + existsSync: () => true, + unlinkSync: () => { + serverJsonRemoved = true; + }, + }, + }; + + let manager = createServerManagerWithDeps( + { server: { port: 47392 } }, + {}, + mockDeps + ); + + await manager.start('build-123', true, false); + await manager.stop(); + + assert.strictEqual(httpServerStopped, true); + assert.strictEqual(handlerCleaned, true); + assert.strictEqual(serverJsonRemoved, true); + }); + + it('handles stop before start gracefully', async () => { + let mockDeps = { + createHttpServer: () => ({}), + createTddHandler: () => ({}), + createApiHandler: () => ({}), + createApiClient: () => ({}), + fs: { + mkdirSync: () => {}, + writeFileSync: () => {}, + existsSync: () => false, + unlinkSync: () => {}, + }, + }; + + let manager = createServerManagerWithDeps( + { server: { port: 47392 } }, + {}, + mockDeps + ); + + // Should not throw + await manager.stop(); + }); + }); + + describe('getTddResults', () => { + it('returns results from TDD handler', async () => { + let mockResults = { total: 10, passed: 8, failed: 2 }; + + let mockHandler = { + initialize: async () => {}, + tddService: {}, + getResults: async () => mockResults, + }; + + let mockDeps = { + createHttpServer: () => ({ + start: async () => {}, + }), + createTddHandler: () => mockHandler, + createApiHandler: () => ({}), + createApiClient: () => ({}), + fs: { + mkdirSync: () => {}, + writeFileSync: () => {}, + }, + }; + + let manager = createServerManagerWithDeps( + { server: { port: 47392 } }, + {}, + mockDeps + ); + + await manager.start('build-123', true, false); + let results = await manager.getTddResults(); + + assert.deepStrictEqual(results, mockResults); + }); + + it('returns null before server is started', async () => { + let manager = createServerManager({ server: { port: 47392 } }); + + let results = await manager.getTddResults(); + + assert.strictEqual(results, null); + }); + }); + + describe('server getter', () => { + it('returns server interface after start', async () => { + let mockHandler = { + initialize: async () => {}, + tddService: {}, + getScreenshotCount: () => 5, + }; + + let mockHttpServer = { + start: async () => {}, + finishBuild: () => ({ id: 'finished' }), + }; + + let mockDeps = { + createHttpServer: () => mockHttpServer, + createTddHandler: () => mockHandler, + createApiHandler: () => ({}), + createApiClient: () => ({}), + fs: { + mkdirSync: () => {}, + writeFileSync: () => {}, + }, + }; + + let manager = createServerManagerWithDeps( + { server: { port: 47392 } }, + {}, + mockDeps + ); + + await manager.start('build-123', true, false); + let server = manager.server; + + assert.strictEqual(server.getScreenshotCount('any'), 5); + assert.deepStrictEqual(server.finishBuild('build-123'), { + id: 'finished', + }); + }); + + it('returns interface with defaults before start', () => { + let manager = createServerManager({ server: { port: 47392 } }); + let server = manager.server; + + assert.strictEqual(server.getScreenshotCount('build-123'), 0); + assert.strictEqual(server.finishBuild('build-123'), undefined); + }); + }); + }); +}); + +/** + * Helper to create a server manager with injectable dependencies. + * This mirrors the internal structure of createServerManager but allows + * us to inject mocks for testing. + */ +function createServerManagerWithDeps(config, services, deps) { + let httpServer = null; + let handler = null; + + return { + async start(buildId, tddMode, setBaseline) { + let result = await startServer({ + config, + buildId, + tddMode, + setBaseline, + projectRoot: process.cwd(), + services, + deps, + }); + httpServer = result.httpServer; + handler = result.handler; + }, + + async stop() { + await stopServer({ + httpServer, + handler, + projectRoot: process.cwd(), + deps, + }); + }, + + async getTddResults() { + return getTddResults({ tddMode: true, handler }); + }, + + get server() { + return buildServerInterface({ handler, httpServer }); + }, + }; +}