-
Notifications
You must be signed in to change notification settings - Fork 0
test(infrastructure): add unit tests for background services and handlers #125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T>(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]; | ||
|
Comment on lines
+49
to
+51
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Even after the missing Chrome APIs are stubbed, the last Useful? React with 👍 / 👎. |
||
| if (!listener) throw new Error(`No listener registered for ${type}`); | ||
|
|
||
| return async (message: ExtensionMessage): Promise<MessageResponse> => { | ||
| 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(); | ||
|
Comment on lines
+131
to
+132
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the logout handler successfully removes this org, Useful? React with 👍 / 👎. |
||
| }); | ||
|
|
||
| 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'); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<MessageResponse> => ({ | ||
| 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<MessageResponse>((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<MessageResponse> => ({ | ||
| 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<MessageResponse> => { | ||
| 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<MessageResponse>((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([]); | ||
| }, | ||
|
Comment on lines
+189
to
+192
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| ); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Importing the full background module during Jest evaluation immediately executes
chrome.commands.onCommand.addListenerand laterchrome.alarms.onAlarm.addListener, buttests/mocks/chromeMock.tsdefines neithercommandsnoralarms. Consequently this suite, and the storage-handler suite with the same import, aborts before any test runs with an undefined-property error; extend the Chrome mock or isolate the handler registration from service-worker startup.Useful? React with 👍 / 👎.