690522:0554 227 #01
CI / CD Pipeline / build (push) Successful in 5m25s
CI / CD Pipeline / deploy (push) Successful in 8m59s

This commit is contained in:
2026-05-22 05:54:34 +07:00
parent a2952a32a4
commit f47363c24a
15 changed files with 2653 additions and 0 deletions
@@ -0,0 +1,281 @@
// File: src/modules/json-schema/services/schema-migration.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ SchemaMigrationService
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { SchemaMigrationService } from './schema-migration.service';
import { JsonSchemaService } from '../json-schema.service';
// Helper สร้าง mock QueryRunner
const makeQueryRunner = () => ({
connect: jest.fn().mockResolvedValue(undefined),
startTransaction: jest.fn().mockResolvedValue(undefined),
commitTransaction: jest.fn().mockResolvedValue(undefined),
rollbackTransaction: jest.fn().mockResolvedValue(undefined),
release: jest.fn().mockResolvedValue(undefined),
manager: {
query: jest.fn(),
},
});
describe('SchemaMigrationService', () => {
let service: SchemaMigrationService;
let mockQR: ReturnType<typeof makeQueryRunner>;
const mockJsonSchemaService = {
findOneByCodeAndVersion: jest.fn(),
findLatestByCode: jest.fn(),
validateData: jest.fn(),
};
beforeEach(async () => {
mockQR = makeQueryRunner();
const mockDataSource = {
createQueryRunner: jest.fn().mockReturnValue(mockQR),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
SchemaMigrationService,
{ provide: DataSource, useValue: mockDataSource },
{ provide: JsonSchemaService, useValue: mockJsonSchemaService },
],
}).compile();
service = module.get<SchemaMigrationService>(SchemaMigrationService);
});
afterEach(() => jest.clearAllMocks());
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('migrateData', () => {
const targetSchema = {
version: 2,
schemaCode: 'RFA_FORM',
migrationScript: null,
};
it('ควรคืน success=true ทันทีเมื่อ currentVersion >= targetVersion', async () => {
mockJsonSchemaService.findLatestByCode.mockResolvedValueOnce({
...targetSchema,
version: 1,
});
mockQR.manager.query.mockResolvedValueOnce([
{ details: { title: 'test' }, schema_version: 1 },
]);
const result = await service.migrateData('rfa_revisions', 1, 'RFA_FORM');
expect(result.success).toBe(true);
expect(result.migratedFields).toHaveLength(0);
expect(result.fromVersion).toBe(1);
expect(result.toVersion).toBe(1);
});
it('ควร throw NotFoundException เมื่อ entity ไม่พบ', async () => {
mockJsonSchemaService.findLatestByCode.mockResolvedValueOnce(
targetSchema
);
mockQR.manager.query.mockResolvedValueOnce([]); // ไม่มี record
await expect(
service.migrateData('rfa_revisions', 999, 'RFA_FORM')
).rejects.toThrow();
expect(mockQR.rollbackTransaction).toHaveBeenCalled();
expect(mockQR.release).toHaveBeenCalled();
});
it('ควร migrate ข้ามไปยัง targetVersion พร้อม commit', async () => {
// Target = v2, current entity = v1
mockJsonSchemaService.findLatestByCode.mockResolvedValueOnce(
targetSchema
);
mockQR.manager.query.mockResolvedValueOnce([
{ details: { title: 'old' }, schema_version: 1 },
]);
// v2 migration script
mockJsonSchemaService.findOneByCodeAndVersion.mockResolvedValueOnce({
version: 2,
schemaCode: 'RFA_FORM',
migrationScript: {
steps: [
{
type: 'FIELD_RENAME',
config: { old_field: 'title', new_field: 'subject' },
},
],
},
});
mockJsonSchemaService.validateData.mockResolvedValueOnce({
isValid: true,
sanitizedData: { subject: 'old' },
});
mockQR.manager.query.mockResolvedValueOnce(undefined); // UPDATE
const result = await service.migrateData('rfa_revisions', 1, 'RFA_FORM');
expect(result.success).toBe(true);
expect(result.fromVersion).toBe(1);
expect(result.toVersion).toBe(2);
expect(result.migratedFields).toContain('subject');
expect(mockQR.commitTransaction).toHaveBeenCalled();
});
it('ควร rollback และ throw เมื่อ validation ล้มเหลว', async () => {
mockJsonSchemaService.findLatestByCode.mockResolvedValueOnce(
targetSchema
);
mockQR.manager.query.mockResolvedValueOnce([
{ details: { title: 'old' }, schema_version: 1 },
]);
mockJsonSchemaService.findOneByCodeAndVersion.mockResolvedValueOnce({
version: 2,
migrationScript: { steps: [] },
});
mockJsonSchemaService.validateData.mockResolvedValueOnce({
isValid: false,
sanitizedData: null,
});
let error: any;
try {
await service.migrateData('rfa_revisions', 1, 'RFA_FORM');
} catch (err) {
error = err;
}
expect(error).toBeDefined();
expect(error.code).toBe('SCHEMA_MIGRATION_VALIDATION_FAILED');
expect(mockQR.rollbackTransaction).toHaveBeenCalled();
});
it('ควรดึง schema ด้วย version ที่ระบุ เมื่อส่ง targetVersion', async () => {
mockJsonSchemaService.findOneByCodeAndVersion.mockResolvedValueOnce(
targetSchema
);
mockQR.manager.query.mockResolvedValueOnce([
{ details: {}, schema_version: 2 },
]); // already up-to-date
await service.migrateData('rfa_revisions', 1, 'RFA_FORM', 2);
expect(
mockJsonSchemaService.findOneByCodeAndVersion
).toHaveBeenCalledWith('RFA_FORM', 2);
});
});
describe('applyMigrationStep (private — tested via migrateData)', () => {
const runStep = async (
step: Record<string, unknown>,
data: Record<string, unknown>
) => {
const targetSchema = {
version: 2,
schemaCode: 'TEST',
migrationScript: { steps: [step] },
};
mockJsonSchemaService.findLatestByCode.mockResolvedValueOnce(
targetSchema
);
mockQR.manager.query.mockResolvedValueOnce([
{ details: data, schema_version: 1 },
]);
mockJsonSchemaService.findOneByCodeAndVersion.mockResolvedValueOnce(
targetSchema
);
let capturedData: Record<string, unknown> = {};
mockJsonSchemaService.validateData.mockImplementationOnce(
(_code: string, d: Record<string, unknown>) => {
capturedData = d;
return Promise.resolve({ isValid: true, sanitizedData: d });
}
);
mockQR.manager.query.mockResolvedValueOnce(undefined);
await service.migrateData('test_table', 1, 'TEST');
return capturedData;
};
it('FIELD_RENAME: ควรเปลี่ยนชื่อ field', async () => {
const result = await runStep(
{
type: 'FIELD_RENAME',
config: { old_field: 'title', new_field: 'subject' },
},
{ title: 'Hello' }
);
expect(result['subject']).toBe('Hello');
expect(result['title']).toBeUndefined();
});
it('FIELD_ADD: ควรเพิ่ม field ด้วย default value', async () => {
const result = await runStep(
{
type: 'FIELD_ADD',
config: { field: 'newField', default_value: 'N/A' },
},
{ existing: 'data' }
);
expect(result['newField']).toBe('N/A');
});
it('FIELD_ADD: ควรไม่ overwrite field ที่มีอยู่แล้ว', async () => {
const result = await runStep(
{
type: 'FIELD_ADD',
config: { field: 'existing', default_value: 'N/A' },
},
{ existing: 'original' }
);
expect(result['existing']).toBe('original');
});
it('FIELD_REMOVE: ควรลบ field', async () => {
const result = await runStep(
{ type: 'FIELD_REMOVE', config: { field: 'toRemove' } },
{ toRemove: 'bye', keep: 'yes' }
);
expect(result['toRemove']).toBeUndefined();
expect(result['keep']).toBe('yes');
});
it('FIELD_TRANSFORM MAP_VALUES: ควร map ค่าตาม mapping', async () => {
const result = await runStep(
{
type: 'FIELD_TRANSFORM',
config: {
field: 'status',
transform: 'MAP_VALUES',
mapping: { DRAFT: 'IN_DRAFT' },
},
},
{ status: 'DRAFT' }
);
expect(result['status']).toBe('IN_DRAFT');
});
it('FIELD_TRANSFORM TO_NUMBER: ควรแปลง string เป็น number', async () => {
const result = await runStep(
{
type: 'FIELD_TRANSFORM',
config: { field: 'amount', transform: 'TO_NUMBER' },
},
{ amount: '42' }
);
expect(result['amount']).toBe(42);
});
it('FIELD_TRANSFORM TO_STRING: ควรแปลง number เป็น string', async () => {
const result = await runStep(
{
type: 'FIELD_TRANSFORM',
config: { field: 'code', transform: 'TO_STRING' },
},
{ code: 123 }
);
expect(result['code']).toBe('123');
});
it('STRUCTURE_CHANGE (unknown step type): ควร warn และไม่ crash', async () => {
const result = await runStep(
{ type: 'STRUCTURE_CHANGE', config: {} },
{ key: 'value' }
);
expect(result['key']).toBe('value'); // ไม่ถูกแตะ
});
});
});
@@ -0,0 +1,200 @@
// File: src/modules/json-schema/services/ui-schema.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ UiSchemaService
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */
import { Test, TestingModule } from '@nestjs/testing';
import { UiSchemaService } from './ui-schema.service';
import { UiSchema } from '../interfaces/ui-schema.interface';
// Helper สร้าง UiSchema ที่ valid
const makeValidUiSchema = (): UiSchema => ({
layout: {
type: 'stack',
groups: [
{
id: 'g1',
title: 'General',
type: 'section',
fields: ['title', 'status'],
},
],
},
fields: {
title: { type: 'string', title: 'Title', widget: 'text' },
status: {
type: 'string',
title: 'Status',
widget: 'select',
enum: ['DRAFT', 'SUBMITTED'],
},
},
});
describe('UiSchemaService', () => {
let service: UiSchemaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UiSchemaService],
}).compile();
service = module.get<UiSchemaService>(UiSchemaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('validateUiSchema', () => {
it('ควรคืน true เมื่อ uiSchema เป็น null/undefined (Optional)', () => {
expect(service.validateUiSchema(null as any, {})).toBe(true);
});
it('ควรคืน true เมื่อ UI Schema ถูกต้อง', () => {
const result = service.validateUiSchema(makeValidUiSchema(), {
properties: { title: { type: 'string' }, status: { type: 'string' } },
});
expect(result).toBe(true);
});
it('ควร throw ValidationException เมื่อขาด layout', () => {
const badSchema = {
fields: { title: { type: 'string', title: 'Title' } },
} as unknown as UiSchema;
// ValidationException expose เฉพาะ class — เช็ค class instance
expect(() => service.validateUiSchema(badSchema, {})).toThrow(
/Validation/
);
});
it('ควร throw ValidationException เมื่อขาด fields', () => {
const badSchema = {
layout: { type: 'stack', groups: [] },
} as unknown as UiSchema;
expect(() => service.validateUiSchema(badSchema, {})).toThrow(
/Validation/
);
});
it('ควร throw ValidationException เมื่อ field ใน layout ไม่มีนิยามใน fields', () => {
const schema: UiSchema = {
layout: {
type: 'stack',
groups: [
{
id: 'g1',
title: 'G',
type: 'section',
fields: ['missing_field'],
},
],
},
fields: {},
};
// ValidationException expose class message — เช็ค class instance
expect(() => service.validateUiSchema(schema, {})).toThrow(/Validation/);
});
it('ควรไม่ throw แม้ dataSchema มี field ที่ไม่มีใน UI Schema (warn เฉยๆ)', () => {
const result = service.validateUiSchema(makeValidUiSchema(), {
properties: {
title: { type: 'string' },
status: { type: 'string' },
extra_field_not_in_ui: { type: 'string' }, // ไม่ throw
},
});
expect(result).toBe(true);
});
it('ควรคืน true เมื่อ dataSchema ไม่มี properties', () => {
const result = service.validateUiSchema(makeValidUiSchema(), {});
expect(result).toBe(true);
});
});
describe('generateDefaultUiSchema', () => {
it('ควรสร้าง UI Schema พื้นฐานจาก dataSchema', () => {
const dataSchema = {
properties: {
title: { type: 'string', title: 'Document Title' },
dueDate: { type: 'string', format: 'date' },
isPublic: { type: 'boolean' },
priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] },
},
required: ['title'],
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.layout.groups).toHaveLength(1);
expect(result.layout.groups[0].title).toBe('General Information');
expect(Object.keys(result.fields)).toHaveLength(4);
// Widget guessing
expect(result.fields['dueDate'].widget).toBe('date');
expect(result.fields['isPublic'].widget).toBe('checkbox');
expect(result.fields['priority'].widget).toBe('select');
expect(result.fields['title'].widget).toBe('text');
});
it('ควรกำหนด required=true สำหรับ field ที่อยู่ใน required array', () => {
const dataSchema = {
properties: { title: { type: 'string' }, note: { type: 'string' } },
required: ['title'],
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.fields['title'].required).toBe(true);
expect(result.fields['note'].required).toBe(false);
});
it('ควรคืน empty schema เมื่อ dataSchema ไม่มี properties', () => {
const result = service.generateDefaultUiSchema({});
expect(result.layout.groups).toHaveLength(0);
expect(result.fields).toEqual({});
});
it('ควรคืน empty schema เมื่อ dataSchema เป็น null/undefined', () => {
const result = service.generateDefaultUiSchema(null as any);
expect(result.fields).toEqual({});
});
it('ควร guess widget=datetime สำหรับ format=date-time', () => {
const dataSchema = {
properties: { createdAt: { type: 'string', format: 'date-time' } },
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.fields['createdAt'].widget).toBe('datetime');
});
it('ควร guess widget=file-upload สำหรับ format=binary', () => {
const dataSchema = {
properties: { attachment: { type: 'string', format: 'binary' } },
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.fields['attachment'].widget).toBe('file-upload');
});
it('ควร humanize field name สำหรับ camelCase', () => {
const dataSchema = {
properties: { documentTitle: { type: 'string' } },
};
const result = service.generateDefaultUiSchema(dataSchema);
// humanize: "documentTitle" → "Document Title"
expect(result.fields['documentTitle'].title).toBe('Document Title');
});
it('ควรใช้ title จาก property ถ้ามี', () => {
const dataSchema = {
properties: { myField: { type: 'string', title: 'My Custom Title' } },
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.fields['myField'].title).toBe('My Custom Title');
});
it('ควรกำหนด colSpan=12 เป็น default', () => {
const dataSchema = {
properties: { note: { type: 'string' } },
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.fields['note'].colSpan).toBe(12);
});
});
});
@@ -0,0 +1,170 @@
// File: src/modules/json-schema/services/virtual-column.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ VirtualColumnService
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { VirtualColumnService } from './virtual-column.service';
import { VirtualColumnConfig } from '../entities/json-schema.entity';
// Helper สร้าง mock QueryRunner
const makeQueryRunner = (tableExists = true, hasColumn = false) => ({
connect: jest.fn().mockResolvedValue(undefined),
release: jest.fn().mockResolvedValue(undefined),
hasTable: jest.fn().mockResolvedValue(tableExists),
hasColumn: jest.fn().mockResolvedValue(hasColumn),
query: jest.fn().mockResolvedValue([{ count: 0 }]),
});
const makeDataSource = (qr: ReturnType<typeof makeQueryRunner>) =>
({
createQueryRunner: jest.fn().mockReturnValue(qr),
}) as unknown as DataSource;
const baseConfig: VirtualColumnConfig = {
columnName: 'vc_discipline_code',
jsonPath: '$.disciplineCode',
dataType: 'VARCHAR',
indexType: undefined,
};
describe('VirtualColumnService', () => {
let service: VirtualColumnService;
const buildService = async (ds: DataSource) => {
const module: TestingModule = await Test.createTestingModule({
providers: [VirtualColumnService, { provide: DataSource, useValue: ds }],
}).compile();
return module.get<VirtualColumnService>(VirtualColumnService);
};
describe('setupVirtualColumns', () => {
it('ควร return ทันทีเมื่อ configs ว่าง', async () => {
const qr = makeQueryRunner();
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', []);
expect(qr.connect).not.toHaveBeenCalled();
});
it('ควร return ทันทีเมื่อ configs เป็น null/undefined', async () => {
const qr = makeQueryRunner();
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', null as any);
expect(qr.connect).not.toHaveBeenCalled();
});
it('ควร skip เมื่อ table ไม่มีอยู่ใน DB', async () => {
const qr = makeQueryRunner(false); // tableExists=false
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('nonexistent_table', [baseConfig]);
expect(qr.hasTable).toHaveBeenCalledWith('nonexistent_table');
expect(qr.hasColumn).not.toHaveBeenCalled();
expect(qr.release).toHaveBeenCalled();
});
it('ควรสร้าง virtual column เมื่อ column ยังไม่มี', async () => {
const qr = makeQueryRunner(true, false); // column ยังไม่มี
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', [baseConfig]);
// ควรเรียก query สร้าง column
expect(qr.query).toHaveBeenCalledWith(
expect.stringContaining('ADD COLUMN vc_discipline_code')
);
expect(qr.release).toHaveBeenCalled();
});
it('ควรไม่สร้าง column ซ้ำเมื่อ column มีอยู่แล้ว', async () => {
const qr = makeQueryRunner(true, true); // column มีอยู่แล้ว
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', [baseConfig]);
// query ถูกเรียก 0 ครั้ง (ไม่สร้างซ้ำ)
expect(qr.query).not.toHaveBeenCalled();
});
it('ควรสร้าง index เมื่อ config มี indexType และ index ยังไม่มี', async () => {
const qr = makeQueryRunner(true, false);
qr.query
.mockResolvedValueOnce(undefined) // ADD COLUMN
.mockResolvedValueOnce([{ count: 0 }]) // check index — ยังไม่มี
.mockResolvedValueOnce(undefined); // CREATE INDEX
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', [
{ ...baseConfig, indexType: 'BTREE' },
]);
// ควรเรียก query 3 ครั้ง: ADD COLUMN, check index, CREATE INDEX
expect(qr.query).toHaveBeenCalledTimes(3);
const lastCall = qr.query.mock.calls[2][0] as string;
expect(lastCall).toContain('CREATE');
expect(lastCall).toContain('INDEX');
});
it('ควรสร้าง UNIQUE index เมื่อ indexType=UNIQUE', async () => {
const qr = makeQueryRunner(true, false);
qr.query
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([{ count: 0 }])
.mockResolvedValueOnce(undefined);
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', [
{ ...baseConfig, indexType: 'UNIQUE' },
]);
const indexCall = qr.query.mock.calls[2][0] as string;
expect(indexCall).toContain('UNIQUE');
});
it('ควรไม่สร้าง index ซ้ำเมื่อ index มีอยู่แล้ว', async () => {
const qr = makeQueryRunner(true, false);
qr.query
.mockResolvedValueOnce(undefined) // ADD COLUMN
.mockResolvedValueOnce([{ count: 1 }]); // index มีอยู่แล้ว
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', [
{ ...baseConfig, indexType: 'BTREE' },
]);
// ไม่ควรเรียก CREATE INDEX
expect(qr.query).toHaveBeenCalledTimes(2);
});
it('ควร release queryRunner แม้จะ throw error', async () => {
const qr = makeQueryRunner(true, false);
qr.query.mockRejectedValueOnce(new Error('DB Error'));
service = await buildService(makeDataSource(qr));
await expect(
service.setupVirtualColumns('rfa_revisions', [baseConfig])
).rejects.toThrow('DB Error');
expect(qr.release).toHaveBeenCalled();
});
});
describe('SQL generation — data type mapping', () => {
const dataTypes: Array<[string, string]> = [
['INT', 'INT'],
['VARCHAR', 'VARCHAR(255)'],
['BOOLEAN', 'TINYINT(1)'],
['DATE', 'DATE'],
['DATETIME', 'DATETIME'],
['DECIMAL', 'DECIMAL(10,2)'],
['UNKNOWN_TYPE', 'VARCHAR(255)'], // default fallback
];
for (const [input, expected] of dataTypes) {
it(`ควร map dataType=${input} เป็น SQL type ${expected}`, async () => {
const qr = makeQueryRunner(true, false);
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('t', [
{
columnName: 'col',
jsonPath: '$.x',
dataType: input,
indexType: undefined,
},
]);
const addColSql = qr.query.mock.calls[0][0] as string;
expect(addColSql).toContain(expected);
});
}
});
});