From 9cc9864ed9e6014a6cc678f1c5b55fa1012c79eb Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 12 Feb 2026 20:24:51 +0100 Subject: [PATCH] refactor(tests): harmonize UI server tests with codebase style - Add helper functions to reduce duplication (createHttpServerConfig, createLargePayload) - Add waitForStreamFlush() utility for async stream tests - Add GZIP_STREAM_FLUSH_DELAY_MS constant with documentation - Remove low-value constant assertion tests - Add edge case tests for compression boundary and context cleanup - Harmonize test names to 'Verify X' style - Use Reflect.get() instead of unsafe double cast - Replace eslint-disable with proper guard pattern --- .../ui-server/UIHttpServer.test.ts | 155 ++++++++++-------- .../ui-server/UIServerSecurity.test.ts | 119 +++++--------- .../ui-server/UIServerTestConstants.ts | 7 + .../ui-server/UIServerTestUtils.ts | 6 + 4 files changed, 146 insertions(+), 141 deletions(-) diff --git a/tests/charging-station/ui-server/UIHttpServer.test.ts b/tests/charging-station/ui-server/UIHttpServer.test.ts index 5df5f6d1..3c7fabb3 100644 --- a/tests/charging-station/ui-server/UIHttpServer.test.ts +++ b/tests/charging-station/ui-server/UIHttpServer.test.ts @@ -9,27 +9,42 @@ import type { UUIDv4 } from '../../../src/types/index.js' import { UIHttpServer } from '../../../src/charging-station/ui-server/UIHttpServer.js' import { DEFAULT_COMPRESSION_THRESHOLD } from '../../../src/charging-station/ui-server/UIServerSecurity.js' import { ApplicationProtocol, ResponseStatus } from '../../../src/types/index.js' -import { TEST_UUID } from './UIServerTestConstants.js' -import { createMockUIServerConfiguration, MockServerResponse } from './UIServerTestUtils.js' +import { GZIP_STREAM_FLUSH_DELAY_MS, TEST_UUID } from './UIServerTestConstants.js' +import { + createMockUIServerConfiguration, + MockServerResponse, + waitForStreamFlush, +} from './UIServerTestUtils.js' class TestableUIHttpServer extends UIHttpServer { public addResponseHandler (uuid: UUIDv4, res: MockServerResponse): void { this.responseHandlers.set(uuid, res as never) } + public getAcceptsGzip (): Map { + return Reflect.get(this, 'acceptsGzip') as Map + } + public getResponseHandlersSize (): number { return this.responseHandlers.size } public setAcceptsGzip (uuid: UUIDv4, value: boolean): void { - ;(this as unknown as { acceptsGzip: Map }).acceptsGzip.set(uuid, value) + this.getAcceptsGzip().set(uuid, value) } } +const createHttpServerConfig = () => + createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) + +const createLargePayload = (status: ResponseStatus = ResponseStatus.SUCCESS) => ({ + data: 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100), + status, +}) + await describe('UIHttpServer test suite', async () => { await it('Verify sendResponse() deletes handler after sending', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() server.addResponseHandler(TEST_UUID, res) @@ -43,8 +58,7 @@ await describe('UIHttpServer test suite', async () => { }) await it('Verify sendResponse() logs error when handler not found', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + const server = new TestableUIHttpServer(createHttpServerConfig()) server.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }]) @@ -52,8 +66,7 @@ await describe('UIHttpServer test suite', async () => { }) await it('Verify sendResponse() sets correct status code for failure', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() server.addResponseHandler(TEST_UUID, res) @@ -65,8 +78,7 @@ await describe('UIHttpServer test suite', async () => { }) await it('Verify sendResponse() handles send errors gracefully', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() res.end = (): never => { throw new Error('HTTP response end error') @@ -79,8 +91,7 @@ await describe('UIHttpServer test suite', async () => { }) await it('Verify sendResponse() sets correct Content-Type header', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() server.addResponseHandler(TEST_UUID, res) @@ -90,8 +101,7 @@ await describe('UIHttpServer test suite', async () => { }) await it('Verify response handlers cleanup', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + const server = new TestableUIHttpServer(createHttpServerConfig()) const res1 = new MockServerResponse() const res2 = new MockServerResponse() @@ -107,8 +117,7 @@ await describe('UIHttpServer test suite', async () => { }) await it('Verify handlers cleared on server stop', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() server.addResponseHandler(TEST_UUID, res) @@ -120,8 +129,7 @@ await describe('UIHttpServer test suite', async () => { }) await it('Verify response payload serialization', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() const payload = { hashIdsSucceeded: ['station-1', 'station-2'], @@ -132,15 +140,13 @@ await describe('UIHttpServer test suite', async () => { server.sendResponse([TEST_UUID, payload]) expect(res.body).toBeDefined() - // HTTP server sends only the payload, not [uuid, payload] const parsedBody = JSON.parse(res.body ?? '{}') as Record expect(parsedBody.status).toBe('success') expect(parsedBody.hashIdsSucceeded).toEqual(['station-1', 'station-2']) }) await it('Verify response with error details', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() const payload = { errorMessage: 'Test error', @@ -152,7 +158,6 @@ await describe('UIHttpServer test suite', async () => { server.sendResponse([TEST_UUID, payload]) expect(res.body).toBeDefined() - // HTTP server sends only the payload, not [uuid, payload] const parsedBody = JSON.parse(res.body ?? '{}') as Record expect(parsedBody.status).toBe('failure') expect(parsedBody.errorMessage).toBe('Test error') @@ -160,8 +165,7 @@ await describe('UIHttpServer test suite', async () => { }) await it('Verify valid HTTP configuration', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new UIHttpServer(config) + const server = new UIHttpServer(createHttpServerConfig()) expect(server).toBeDefined() }) @@ -180,87 +184,108 @@ await describe('UIHttpServer test suite', async () => { }) await describe('Gzip compression', async () => { - await it('Verify sendResponse() does not compress when acceptsGzip is false', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + await it('Verify no compression when acceptsGzip is false', () => { + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() - const largeData = 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100) - const payload = { - data: largeData, - status: ResponseStatus.SUCCESS, - } server.addResponseHandler(TEST_UUID, res) server.setAcceptsGzip(TEST_UUID, false) - server.sendResponse([TEST_UUID, payload]) + server.sendResponse([TEST_UUID, createLargePayload()]) expect(res.headers['Content-Encoding']).toBeUndefined() expect(res.headers['Content-Type']).toBe('application/json') }) - await it('Verify sendResponse() does not compress small responses', () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + await it('Verify no compression for small responses', () => { + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() - const payload = { - status: ResponseStatus.SUCCESS, - } server.addResponseHandler(TEST_UUID, res) server.setAcceptsGzip(TEST_UUID, true) - server.sendResponse([TEST_UUID, payload]) + server.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }]) expect(res.headers['Content-Encoding']).toBeUndefined() expect(res.headers['Content-Type']).toBe('application/json') }) - await it('Verify sendResponse() compresses large responses when client accepts gzip', async () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + await it('Verify no compression at exact threshold boundary', () => { + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() - const largeData = 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100) - const payload = { - data: largeData, + const basePayload = { data: '', status: ResponseStatus.SUCCESS } + const baseSize = Buffer.byteLength(JSON.stringify(basePayload)) + const paddingNeeded = DEFAULT_COMPRESSION_THRESHOLD - baseSize + const boundaryPayload = { + data: 'x'.repeat(Math.max(0, paddingNeeded)), status: ResponseStatus.SUCCESS, } server.addResponseHandler(TEST_UUID, res) server.setAcceptsGzip(TEST_UUID, true) - server.sendResponse([TEST_UUID, payload]) + server.sendResponse([TEST_UUID, boundaryPayload]) + + expect(res.headers['Content-Encoding']).toBeUndefined() + }) + + await it('Verify compression headers for large responses', async () => { + const server = new TestableUIHttpServer(createHttpServerConfig()) + const res = new MockServerResponse() + + server.addResponseHandler(TEST_UUID, res) + server.setAcceptsGzip(TEST_UUID, true) + server.sendResponse([TEST_UUID, createLargePayload()]) - await new Promise(resolve => { - setTimeout(resolve, 50) - }) + await waitForStreamFlush(GZIP_STREAM_FLUSH_DELAY_MS) expect(res.headers['Content-Encoding']).toBe('gzip') expect(res.headers['Content-Type']).toBe('application/json') expect(res.headers.Vary).toBe('Accept-Encoding') }) - await it('Verify compressed response can be decompressed to original payload', async () => { - const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) - const server = new TestableUIHttpServer(config) + await it('Verify compressed response decompresses to original payload', async () => { + const server = new TestableUIHttpServer(createHttpServerConfig()) const res = new MockServerResponse() - const largeData = 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100) - const payload = { - data: largeData, - status: ResponseStatus.SUCCESS, - } + const payload = createLargePayload() server.addResponseHandler(TEST_UUID, res) server.setAcceptsGzip(TEST_UUID, true) server.sendResponse([TEST_UUID, payload]) - await new Promise(resolve => { - setTimeout(resolve, 50) - }) + await waitForStreamFlush(GZIP_STREAM_FLUSH_DELAY_MS) expect(res.bodyBuffer).toBeDefined() - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const decompressed = gunzipSync(res.bodyBuffer!).toString('utf8') + if (res.bodyBuffer == null) { + throw new Error('Expected bodyBuffer to be defined') + } + const decompressed = gunzipSync(res.bodyBuffer).toString('utf8') const parsedBody = JSON.parse(decompressed) as Record expect(parsedBody.status).toBe('success') - expect(parsedBody.data).toBe(largeData) + expect(parsedBody.data).toBe(payload.data) + }) + + await it('Verify no compression when acceptsGzip context is missing', () => { + const server = new TestableUIHttpServer(createHttpServerConfig()) + const res = new MockServerResponse() + + server.addResponseHandler(TEST_UUID, res) + server.sendResponse([TEST_UUID, createLargePayload()]) + + expect(res.headers['Content-Encoding']).toBeUndefined() + expect(res.headers['Content-Type']).toBe('application/json') + }) + + await it('Verify acceptsGzip context cleanup after response', async () => { + const server = new TestableUIHttpServer(createHttpServerConfig()) + const res = new MockServerResponse() + + server.addResponseHandler(TEST_UUID, res) + server.setAcceptsGzip(TEST_UUID, true) + expect(server.getAcceptsGzip().has(TEST_UUID)).toBe(true) + + server.sendResponse([TEST_UUID, createLargePayload()]) + + await waitForStreamFlush(GZIP_STREAM_FLUSH_DELAY_MS) + + expect(server.getAcceptsGzip().has(TEST_UUID)).toBe(false) }) }) }) diff --git a/tests/charging-station/ui-server/UIServerSecurity.test.ts b/tests/charging-station/ui-server/UIServerSecurity.test.ts index 5c95d292..42d8d574 100644 --- a/tests/charging-station/ui-server/UIServerSecurity.test.ts +++ b/tests/charging-station/ui-server/UIServerSecurity.test.ts @@ -6,165 +6,132 @@ import { describe, it } from 'node:test' import { createBodySizeLimiter, createRateLimiter, - DEFAULT_COMPRESSION_THRESHOLD, - DEFAULT_MAX_PAYLOAD_SIZE, DEFAULT_MAX_STATIONS, - DEFAULT_MAX_TRACKED_IPS, - DEFAULT_RATE_LIMIT, - DEFAULT_RATE_WINDOW, isValidCredential, isValidNumberOfStations, } from '../../../src/charging-station/ui-server/UIServerSecurity.js' +import { waitForStreamFlush } from './UIServerTestUtils.js' + +const RATE_WINDOW_EXPIRY_DELAY_MS = 110 await describe('UIServerSecurity test suite', async () => { await describe('isValidCredential()', async () => { - await it('should return true for matching credentials', () => { - const result = isValidCredential('myPassword123', 'myPassword123') - expect(result).toBe(true) + await it('Verify matching credentials return true', () => { + expect(isValidCredential('myPassword123', 'myPassword123')).toBe(true) }) - await it('should return false for non-matching credentials', () => { - const result = isValidCredential('password1', 'password2') - expect(result).toBe(false) + await it('Verify non-matching credentials return false', () => { + expect(isValidCredential('password1', 'password2')).toBe(false) }) - await it('should handle empty string credentials', () => { - const result = isValidCredential('', '') - expect(result).toBe(true) + await it('Verify empty string credentials match', () => { + expect(isValidCredential('', '')).toBe(true) }) - await it('should handle different length credentials', () => { + await it('Verify different length credentials return false', () => { // cspell:disable-next-line - const result = isValidCredential('short', 'verylongpassword') - expect(result).toBe(false) + expect(isValidCredential('short', 'verylongpassword')).toBe(false) }) }) await describe('createBodySizeLimiter()', async () => { - await it('should return true when accumulated bytes are under limit', () => { + await it('Verify bytes under limit return true', () => { const limiter = createBodySizeLimiter(1000) - const result = limiter(500) - expect(result).toBe(true) + + expect(limiter(500)).toBe(true) }) - await it('should return false when accumulated bytes exceed limit', () => { + await it('Verify accumulated bytes exceeding limit return false', () => { const limiter = createBodySizeLimiter(1000) limiter(600) - const result = limiter(500) - expect(result).toBe(false) + + expect(limiter(500)).toBe(false) }) - await it('should return true at exact limit boundary', () => { + await it('Verify exact limit boundary returns true', () => { const limiter = createBodySizeLimiter(1000) - const result = limiter(1000) - expect(result).toBe(true) + + expect(limiter(1000)).toBe(true) }) }) await describe('createRateLimiter()', async () => { - await it('should allow requests under the limit', () => { + await it('Verify requests under limit are allowed', () => { const limiter = createRateLimiter(5, 1000) + for (let i = 0; i < 5; i++) { expect(limiter('192.168.1.1')).toBe(true) } }) - await it('should block requests that exceed the limit', () => { + await it('Verify requests exceeding limit are blocked', () => { const limiter = createRateLimiter(3, 1000) limiter('192.168.1.1') limiter('192.168.1.1') limiter('192.168.1.1') - const result = limiter('192.168.1.1') - expect(result).toBe(false) + + expect(limiter('192.168.1.1')).toBe(false) }) - await it('should reset window after time expires', async () => { + await it('Verify window resets after time expires', async () => { const limiter = createRateLimiter(2, 100) limiter('10.0.0.1') limiter('10.0.0.1') expect(limiter('10.0.0.1')).toBe(false) - await new Promise(resolve => { - setTimeout(resolve, 110) - }) + await waitForStreamFlush(RATE_WINDOW_EXPIRY_DELAY_MS) expect(limiter('10.0.0.1')).toBe(true) }) - await it('should reject new IPs when at max tracked IPs capacity', () => { + await it('Verify new IPs rejected when at max tracked capacity', () => { const limiter = createRateLimiter(10, 60000, 3) + expect(limiter('192.168.1.1')).toBe(true) expect(limiter('192.168.1.2')).toBe(true) expect(limiter('192.168.1.3')).toBe(true) expect(limiter('192.168.1.4')).toBe(false) }) - await it('should still allow existing IPs when at capacity', () => { + await it('Verify existing IPs still allowed when at capacity', () => { const limiter = createRateLimiter(10, 60000, 2) + expect(limiter('192.168.1.1')).toBe(true) expect(limiter('192.168.1.2')).toBe(true) expect(limiter('192.168.1.1')).toBe(true) expect(limiter('192.168.1.2')).toBe(true) }) - await it('should cleanup expired entries when at capacity', async () => { + await it('Verify expired entries cleanup when at capacity', async () => { const limiter = createRateLimiter(10, 50, 2) expect(limiter('192.168.1.1')).toBe(true) expect(limiter('192.168.1.2')).toBe(true) - await new Promise(resolve => { - setTimeout(resolve, 60) - }) + await waitForStreamFlush(60) expect(limiter('192.168.1.3')).toBe(true) }) }) await describe('isValidNumberOfStations()', async () => { - await it('should return true for valid number of stations', () => { - const result = isValidNumberOfStations(50, DEFAULT_MAX_STATIONS) - expect(result).toBe(true) - }) - - await it('should return false when exceeding max stations', () => { - const result = isValidNumberOfStations(150, DEFAULT_MAX_STATIONS) - expect(result).toBe(false) - }) - - await it('should return false for zero stations', () => { - const result = isValidNumberOfStations(0, DEFAULT_MAX_STATIONS) - expect(result).toBe(false) - }) - - await it('should return false for negative stations', () => { - const result = isValidNumberOfStations(-5, DEFAULT_MAX_STATIONS) - expect(result).toBe(false) - }) - }) - - await describe('Security constants', async () => { - await it('should have correct DEFAULT_MAX_PAYLOAD_SIZE value', () => { - expect(DEFAULT_MAX_PAYLOAD_SIZE).toBe(1048576) // 1MB - }) - - await it('should have correct DEFAULT_RATE_LIMIT value', () => { - expect(DEFAULT_RATE_LIMIT).toBe(100) + await it('Verify valid number of stations returns true', () => { + expect(isValidNumberOfStations(50, DEFAULT_MAX_STATIONS)).toBe(true) }) - await it('should have correct DEFAULT_RATE_WINDOW value', () => { - expect(DEFAULT_RATE_WINDOW).toBe(60000) // 60 seconds + await it('Verify exceeding max stations returns false', () => { + expect(isValidNumberOfStations(150, DEFAULT_MAX_STATIONS)).toBe(false) }) - await it('should have correct DEFAULT_MAX_STATIONS value', () => { - expect(DEFAULT_MAX_STATIONS).toBe(100) + await it('Verify zero stations returns false', () => { + expect(isValidNumberOfStations(0, DEFAULT_MAX_STATIONS)).toBe(false) }) - await it('should have correct DEFAULT_MAX_TRACKED_IPS value', () => { - expect(DEFAULT_MAX_TRACKED_IPS).toBe(10000) + await it('Verify negative stations returns false', () => { + expect(isValidNumberOfStations(-5, DEFAULT_MAX_STATIONS)).toBe(false) }) - await it('should have correct DEFAULT_COMPRESSION_THRESHOLD value', () => { - expect(DEFAULT_COMPRESSION_THRESHOLD).toBe(1024) // 1KB + await it('Verify exact max stations boundary returns true', () => { + expect(isValidNumberOfStations(DEFAULT_MAX_STATIONS, DEFAULT_MAX_STATIONS)).toBe(true) }) }) }) diff --git a/tests/charging-station/ui-server/UIServerTestConstants.ts b/tests/charging-station/ui-server/UIServerTestConstants.ts index 8a2b9958..e95ba4d2 100644 --- a/tests/charging-station/ui-server/UIServerTestConstants.ts +++ b/tests/charging-station/ui-server/UIServerTestConstants.ts @@ -15,3 +15,10 @@ export const TEST_PROCEDURES = { export const TEST_HASH_ID = 'test-station-001' as const export const TEST_HASH_ID_2 = 'test-station-002' as const + +/** + * Delay for gzip stream completion in tests. + * Gzip compression is asynchronous (stream-based), requiring a brief wait + * for the pipe to flush before asserting on the compressed output. + */ +export const GZIP_STREAM_FLUSH_DELAY_MS = 50 diff --git a/tests/charging-station/ui-server/UIServerTestUtils.ts b/tests/charging-station/ui-server/UIServerTestUtils.ts index 6a604123..7f5fa811 100644 --- a/tests/charging-station/ui-server/UIServerTestUtils.ts +++ b/tests/charging-station/ui-server/UIServerTestUtils.ts @@ -222,3 +222,9 @@ export class MockUIServiceNonBroadcast { return Promise.resolve([request[0], { status: ResponseStatus.SUCCESS }]) } } + +export const waitForStreamFlush = async (delayMs: number): Promise => { + await new Promise(resolve => { + setTimeout(resolve, delayMs) + }) +} -- 2.53.0