diff --git a/angular.json b/angular.json index 2d1a710..11e544d 100644 --- a/angular.json +++ b/angular.json @@ -58,6 +58,10 @@ "options": { "tsConfig": "tsconfig.spec.json", "karmaConfig": "karma.conf.cjs", + "polyfills": [ + "zone.js", + "zone.js/testing" + ], "include": [ "src/**/*.spec.ts" ], diff --git a/package-lock.json b/package-lock.json index eba90ee..55316c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,8 @@ "postcss": "^8.4.49", "ts-node": "^10.9.2", "typescript": "~5.8.2", - "vite": "^6.2.0" + "vite": "^6.2.0", + "zone.js": "^0.15.1" } }, "node_modules/@algolia/abtesting": { @@ -18132,6 +18133,13 @@ "peerDependencies": { "zod": "^3.24.1" } + }, + "node_modules/zone.js": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", + "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", + "devOptional": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index d079155..640d6a8 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "postcss": "^8.4.49", "ts-node": "^10.9.2", "typescript": "~5.8.2", - "vite": "^6.2.0" + "vite": "^6.2.0", + "zone.js": "^0.15.1" }, "resolutions": { "@angular/core": "20.3.2", diff --git a/src/core/logging/log.sender.spec.ts b/src/core/logging/log.sender.spec.ts index 59b9251..8491495 100644 --- a/src/core/logging/log.sender.spec.ts +++ b/src/core/logging/log.sender.spec.ts @@ -17,24 +17,44 @@ describe('sendBatch', () => { data: {}, }; + let originalFetch: typeof fetch | undefined; + let fetchSpy: (jasmine.Spy & typeof fetch) | undefined; + + beforeAll(() => { + originalFetch = globalThis.fetch; + }); + beforeEach(() => { - // Mock fetch - global.fetch = jest.fn(); + fetchSpy = jasmine.createSpy('fetch') as jasmine.Spy & typeof fetch; + (globalThis as { fetch: typeof fetch }).fetch = fetchSpy; }); afterEach(() => { - jest.restoreAllMocks(); + fetchSpy = undefined; + if (originalFetch) { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + }); + + afterAll(() => { + if (originalFetch) { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } }); it('should send single record', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - status: 200, - }); + fetchSpy!.and.returnValue( + Promise.resolve({ + ok: true, + status: 200, + } as Response) + ); await sendBatch([mockRecord], '/api/log'); - expect(global.fetch).toHaveBeenCalledWith('/api/log', { + expect(fetchSpy).toHaveBeenCalledWith('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -44,15 +64,17 @@ describe('sendBatch', () => { }); it('should send multiple records as array', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - status: 200, - }); + fetchSpy.and.returnValue( + Promise.resolve({ + ok: true, + status: 200, + } as Response) + ); const records = [mockRecord, { ...mockRecord, event: 'NAVIGATE' as const }]; await sendBatch(records, '/api/log'); - expect(global.fetch).toHaveBeenCalledWith('/api/log', { + expect(fetchSpy).toHaveBeenCalledWith('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -62,26 +84,38 @@ describe('sendBatch', () => { }); it('should throw on HTTP error', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }); - - await expect(sendBatch([mockRecord], '/api/log')).rejects.toThrow( - 'HTTP 500: Internal Server Error' + fetchSpy!.and.returnValue( + Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as Response) ); + + try { + await sendBatch([mockRecord], '/api/log'); + fail('Expected sendBatch to throw on HTTP error'); + } catch (error) { + expect(error).toEqual(jasmine.any(Error)); + expect((error as Error).message).toBe('HTTP 500: Internal Server Error'); + } }); it('should throw on network error', async () => { - (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + fetchSpy!.and.returnValue(Promise.reject(new Error('Network error'))); - await expect(sendBatch([mockRecord], '/api/log')).rejects.toThrow('Network error'); + try { + await sendBatch([mockRecord], '/api/log'); + fail('Expected sendBatch to throw on network error'); + } catch (error) { + expect(error).toEqual(jasmine.any(Error)); + expect((error as Error).message).toBe('Network error'); + } }); it('should not send empty batch', async () => { await sendBatch([], '/api/log'); - expect(global.fetch).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/core/logging/log.service.spec.ts b/src/core/logging/log.service.spec.ts index b76213a..dcdb91a 100644 --- a/src/core/logging/log.service.spec.ts +++ b/src/core/logging/log.service.spec.ts @@ -1,17 +1,45 @@ import { TestBed } from '@angular/core/testing'; +import { PLATFORM_ID } from '@angular/core'; import { LogService } from './log.service'; import { environment } from './environment'; describe('LogService', () => { let service: LogService; + let originalOnLine: boolean | undefined; + let originalRequestIdle: (typeof window)[keyof typeof window] | undefined; beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(LogService); - - // Clear localStorage and sessionStorage + // Clear storages BEFORE creating the service instance localStorage.clear(); sessionStorage.clear(); + + // Make sure env won't trigger immediate flushes during assertions + environment.logging.enabled = true; + environment.logging.batchSize = 9999; // avoid immediate batch flush + environment.logging.debounceMs = 60 * 60 * 1000; // 1h to avoid timer firing + + // Stub navigator.onLine and requestIdleCallback for deterministic behavior + originalOnLine = navigator.onLine; + Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }); + originalRequestIdle = (window as any).requestIdleCallback; + (window as any).requestIdleCallback = (cb: Function) => cb(); + + TestBed.configureTestingModule({ + providers: [{ provide: PLATFORM_ID, useValue: 'browser' }], + }); + service = TestBed.inject(LogService); + }); + + afterEach(() => { + // Restore stubs + if (originalOnLine !== undefined) { + Object.defineProperty(navigator, 'onLine', { value: originalOnLine, configurable: true }); + } + if (originalRequestIdle) { + (window as any).requestIdleCallback = originalRequestIdle; + } else { + delete (window as any).requestIdleCallback; + } }); it('should be created', () => { @@ -61,12 +89,13 @@ describe('LogService', () => { it('should not log when disabled', () => { const originalEnabled = environment.logging.enabled; - + environment.logging.enabled = false; + service.log('APP_START'); - + const queue = (service as any).queue; expect(queue.length).toBe(0); - + environment.logging.enabled = originalEnabled; }); @@ -95,10 +124,9 @@ describe('LogService', () => { ]; localStorage.setItem('obsiviewer.log.queue', JSON.stringify(mockQueue)); - - // Create new service instance - const newService = TestBed.inject(LogService); - const queue = (newService as any).queue; + // Trigger loading using the existing instance + (service as any).loadQueueFromStorage(); + const queue = (service as any).queue; expect(queue.length).toBe(1); expect(queue[0].event).toBe('APP_START'); diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 0000000..7d45035 --- /dev/null +++ b/src/test.ts @@ -0,0 +1,13 @@ +import 'zone.js'; +import 'zone.js/testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +// Initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), +); diff --git a/tsconfig.json b/tsconfig.json index d99ce3b..15456d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,8 +38,9 @@ "./src/**/*.d.ts" ], "exclude": [ - - "node_modules" + "node_modules", + "./src/**/*.spec.ts", + "./src/**/*.spec.tsx" ], "angularCompilerOptions": { "disableTypeScriptVersionCheck": true