import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import type { BaseEntity } from './types'; import type { IDataTransformer } from './base-data.transformer'; import { CommonRemoteDataServices } from './common-remote.data-services'; import { BaseDataTransformer } from './base-data.transformer'; // ─── Mock AxiosInstance ───────────────────────────────────────── function createMockHttpClient(): AxiosInstance { return { request: vi.fn().mockResolvedValue({ data: [{ id: '1', name: 'Test' }], status: 200, }), // Satisfy the AxiosInstance shape (unused properties) defaults: {} as AxiosInstance['defaults'], interceptors: { request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() }, response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() }, }, getUri: vi.fn(), get: vi.fn(), delete: vi.fn(), head: vi.fn(), options: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), postForm: vi.fn(), putForm: vi.fn(), patchForm: vi.fn(), } as unknown as AxiosInstance; } // ─── Test Entity ──────────────────────────────────────────────── interface TestEntity extends BaseEntity { bookingCode: string; customerName: string; } // ─── Tests ────────────────────────────────────────────────────── describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => { let mockClient: AxiosInstance; let services: CommonRemoteDataServices; beforeEach(() => { mockClient = createMockHttpClient(); services = new CommonRemoteDataServices(mockClient, { apiUrl: '/bookings', moduleKey: 'BOOKING', }); }); // ── URL Interpolation ───────────────────────────────────────── describe('URL interpolation', () => { it('getMany() uses the base URL without params', async () => { await services.getMany(); expect(mockClient.request).toHaveBeenCalledWith( expect.objectContaining({ url: '/bookings', method: 'GET', }), ); }); it('getOne() replaces :id in the URL template', async () => { await services.getOne('123'); expect(mockClient.request).toHaveBeenCalledWith( expect.objectContaining({ url: '/bookings/123', method: 'GET', }), ); }); it('getOne() encodes special characters in ID', async () => { await services.getOne('hello world'); expect(mockClient.request).toHaveBeenCalledWith( expect.objectContaining({ url: '/bookings/hello%20world', }), ); }); it('activate() resolves to /:id/active', async () => { await services.activate('42'); expect(mockClient.request).toHaveBeenCalledWith( expect.objectContaining({ url: '/bookings/42/active', method: 'PATCH', }), ); }); it('confirmData() resolves to /:id/confirm', async () => { await services.confirmData('99'); expect(mockClient.request).toHaveBeenCalledWith( expect.objectContaining({ url: '/bookings/99/confirm', method: 'PATCH', }), ); }); }); // ── Header Injection ────────────────────────────────────────── describe('header injection', () => { it('injects ex-module-key from moduleKey config', async () => { await services.getMany(); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.headers['ex-module-key']).toBe('BOOKING'); }); it('injects ex-module-action from the descriptor action', async () => { await services.getMany(); // VIEW action const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.headers['ex-module-action']).toBe('VIEW'); }); it('injects CREATE action for create()', async () => { await services.create({ bookingCode: 'BK001', customerName: 'Test' } as Partial); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.headers['ex-module-action']).toBe('CREATE'); }); it('injects EDIT action for edit()', async () => { await services.edit('42', { customerName: 'Updated' } as Partial); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.headers['ex-module-action']).toBe('EDIT'); }); it('injects DELETE action for delete()', async () => { await services.delete('42'); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.headers['ex-module-action']).toBe('DELETE'); }); it('omits ex-module-key when moduleKey is not configured', async () => { const noKeyServices = new CommonRemoteDataServices(mockClient, { apiUrl: '/items', }); await noKeyServices.getMany(); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.headers['ex-module-key']).toBeUndefined(); // But action is always present expect(requestArg.headers['ex-module-action']).toBe('VIEW'); }); it('preserves caller-provided headers alongside injected ones', async () => { await services.getMany({ headers: { 'X-Custom-Header': 'custom-value' }, }); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.headers['ex-module-key']).toBe('BOOKING'); expect(requestArg.headers['ex-module-action']).toBe('VIEW'); expect(requestArg.headers['X-Custom-Header']).toBe('custom-value'); }); }); // ── Telemetry Context ───────────────────────────────────────── describe('telemetry context passthrough', () => { it('passes telemetryContext from getMany() config to the Axios request', async () => { const telemetryContext = { customSpanName: 'booking.list.fetch', tags: { region: 'asia' }, pushEventOnSuccess: 'booking_list_loaded', }; await services.getMany({ telemetryContext } as AxiosRequestConfig); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.telemetryContext).toEqual(telemetryContext); }); it('passes telemetryContext via customRequest()', async () => { const telemetryContext = { customSpanName: 'custom.tax.calculate', tags: { business: 'tax' }, }; await services.customRequest({ url: '/bookings/42/calculate-tax', method: 'POST', data: { items: [] }, telemetryContext, }); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.telemetryContext).toEqual(telemetryContext); }); it('customRequest() still injects ex-module-key header', async () => { await services.customRequest({ url: '/bookings/42/calculate-tax', method: 'POST', }); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.headers['ex-module-key']).toBe('BOOKING'); }); }); // ── Response Shape ──────────────────────────────────────────── describe('response shape', () => { it('returns { data, status } from the Axios response', async () => { (mockClient.request as ReturnType).mockResolvedValueOnce({ data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' }, status: 200, }); const result = await services.getOne('42'); expect(result).toEqual({ data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' }, status: 200, }); }); it('create() passes data in the Axios request config', async () => { const newBooking: Partial = { bookingCode: 'BK001', customerName: 'Bob', }; await services.create(newBooking); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.method).toBe('POST'); expect(requestArg.url).toBe('/bookings'); expect(requestArg.data).toEqual(newBooking); }); }); // ── Batch Operations ────────────────────────────────────────── describe('batch operations', () => { it('batchDelete() sends ids in data payload', async () => { await services.batchDelete(['1', '2', '3']); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.url).toBe('/bookings/batch-delete'); expect(requestArg.data).toEqual({ ids: ['1', '2', '3'] }); }); }); }); // ═══════════════════════════════════════════════════════════════════ // Data Transformer Integration Tests // ═══════════════════════════════════════════════════════════════════ // ─── Test DTO (snake_case API shape) ──────────────────────────── interface TestDTO { id?: string; booking_code: string; customer_name: string; } interface TestEntity2 extends BaseEntity { bookingCode: string; customerName: string; } // ─── Concrete Transformer for Testing ─────────────────────────── class TestTransformer extends BaseDataTransformer { transformToEntity(dto: TestDTO): TestEntity2 { return { id: dto.id, bookingCode: dto.booking_code, customerName: dto.customer_name, }; } transformToDTO(entity: TestEntity2): TestDTO { return { id: entity.id, booking_code: entity.bookingCode, customer_name: entity.customerName, }; } } // ─── Transformer with Custom Hooks ────────────────────────────── class CustomHookTransformer extends TestTransformer { override transformGetOneResponse(dto: TestDTO): TestEntity2 { const entity = this.transformToEntity(dto); return { ...entity, customerName: entity.customerName.toUpperCase() }; } override transformGetManyResponse(dtos: TestDTO[]): TestEntity2[] { return dtos .map((dto) => this.transformToEntity(dto)) .map((entity) => ({ ...entity, bookingCode: `LIST-${entity.bookingCode}` })); } override transformCreatePayload(entity: Partial): Partial { const dto = super.transformCreatePayload(entity); return { ...dto, id: undefined }; } override transformEditPayload(entity: Partial): Partial { const dto = super.transformEditPayload(entity); return { ...dto, booking_code: `EDIT-${dto.booking_code}` }; } } // ─── Transformer Integration Tests ────────────────────────────── describe('BaseRemoteDataServices — Data Transformer Integration', () => { let mockClient: AxiosInstance; beforeEach(() => { mockClient = createMockHttpClient(); }); // ── Without Transformer (backward compatibility) ────────────── describe('without transformer (backward compatibility)', () => { it('getOne() returns raw API response unchanged', async () => { const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' }; (mockClient.request as ReturnType).mockResolvedValueOnce({ data: rawDTO, status: 200, }); const services = new CommonRemoteDataServices(mockClient, { apiUrl: '/bookings', }); const result = await services.getOne('42'); expect(result.data).toEqual(rawDTO); }); it('getMany() returns raw API response unchanged', async () => { const rawDTOs = [ { id: '1', booking_code: 'BK001', customer_name: 'Alice' }, { id: '2', booking_code: 'BK002', customer_name: 'Bob' }, ]; (mockClient.request as ReturnType).mockResolvedValueOnce({ data: rawDTOs, status: 200, }); const services = new CommonRemoteDataServices(mockClient, { apiUrl: '/bookings', }); const result = await services.getMany(); expect(result.data).toEqual(rawDTOs); }); it('create() sends entity data as-is without transformation', async () => { const services = new CommonRemoteDataServices(mockClient, { apiUrl: '/bookings', }); const entityData = { id: 'temp-1' }; await services.create(entityData); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.data).toEqual(entityData); }); }); // ── With Base Transformer (core methods) ────────────────────── describe('with transformer (core transformToEntity / transformToDTO)', () => { let services: CommonRemoteDataServices; const transformer = new TestTransformer(); beforeEach(() => { services = new CommonRemoteDataServices(mockClient, { apiUrl: '/bookings', moduleKey: 'BOOKING', transformer, }); }); it('getOne() transforms API DTO to domain entity', async () => { (mockClient.request as ReturnType).mockResolvedValueOnce({ data: { id: '42', booking_code: 'BK042', customer_name: 'Alice' }, status: 200, }); const result = await services.getOne('42'); expect(result.data).toEqual({ id: '42', bookingCode: 'BK042', customerName: 'Alice', }); expect(result.status).toBe(200); }); it('getMany() transforms each DTO in the array to entities', async () => { (mockClient.request as ReturnType).mockResolvedValueOnce({ data: [ { id: '1', booking_code: 'BK001', customer_name: 'Alice' }, { id: '2', booking_code: 'BK002', customer_name: 'Bob' }, ], status: 200, }); const result = await services.getMany(); expect(result.data).toEqual([ { id: '1', bookingCode: 'BK001', customerName: 'Alice' }, { id: '2', bookingCode: 'BK002', customerName: 'Bob' }, ]); }); it('getMany() handles empty array response', async () => { (mockClient.request as ReturnType).mockResolvedValueOnce({ data: [], status: 200, }); const result = await services.getMany(); expect(result.data).toEqual([]); }); it('create() transforms entity payload to DTO before sending', async () => { await services.create({ bookingCode: 'BK001', customerName: 'Alice', }); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.data).toEqual({ id: undefined, booking_code: 'BK001', customer_name: 'Alice', }); }); it('edit() transforms entity payload to DTO before sending', async () => { await services.edit('42', { bookingCode: 'BK042-UPDATED', customerName: 'Bob', }); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.data).toEqual({ id: undefined, booking_code: 'BK042-UPDATED', customer_name: 'Bob', }); expect(requestArg.url).toBe('/bookings/42'); }); it('delete() is unaffected by transformer (no data transformation needed)', async () => { await services.delete('42'); expect(mockClient.request).toHaveBeenCalledWith( expect.objectContaining({ url: '/bookings/42', method: 'DELETE', }), ); }); it('customRequest() is unaffected by transformer', async () => { await services.customRequest({ url: '/bookings/42/calculate-tax', method: 'POST', data: { items: [] }, }); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.data).toEqual({ items: [] }); }); }); // ── With Custom Hook Transformer ────────────────────────────── describe('with custom operation-specific hooks', () => { let services: CommonRemoteDataServices; const transformer = new CustomHookTransformer(); beforeEach(() => { services = new CommonRemoteDataServices(mockClient, { apiUrl: '/bookings', moduleKey: 'BOOKING', transformer, }); }); it('getOne() uses transformGetOneResponse hook (uppercases customer name)', async () => { (mockClient.request as ReturnType).mockResolvedValueOnce({ data: { id: '42', booking_code: 'BK042', customer_name: 'alice' }, status: 200, }); const result = await services.getOne('42'); expect(result.data).toEqual({ id: '42', bookingCode: 'BK042', customerName: 'ALICE', // uppercased by custom hook }); }); it('getMany() uses transformGetManyResponse hook (prefixes booking code)', async () => { (mockClient.request as ReturnType).mockResolvedValueOnce({ data: [{ id: '1', booking_code: 'BK001', customer_name: 'Alice' }], status: 200, }); const result = await services.getMany(); expect(result.data).toEqual([{ id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' }]); }); it('create() uses transformCreatePayload hook (strips id)', async () => { await services.create({ id: 'should-be-removed', bookingCode: 'BK001', customerName: 'Alice', }); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.data.id).toBeUndefined(); expect(requestArg.data.booking_code).toBe('BK001'); }); it('edit() uses transformEditPayload hook (prefixes booking code)', async () => { await services.edit('42', { bookingCode: 'BK042', customerName: 'Alice', }); const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; expect(requestArg.data.booking_code).toBe('EDIT-BK042'); }); }); // ── Identity Transformer ────────────────────────────────────── describe('with identity transformer (default passthrough)', () => { it('produces same results as no transformer', async () => { const rawData = { id: '1', name: 'Test' }; (mockClient.request as ReturnType).mockResolvedValue({ data: rawData, status: 200, }); // Service without transformer const servicesNoTransformer = new CommonRemoteDataServices(mockClient, { apiUrl: '/items', }); // Service with identity transformer (no method overrides) class IdentityTransformer extends BaseDataTransformer {} const servicesWithIdentity = new CommonRemoteDataServices(mockClient, { apiUrl: '/items', transformer: new IdentityTransformer(), }); const resultWithout = await servicesNoTransformer.getOne('1'); const resultWith = await servicesWithIdentity.getOne('1'); expect(resultWithout.data).toEqual(resultWith.data); }); }); // ── Transformer receives correct arguments ──────────────────── describe('transformer method invocation', () => { it('transformGetOneResponse receives the raw DTO from API', async () => { const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' }; (mockClient.request as ReturnType).mockResolvedValueOnce({ data: rawDTO, status: 200, }); const mockTransformer: IDataTransformer = { transformToEntity: vi.fn((dto: TestDTO) => ({ id: dto.id, bookingCode: dto.booking_code, customerName: dto.customer_name, })), transformToDTO: vi.fn(), transformGetOneResponse: vi.fn((dto: TestDTO) => ({ id: dto.id, bookingCode: dto.booking_code, customerName: dto.customer_name, })), transformGetManyResponse: vi.fn(), transformCreatePayload: vi.fn(), transformEditPayload: vi.fn(), }; const services = new CommonRemoteDataServices(mockClient, { apiUrl: '/bookings', transformer: mockTransformer, }); await services.getOne('42'); expect(mockTransformer.transformGetOneResponse).toHaveBeenCalledWith(rawDTO); expect(mockTransformer.transformToEntity).not.toHaveBeenCalled(); }); it('transformCreatePayload receives the entity data from caller', async () => { const entityData: Partial = { bookingCode: 'BK001', customerName: 'Alice', }; const mockTransformer: IDataTransformer = { transformToEntity: vi.fn(), transformToDTO: vi.fn(), transformGetOneResponse: vi.fn(), transformGetManyResponse: vi.fn(), transformCreatePayload: vi.fn((entity) => ({ booking_code: entity.bookingCode!, customer_name: entity.customerName!, })), transformEditPayload: vi.fn(), }; const services = new CommonRemoteDataServices(mockClient, { apiUrl: '/bookings', transformer: mockTransformer, }); await services.create(entityData); expect(mockTransformer.transformCreatePayload).toHaveBeenCalledWith(entityData); expect(mockTransformer.transformToDTO).not.toHaveBeenCalled(); }); }); });