feat(core-api): implement abstract data transformer architecture
- **Core API**: Introduced `IDataTransformer` interface and `BaseDataTransformer` abstract class. - **Data Services**: Updated `BaseRemoteDataServices` and `DataServicesConfig` to support optional transformer injection. CRUD methods now auto-transform data mapping (DTO <-> Entity) when a transformer is provided. - **Generics**: Updated `CommonRemoteDataServices` to forward the `TDTO` generic for strict type safety. Fixed `ApiResponse<T>` casting and excess property type checks. - **Showcase/Samples**: Created `BookingTransformer` (snake_case to camelCase mapping) and injected it into `booking.data-services.ts`. - **Advanced Showcase**: Added `AdvancedBookingTransformer` and `AdvancedBookingService` to demonstrate extending base transformers with custom methods (e.g., `getAvailabilityChart`). - **Documentation**: Added comprehensive developer guide at `apps/docs-dev/src/packages/core-api/transformers.md`. - **Testing**: Added 16 new unit tests for transformer integrations (identity and mock scenarios). Note: The implementation is 100% backward compatible. All 49/49 tests pass and the TypeScript typecheck is entirely clean.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
@@ -261,3 +263,398 @@ describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 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<TestEntity2, TestDTO> {
|
||||
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<TestEntity2>): Partial<TestDTO> {
|
||||
const dto = super.transformCreatePayload(entity);
|
||||
return { ...dto, id: undefined };
|
||||
}
|
||||
|
||||
override transformEditPayload(entity: Partial<TestEntity2>): Partial<TestDTO> {
|
||||
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(requestArg.data).toEqual(entityData);
|
||||
});
|
||||
});
|
||||
|
||||
// ── With Base Transformer (core methods) ──────────────────────
|
||||
|
||||
describe('with transformer (core transformToEntity / transformToDTO)', () => {
|
||||
let services: CommonRemoteDataServices<TestEntity2, TestDTO>;
|
||||
const transformer = new TestTransformer();
|
||||
|
||||
beforeEach(() => {
|
||||
services = new CommonRemoteDataServices<TestEntity2, TestDTO>(mockClient, {
|
||||
apiUrl: '/bookings',
|
||||
moduleKey: 'BOOKING',
|
||||
transformer,
|
||||
});
|
||||
});
|
||||
|
||||
it('getOne() transforms API DTO to domain entity', async () => {
|
||||
(mockClient.request as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(requestArg.data).toEqual({ items: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ── With Custom Hook Transformer ──────────────────────────────
|
||||
|
||||
describe('with custom operation-specific hooks', () => {
|
||||
let services: CommonRemoteDataServices<TestEntity2, TestDTO>;
|
||||
const transformer = new CustomHookTransformer();
|
||||
|
||||
beforeEach(() => {
|
||||
services = new CommonRemoteDataServices<TestEntity2, TestDTO>(mockClient, {
|
||||
apiUrl: '/bookings',
|
||||
moduleKey: 'BOOKING',
|
||||
transformer,
|
||||
});
|
||||
});
|
||||
|
||||
it('getOne() uses transformGetOneResponse hook (uppercases customer name)', async () => {
|
||||
(mockClient.request as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: rawDTO,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const mockTransformer: IDataTransformer<TestEntity2, TestDTO> = {
|
||||
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<TestEntity2, TestDTO>(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<TestEntity2> = {
|
||||
bookingCode: 'BK001',
|
||||
customerName: 'Alice',
|
||||
};
|
||||
|
||||
const mockTransformer: IDataTransformer<TestEntity2, TestDTO> = {
|
||||
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<TestEntity2, TestDTO>(mockClient, {
|
||||
apiUrl: '/bookings',
|
||||
transformer: mockTransformer,
|
||||
});
|
||||
|
||||
await services.create(entityData);
|
||||
|
||||
expect(mockTransformer.transformCreatePayload).toHaveBeenCalledWith(entityData);
|
||||
expect(mockTransformer.transformToDTO).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user