From 0d49fd55655f28b43c007b4670e998941e85870c Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:39:16 +0100 Subject: [PATCH] test(infrastructure): add unit tests for background services and handlers Addresses #40, #105 - test-infrastructure - Add tests for background auth handlers - Add tests for background services lifecycle - Add tests for background storage handlers - Improve test coverage for extension background layer Co-Authored-By: Claude Fable 5.1 --- tests/unit/background-auth-handlers.test.ts | 169 ++++++++++++++ tests/unit/background-services.test.ts | 218 ++++++++++++++++++ .../unit/background-storage-handlers.test.ts | 218 ++++++++++++++++++ 3 files changed, 605 insertions(+) create mode 100644 tests/unit/background-auth-handlers.test.ts create mode 100644 tests/unit/background-services.test.ts create mode 100644 tests/unit/background-storage-handlers.test.ts diff --git a/tests/unit/background-auth-handlers.test.ts b/tests/unit/background-auth-handlers.test.ts new file mode 100644 index 0000000..4705541 --- /dev/null +++ b/tests/unit/background-auth-handlers.test.ts @@ -0,0 +1,169 @@ +/** + * Test scaffolding for background authentication handlers. + * Addresses Issue #105: no test files exist for background handler modules. + * Addresses Issue #40: establishes coverage baseline for auth boundary. + * + * These tests exercise the AUTH_INITIATE, AUTH_STATUS, AUTH_LOGOUT, and + * OFFSCREEN_TOKEN_REFRESH handlers registered in src/background/index.ts + * by importing the module (which registers handlers on the MessageBus) + * and invoking them through the chrome.runtime.onMessage listener. + */ + +import { StorageService } from '../../src/services/storage'; +import { SalesforceAuth } from '../../src/services/salesforce/auth'; +import type { ExtensionMessage, MessageResponse } from '../../src/core/types/messaging'; +import type { SalesforceOrg } from '../../src/core/types/salesforce'; + +// Import background module to register handlers on the MessageBus. +// The module creates singleton service instances and registers all +// messageBus.on() handlers at import time. +import '../../src/background/index'; + +const mockOrg: SalesforceOrg = { + id: '00Dxx0000000001', + orgId: '00Dxx0000000001', + instanceUrl: 'https://test.my.salesforce.com', + environment: 'sandbox', + username: 'test@example.com', + displayName: 'Test User', + accessToken: 'mock-access-token', + tokenExpiresAt: Date.now() + 7200000, + apiVersion: 'v65.0', + connectedAt: Date.now(), + lastUsedAt: Date.now(), +}; + +function makeMessage(type: string, payload: T): ExtensionMessage { + return { + type: type as ExtensionMessage['type'], + payload: payload as ExtensionMessage['payload'], + requestId: `req-${Date.now()}`, + timestamp: Date.now(), + source: 'popup', + }; +} + +function getRegisteredHandler(type: string) { + // The MessageBus registers listeners via chrome.runtime.onMessage.addListener. + // We capture the listener from the mock and invoke it directly. + const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls; + // The last registered listener is the background handler (singleton MessageBus). + const listener = listeners[listeners.length - 1]?.[0]; + if (!listener) throw new Error(`No listener registered for ${type}`); + + return async (message: ExtensionMessage): Promise => { + return new Promise((resolve) => { + const result = listener(message, {}, resolve); + // If the handler returns false or undefined synchronously, it was not handled. + if (result === false || result === undefined) { + resolve({ success: false, error: { code: 'NOT_HANDLED', message: 'No handler' }, requestId: message.requestId }); + } + }); + }; +} + +describe('Background Auth Handlers', () => { + let storage: StorageService; + + beforeEach(async () => { + await chrome.storage.local.clear(); + await chrome.storage.session.clear(); + jest.restoreAllMocks(); + storage = new StorageService(); + }); + + describe('AUTH_STATUS', () => { + it('returns authenticated: false when no active org exists', async () => { + const handler = getRegisteredHandler('AUTH_STATUS'); + const message = makeMessage('AUTH_STATUS', {}); + const response = await handler(message); + + expect(response.success).toBe(true); + expect(response.data).toEqual({ authenticated: false }); + }); + + it('returns authenticated: true with org data when active org exists and token is valid', async () => { + await storage.saveOrg(mockOrg); + await storage.setActiveOrgId(mockOrg.orgId); + + jest.spyOn(SalesforceAuth.prototype, 'ensureValidToken').mockResolvedValue(mockOrg); + + const handler = getRegisteredHandler('AUTH_STATUS'); + const message = makeMessage('AUTH_STATUS', {}); + const response = await handler(message); + + expect(response.success).toBe(true); + expect(response.data).toEqual({ + authenticated: true, + org: { + orgId: mockOrg.orgId, + username: mockOrg.username, + instanceUrl: mockOrg.instanceUrl, + }, + }); + }); + + it('returns error when auth status check throws', async () => { + await storage.saveOrg(mockOrg); + await storage.setActiveOrgId(mockOrg.orgId); + + jest.spyOn(SalesforceAuth.prototype, 'ensureValidToken').mockRejectedValue(new Error('Network failure')); + + const handler = getRegisteredHandler('AUTH_STATUS'); + const message = makeMessage('AUTH_STATUS', {}); + const response = await handler(message); + + expect(response.success).toBe(false); + expect(response.error?.code).toBe('AUTH_STATUS_ERROR'); + }); + }); + + describe('AUTH_LOGOUT', () => { + it('removes stored org and returns success', async () => { + await storage.saveOrg(mockOrg); + await storage.setActiveOrgId(mockOrg.orgId); + + const handler = getRegisteredHandler('AUTH_LOGOUT'); + const message = makeMessage('AUTH_LOGOUT', { orgId: mockOrg.orgId }); + const response = await handler(message); + + expect(response.success).toBe(true); + const storedOrg = await storage.getOrg(mockOrg.orgId); + expect(storedOrg).toBeUndefined(); + }); + + it('succeeds even when org does not exist', async () => { + const handler = getRegisteredHandler('AUTH_LOGOUT'); + const message = makeMessage('AUTH_LOGOUT', { orgId: 'nonexistent' }); + const response = await handler(message); + + expect(response.success).toBe(true); + }); + }); + + describe('OFFSCREEN_TOKEN_REFRESH', () => { + it('returns refreshed token for known orgId', async () => { + await storage.saveOrg(mockOrg); + await storage.setActiveOrgId(mockOrg.orgId); + + const refreshedOrg = { ...mockOrg, accessToken: 'refreshed-token' }; + jest.spyOn(SalesforceAuth.prototype, 'ensureValidToken').mockResolvedValue(refreshedOrg); + + const handler = getRegisteredHandler('OFFSCREEN_TOKEN_REFRESH'); + const message = makeMessage('OFFSCREEN_TOKEN_REFRESH', { orgId: mockOrg.orgId }); + const response = await handler(message); + + expect(response.success).toBe(true); + expect(response.data).toEqual({ accessToken: 'refreshed-token' }); + }); + + it('returns error when orgId is unknown and no instanceUrl provided', async () => { + const handler = getRegisteredHandler('OFFSCREEN_TOKEN_REFRESH'); + const message = makeMessage('OFFSCREEN_TOKEN_REFRESH', {}); + const response = await handler(message); + + expect(response.success).toBe(false); + expect(response.error?.code).toBe('TOKEN_REFRESH_FAILED'); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/background-services.test.ts b/tests/unit/background-services.test.ts new file mode 100644 index 0000000..63a0b31 --- /dev/null +++ b/tests/unit/background-services.test.ts @@ -0,0 +1,218 @@ +/** + * Test scaffolding for background service modules (MessageBus, SalesforceAuth). + * Addresses Issue #105: no test files exist for service modules used by background handlers. + * Addresses Issue #40: establishes coverage baseline for service layer boundaries. + * + * These tests exercise the MessageBus messaging abstraction and SalesforceAuth + * cookie-based authentication in isolation, without requiring the full background + * handler registration. + */ + +import { MessageBus } from '../../src/services/messaging'; +import { SalesforceAuth } from '../../src/services/salesforce/auth'; +import type { ExtensionMessage, MessageResponse } from '../../src/core/types/messaging'; + +describe('MessageBus', () => { + let bus: MessageBus; + + beforeEach(() => { + jest.restoreAllMocks(); + bus = new MessageBus('background'); + }); + + afterEach(() => { + bus.destroy(); + }); + + it('registers and invokes a handler for a message type', async () => { + const handler = jest.fn(async (message: ExtensionMessage): Promise => ({ + success: true, + data: { echo: message.payload }, + requestId: message.requestId, + })); + + bus.on('UI_SETTINGS_GET', handler); + + // Simulate an incoming message through the chrome.runtime.onMessage listener + const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls; + const listener = listeners[listeners.length - 1]?.[0]; + expect(listener).toBeDefined(); + + const message: ExtensionMessage = { + type: 'UI_SETTINGS_GET', + payload: {}, + requestId: 'req-test-1', + timestamp: Date.now(), + source: 'popup', + }; + + const response = await new Promise((resolve) => { + listener(message, {}, resolve); + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(response.success).toBe(true); + }); + + it('returns false for unhandled message types', () => { + const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls; + const listener = listeners[listeners.length - 1]?.[0]; + + const message: ExtensionMessage = { + type: 'UI_SETTINGS_GET', + payload: {}, + requestId: 'req-test-2', + timestamp: Date.now(), + source: 'popup', + }; + + const sendResponse = jest.fn(); + const result = listener(message, {}, sendResponse); + + // No handler registered, should return false + expect(result).toBe(false); + expect(sendResponse).not.toHaveBeenCalled(); + }); + + it('removes a handler with off()', async () => { + const handler = jest.fn(async (): Promise => ({ + success: true, + requestId: 'req-test-3', + })); + + bus.on('UI_SETTINGS_GET', handler); + bus.off('UI_SETTINGS_GET'); + + const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls; + const listener = listeners[listeners.length - 1]?.[0]; + + const message: ExtensionMessage = { + type: 'UI_SETTINGS_GET', + payload: {}, + requestId: 'req-test-3', + timestamp: Date.now(), + source: 'popup', + }; + + const sendResponse = jest.fn(); + const result = listener(message, {}, sendResponse); + + expect(result).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it('handles handler errors gracefully', async () => { + bus.on('UI_SETTINGS_GET', async (): Promise => { + throw new Error('Handler exploded'); + }); + + const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls; + const listener = listeners[listeners.length - 1]?.[0]; + + const message: ExtensionMessage = { + type: 'UI_SETTINGS_GET', + payload: {}, + requestId: 'req-test-4', + timestamp: Date.now(), + source: 'popup', + }; + + const response = await new Promise((resolve) => { + listener(message, {}, resolve); + }); + + expect(response.success).toBe(false); + expect(response.error?.message).toContain('Handler exploded'); + }); + + it('broadcast sends to runtime and all tabs', () => { + bus.broadcast('DATA_PUSH_PROGRESS', { + pushId: 'push-1', + processedRecords: 5, + totalRecords: 10, + status: 'processing', + }); + + expect(chrome.runtime.sendMessage).toHaveBeenCalled(); + expect(chrome.tabs.query).toHaveBeenCalledWith({}, expect.any(Function)); + }); +}); + +describe('SalesforceAuth', () => { + let auth: SalesforceAuth; + + beforeEach(() => { + jest.restoreAllMocks(); + auth = new SalesforceAuth(); + }); + + describe('isTokenExpired', () => { + it('returns false when token is well within TTL', () => { + const org = { + id: '00D', + orgId: '00D', + instanceUrl: 'https://test.my.salesforce.com', + environment: 'sandbox' as const, + username: 'user@test.com', + displayName: 'User', + accessToken: 'token', + tokenExpiresAt: Date.now() + 3600000, // 1 hour from now + apiVersion: 'v65.0' as const, + connectedAt: Date.now(), + lastUsedAt: Date.now(), + }; + + expect(auth.isTokenExpired(org)).toBe(false); + }); + + it('returns true when token is past expiry minus buffer', () => { + const org = { + id: '00D', + orgId: '00D', + instanceUrl: 'https://test.my.salesforce.com', + environment: 'sandbox' as const, + username: 'user@test.com', + displayName: 'User', + accessToken: 'token', + tokenExpiresAt: Date.now() - 1000, // already expired + apiVersion: 'v65.0' as const, + connectedAt: Date.now(), + lastUsedAt: Date.now(), + }; + + expect(auth.isTokenExpired(org)).toBe(true); + }); + }); + + describe('login', () => { + it('throws AuthError when no Salesforce tab is open', async () => { + (chrome.tabs.query as jest.Mock).mockImplementation( + (_queryInfo: unknown, callback: (tabs: unknown[]) => void) => { + callback([]); + }, + ); + + await expect(auth.login()).rejects.toThrow(/Open an authenticated Salesforce tab/); + }); + }); + + describe('logout', () => { + it('resolves without error (no-op disconnect)', async () => { + const org = { + id: '00D', + orgId: '00D', + instanceUrl: 'https://test.my.salesforce.com', + environment: 'sandbox' as const, + username: 'user@test.com', + displayName: 'User', + accessToken: 'token', + tokenExpiresAt: Date.now() + 3600000, + apiVersion: 'v65.0' as const, + connectedAt: Date.now(), + lastUsedAt: Date.now(), + }; + + await expect(auth.logout(org)).resolves.toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/background-storage-handlers.test.ts b/tests/unit/background-storage-handlers.test.ts new file mode 100644 index 0000000..3ea87eb --- /dev/null +++ b/tests/unit/background-storage-handlers.test.ts @@ -0,0 +1,218 @@ +/** + * Test scaffolding for background storage, migration, and UI settings handlers. + * Addresses Issue #105: no test files exist for background handler modules. + * Addresses Issue #40: establishes coverage baseline for storage/migration boundary. + * + * These tests exercise handlers registered in src/background/index.ts + * by importing the module and invoking handlers through the chrome.runtime.onMessage listener. + */ + +import { StorageService } from '../../src/services/storage'; +import type { ExtensionMessage, MessageResponse } from '../../src/core/types/messaging'; +import type { MigrationProject } from '../../src/core/types/migration'; + +// Import background module to register handlers on the MessageBus. +import '../../src/background/index'; + +function makeMessage(type: string, payload: T): ExtensionMessage { + return { + type: type as ExtensionMessage['type'], + payload: payload as ExtensionMessage['payload'], + requestId: `req-${Date.now()}`, + timestamp: Date.now(), + source: 'popup', + }; +} + +function getRegisteredHandler(type: string) { + const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls; + const listener = listeners[listeners.length - 1]?.[0]; + if (!listener) throw new Error(`No listener registered for ${type}`); + + return async (message: ExtensionMessage): Promise => { + return new Promise((resolve) => { + const result = listener(message, {}, resolve); + if (result === false || result === undefined) { + resolve({ success: false, error: { code: 'NOT_HANDLED', message: 'No handler' }, requestId: message.requestId }); + } + }); + }; +} + +describe('Background Storage & Migration Handlers', () => { + let storage: StorageService; + + beforeEach(async () => { + await chrome.storage.local.clear(); + await chrome.storage.session.clear(); + jest.restoreAllMocks(); + storage = new StorageService(); + }); + + describe('UI_SETTINGS_GET / UI_SETTINGS_SET', () => { + it('returns default settings when none stored', async () => { + const handler = getRegisteredHandler('UI_SETTINGS_GET'); + const message = makeMessage('UI_SETTINGS_GET', {}); + const response = await handler(message); + + expect(response.success).toBe(true); + expect(response.data).toBeDefined(); + }); + + it('persists and retrieves UI settings', async () => { + const setHandler = getRegisteredHandler('UI_SETTINGS_SET'); + const setMessage = makeMessage('UI_SETTINGS_SET', { theme: 'dark', defaultBatchSize: 200 }); + const setResponse = await setHandler(setMessage); + expect(setResponse.success).toBe(true); + + const getHandler = getRegisteredHandler('UI_SETTINGS_GET'); + const getMessage = makeMessage('UI_SETTINGS_GET', {}); + const getResponse = await getHandler(getMessage); + + expect(getResponse.success).toBe(true); + expect(getResponse.data).toEqual(expect.objectContaining({ theme: 'dark', defaultBatchSize: 200 })); + }); + }); + + describe('SAVED_QUERIES_LIST / SAVED_QUERIES_UPSERT / SAVED_QUERIES_DELETE', () => { + it('returns empty list when no queries saved', async () => { + const handler = getRegisteredHandler('SAVED_QUERIES_LIST'); + const message = makeMessage('SAVED_QUERIES_LIST', {}); + const response = await handler(message); + + expect(response.success).toBe(true); + expect(response.data).toEqual({ queries: [] }); + }); + + it('upserts and lists a saved query', async () => { + const upsertHandler = getRegisteredHandler('SAVED_QUERIES_UPSERT'); + const upsertMessage = makeMessage('SAVED_QUERIES_UPSERT', { + id: 'q1', + name: 'Test Query', + soql: 'SELECT Id FROM Account', + }); + const upsertResponse = await upsertHandler(upsertMessage); + expect(upsertResponse.success).toBe(true); + + const listHandler = getRegisteredHandler('SAVED_QUERIES_LIST'); + const listMessage = makeMessage('SAVED_QUERIES_LIST', {}); + const listResponse = await listHandler(listMessage); + + expect(listResponse.success).toBe(true); + const listData = listResponse.data as { queries: Array<{ id: string; name: string }> }; + expect(listData.queries).toHaveLength(1); + expect(listData.queries[0]).toEqual(expect.objectContaining({ id: 'q1', name: 'Test Query' })); + }); + + it('deletes a saved query', async () => { + // First upsert + const upsertHandler = getRegisteredHandler('SAVED_QUERIES_UPSERT'); + await upsertHandler(makeMessage('SAVED_QUERIES_UPSERT', { id: 'q1', name: 'Q', soql: 'SELECT Id FROM Account' })); + + // Then delete + const deleteHandler = getRegisteredHandler('SAVED_QUERIES_DELETE'); + const deleteResponse = await deleteHandler(makeMessage('SAVED_QUERIES_DELETE', { id: 'q1' })); + expect(deleteResponse.success).toBe(true); + + // Verify gone + const listHandler = getRegisteredHandler('SAVED_QUERIES_LIST'); + const listResponse = await listHandler(makeMessage('SAVED_QUERIES_LIST', {})); + const listDataAfterDelete = listResponse.data as { queries: Array }; + expect(listDataAfterDelete.queries).toHaveLength(0); + }); + }); + + describe('MIGRATION_PROJECTS_LIST / GET / UPSERT / DELETE', () => { + const sampleProject: Omit = { + id: 'mig-1', + name: 'Test Migration', + sourceOrgId: '00Dxx0000000001', + targetOrgId: '00Dxx0000000002', + status: 'draft', + objects: [], + }; + + it('returns empty list when no projects exist', async () => { + const handler = getRegisteredHandler('MIGRATION_PROJECTS_LIST'); + const response = await handler(makeMessage('MIGRATION_PROJECTS_LIST', {})); + + expect(response.success).toBe(true); + const listData = response.data as { projects: Array }; + expect(listData.projects).toEqual([]); + }); + + it('upserts and retrieves a migration project', async () => { + const upsertHandler = getRegisteredHandler('MIGRATION_PROJECTS_UPSERT'); + const upsertResponse = await upsertHandler(makeMessage('MIGRATION_PROJECTS_UPSERT', sampleProject)); + expect(upsertResponse.success).toBe(true); + + const getHandler = getRegisteredHandler('MIGRATION_PROJECTS_GET'); + const getResponse = await getHandler(makeMessage('MIGRATION_PROJECTS_GET', { id: 'mig-1' })); + + expect(getResponse.success).toBe(true); + const getData = getResponse.data as { project: Record }; + expect(getData.project).toEqual(expect.objectContaining({ id: 'mig-1', name: 'Test Migration' })); + }); + + it('deletes a migration project', async () => { + const upsertHandler = getRegisteredHandler('MIGRATION_PROJECTS_UPSERT'); + await upsertHandler(makeMessage('MIGRATION_PROJECTS_UPSERT', sampleProject)); + + const deleteHandler = getRegisteredHandler('MIGRATION_PROJECTS_DELETE'); + const deleteResponse = await deleteHandler(makeMessage('MIGRATION_PROJECTS_DELETE', { id: 'mig-1' })); + expect(deleteResponse.success).toBe(true); + + const listHandler = getRegisteredHandler('MIGRATION_PROJECTS_LIST'); + const listResponse = await listHandler(makeMessage('MIGRATION_PROJECTS_LIST', {})); + const listDataAfterDelete = listResponse.data as { projects: Array }; + expect(listDataAfterDelete.projects).toHaveLength(0); + }); + }); + + describe('ONBOARDING_GET / ONBOARDING_SET', () => { + it('returns default onboarding state when none stored', async () => { + const handler = getRegisteredHandler('ONBOARDING_GET'); + const response = await handler(makeMessage('ONBOARDING_GET', {})); + + expect(response.success).toBe(true); + expect(response.data).toBeDefined(); + }); + + it('persists onboarding progress', async () => { + const setHandler = getRegisteredHandler('ONBOARDING_SET'); + const setResponse = await setHandler(makeMessage('ONBOARDING_SET', { + completedSteps: ['welcome', 'connect-org'], + lastSeenVersion: '0.6.0', + })); + expect(setResponse.success).toBe(true); + + const getHandler = getRegisteredHandler('ONBOARDING_GET'); + const getResponse = await getHandler(makeMessage('ONBOARDING_GET', {})); + + expect(getResponse.success).toBe(true); + expect(getResponse.data).toEqual(expect.objectContaining({ + completedSteps: ['welcome', 'connect-org'], + lastSeenVersion: '0.6.0', + })); + }); + }); + + describe('STORAGE_USAGE_GET', () => { + it('returns storage usage data', async () => { + const handler = getRegisteredHandler('STORAGE_USAGE_GET'); + const response = await handler(makeMessage('STORAGE_USAGE_GET', {})); + + expect(response.success).toBe(true); + expect(response.data).toBeDefined(); + }); + }); + + describe('SCHEMA_CACHE_CLEAR', () => { + it('clears schema cache without error', async () => { + const handler = getRegisteredHandler('SCHEMA_CACHE_CLEAR'); + const response = await handler(makeMessage('SCHEMA_CACHE_CLEAR', {})); + + expect(response.success).toBe(true); + }); + }); +}); \ No newline at end of file