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:
Firman Ramdhani
2026-07-01 11:13:09 +07:00
parent 7130eb3fe3
commit 1c2090f4fb
11 changed files with 1494 additions and 19 deletions
@@ -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();
});
});
});