Files
trackgo-fe/packages/core-api/src/data-services/base-remote.data-services.test.ts
T
Firman Ramdhani 2b8f9a9cbc feat: add full page index component with pagination and actions
- Implemented FullPagePageIndex component with mock data for database clusters.
- Added pagination and bulk actions for managing database clusters.
- Created navigation context hooks for detail, edit, duplicate, and create actions.
- Introduced a new store for managing state in full-page and single-page modules.

feat: add navigation localization files

- Added English and Indonesian localization files for navigation menu items.
- Included translations for various modules including CRM, Sales, Supply Chain, and more.

feat: create system information shortcuts component

- Developed Shortcut component to display keyboard shortcuts with search functionality.
- Implemented System component to show placeholder information when system details are unavailable.
- Added localization for shortcuts and system information in English and Indonesian.

feat: implement global theme store

- Created a Zustand store for managing theme color scheme with localStorage persistence.

feat: add module page header component

- Developed ModulePageHeader component for consistent page header across modules.
- Included breadcrumb navigation, title, description, and action buttons.

feat: define default privileges for enterprise module

- Established default privileges for CRUD operations and other actions in the enterprise module.
2026-07-10 22:58:55 +07:00

657 lines
23 KiB
TypeScript

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<TestEntity>;
beforeEach(() => {
mockClient = createMockHttpClient();
services = new CommonRemoteDataServices<TestEntity>(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<typeof vi.fn>).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<typeof vi.fn>).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<TestEntity>);
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).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<TestEntity>);
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<TestEntity>(mockClient, {
apiUrl: '/items',
});
await noKeyServices.getMany();
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' },
status: 200,
});
const result = await services.getOne<TestEntity>('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<TestEntity> = {
bookingCode: 'BK001',
customerName: 'Bob',
};
await services.create(newBooking);
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<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();
});
});
});