DB validation improvements
This commit is contained in:
parent
9130efdb6b
commit
a3dfdccd0d
@ -81,6 +81,60 @@ const isStructuredUiSchema = (
|
|||||||
): value is TourPageStructuredUiSchema =>
|
): value is TourPageStructuredUiSchema =>
|
||||||
Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||||
|
|
||||||
|
const normalizeStructuredUiSchema = (
|
||||||
|
value: TourPageStructuredUiSchema,
|
||||||
|
): TourPageStructuredUiSchema => {
|
||||||
|
if (value.elements === undefined) {
|
||||||
|
return { ...value, elements: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(value.elements)) {
|
||||||
|
throw new ValidationError('ui_schema_json.elements must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUiSchemaJson = (
|
||||||
|
uiSchema: TourPageData['ui_schema_json'] | undefined,
|
||||||
|
): TourPageStructuredUiSchema | null => {
|
||||||
|
if (uiSchema === undefined || uiSchema === null) return null;
|
||||||
|
|
||||||
|
if (typeof uiSchema === 'string') {
|
||||||
|
if (!uiSchema.trim()) return null;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = parseUnknownJson(uiSchema);
|
||||||
|
} catch {
|
||||||
|
throw new ValidationError('Invalid ui_schema_json format');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStructuredUiSchema(parsed)) {
|
||||||
|
throw new ValidationError('ui_schema_json must be a JSON object');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeStructuredUiSchema(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStructuredUiSchema(uiSchema)) {
|
||||||
|
throw new ValidationError('ui_schema_json must be a JSON object');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeStructuredUiSchema(uiSchema);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTourPageUiSchemaData = (data: TourPageData): TourPageData => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(data, 'ui_schema_json')) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
ui_schema_json: normalizeUiSchemaJson(data.ui_schema_json),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const parseUiSchema = (
|
const parseUiSchema = (
|
||||||
uiSchema: TourPageData['ui_schema_json'],
|
uiSchema: TourPageData['ui_schema_json'],
|
||||||
): TourPageStructuredUiSchema | string | null => {
|
): TourPageStructuredUiSchema | string | null => {
|
||||||
@ -623,11 +677,12 @@ class TourPagesService extends BaseService {
|
|||||||
): Promise<TourPageRecord> {
|
): Promise<TourPageRecord> {
|
||||||
assertCreateOptions(options, 'Service');
|
assertCreateOptions(options, 'Service');
|
||||||
const { data, currentUser } = options;
|
const { data, currentUser } = options;
|
||||||
|
const normalizedData = normalizeTourPageUiSchemaData(data);
|
||||||
|
|
||||||
// Process reversed videos and get updated ui_schema_json
|
// Process reversed videos and get updated ui_schema_json
|
||||||
const updatedData =
|
const updatedData =
|
||||||
await TourPagesService.processReversedVideosAndUpdateSchema(
|
await TourPagesService.processReversedVideosAndUpdateSchema(
|
||||||
data,
|
normalizedData,
|
||||||
currentUser,
|
currentUser,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -645,6 +700,7 @@ class TourPagesService extends BaseService {
|
|||||||
): Promise<TourPageRecord> {
|
): Promise<TourPageRecord> {
|
||||||
assertUpdateOptions(options, 'Service');
|
assertUpdateOptions(options, 'Service');
|
||||||
const { id, data, currentUser } = options;
|
const { id, data, currentUser } = options;
|
||||||
|
const normalizedData = normalizeTourPageUiSchemaData(data);
|
||||||
|
|
||||||
// Fetch existing page to get projectId (not included in update request body)
|
// Fetch existing page to get projectId (not included in update request body)
|
||||||
const existingPage = await Tour_pagesDBApi.findBy(
|
const existingPage = await Tour_pagesDBApi.findBy(
|
||||||
@ -652,9 +708,11 @@ class TourPagesService extends BaseService {
|
|||||||
buildFindByOptions(options),
|
buildFindByOptions(options),
|
||||||
);
|
);
|
||||||
const projectId =
|
const projectId =
|
||||||
existingPage?.projectId || data.projectId || data.project_id;
|
existingPage?.projectId ||
|
||||||
|
normalizedData.projectId ||
|
||||||
|
normalizedData.project_id;
|
||||||
const dataWithPageContext: TourPageData = {
|
const dataWithPageContext: TourPageData = {
|
||||||
...data,
|
...normalizedData,
|
||||||
id,
|
id,
|
||||||
};
|
};
|
||||||
if (projectId !== undefined) {
|
if (projectId !== undefined) {
|
||||||
@ -726,7 +784,11 @@ class TourPagesService extends BaseService {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!uiSchema?.elements || !Array.isArray(uiSchema.elements)) {
|
if (
|
||||||
|
!uiSchema?.elements ||
|
||||||
|
!Array.isArray(uiSchema.elements) ||
|
||||||
|
uiSchema.elements.length === 0
|
||||||
|
) {
|
||||||
logger.debug({ hasElements: false }, 'No elements in ui_schema_json');
|
logger.debug({ hasElements: false }, 'No elements in ui_schema_json');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import db from '../src/db/models/index.ts';
|
import db from '../src/db/models/index.ts';
|
||||||
import AssetsDBApi from '../src/db/api/assets.ts';
|
import AssetsDBApi from '../src/db/api/assets.ts';
|
||||||
|
import TourPagesDBApi from '../src/db/api/tour_pages.ts';
|
||||||
import AssetsService from '../src/services/assets.ts';
|
import AssetsService from '../src/services/assets.ts';
|
||||||
|
import TourPagesService from '../src/services/tour_pages.ts';
|
||||||
import GenericDBApi from '../src/db/api/base.api.ts';
|
import GenericDBApi from '../src/db/api/base.api.ts';
|
||||||
import ProjectElementDefaultsDBApi from '../src/db/api/project_element_defaults.ts';
|
import ProjectElementDefaultsDBApi from '../src/db/api/project_element_defaults.ts';
|
||||||
import { createEntityService } from '../src/factories/service.factory.ts';
|
import { createEntityService } from '../src/factories/service.factory.ts';
|
||||||
@ -17,6 +19,9 @@ import type {
|
|||||||
EntityServiceDbApi,
|
EntityServiceDbApi,
|
||||||
RuntimeContext,
|
RuntimeContext,
|
||||||
RuntimeEnvironment,
|
RuntimeEnvironment,
|
||||||
|
TourPageCreateOptions,
|
||||||
|
TourPageRecord,
|
||||||
|
TourPageUpdateOptions,
|
||||||
} from '../src/types/index.ts';
|
} from '../src/types/index.ts';
|
||||||
|
|
||||||
interface TestTransactionOptions {
|
interface TestTransactionOptions {
|
||||||
@ -58,6 +63,9 @@ interface UpdateContractCalls {
|
|||||||
serviceDeleteByIds?: unknown;
|
serviceDeleteByIds?: unknown;
|
||||||
serviceRemove?: unknown;
|
serviceRemove?: unknown;
|
||||||
variantSoftDelete?: { payload: DbData; options: unknown };
|
variantSoftDelete?: { payload: DbData; options: unknown };
|
||||||
|
tourPageCreate?: TourPageCreateOptions;
|
||||||
|
tourPageUpdate?: TourPageUpdateOptions;
|
||||||
|
tourPageFindBy?: { where: { id: string }; options: TestTransactionOptions };
|
||||||
associationThis?: TestAssociationRecord;
|
associationThis?: TestAssociationRecord;
|
||||||
committed?: true;
|
committed?: true;
|
||||||
rolledBack?: true;
|
rolledBack?: true;
|
||||||
@ -164,6 +172,63 @@ function replaceAssetsDbApiDeleteByIds(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceTourPagesDbApiCreate(
|
||||||
|
value: (options: TourPageCreateOptions) => Promise<TourPageRecord>,
|
||||||
|
): () => void {
|
||||||
|
const original = TourPagesDBApi.create.bind(TourPagesDBApi);
|
||||||
|
|
||||||
|
Object.defineProperty(TourPagesDBApi, 'create', {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Object.defineProperty(TourPagesDBApi, 'create', {
|
||||||
|
configurable: true,
|
||||||
|
value: original,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceTourPagesDbApiFindBy(
|
||||||
|
value: (
|
||||||
|
where: { id: string },
|
||||||
|
options?: TestTransactionOptions,
|
||||||
|
) => Promise<TourPageRecord | null>,
|
||||||
|
): () => void {
|
||||||
|
const original = TourPagesDBApi.findBy.bind(TourPagesDBApi);
|
||||||
|
|
||||||
|
Object.defineProperty(TourPagesDBApi, 'findBy', {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Object.defineProperty(TourPagesDBApi, 'findBy', {
|
||||||
|
configurable: true,
|
||||||
|
value: original,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceTourPagesDbApiUpdate(
|
||||||
|
value: (options: TourPageUpdateOptions) => Promise<TourPageRecord>,
|
||||||
|
): () => void {
|
||||||
|
const original = TourPagesDBApi.update.bind(TourPagesDBApi);
|
||||||
|
|
||||||
|
Object.defineProperty(TourPagesDBApi, 'update', {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Object.defineProperty(TourPagesDBApi, 'update', {
|
||||||
|
configurable: true,
|
||||||
|
value: original,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createServiceDbApi(
|
function createServiceDbApi(
|
||||||
calls: UpdateContractCalls,
|
calls: UpdateContractCalls,
|
||||||
): EntityServiceDbApi<TestEntity, TestEntityData, TestEntityData> {
|
): EntityServiceDbApi<TestEntity, TestEntityData, TestEntityData> {
|
||||||
@ -587,6 +652,84 @@ void test('AssetsService.deleteByIds soft-deletes variants for deleted assets',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('TourPagesService.create normalizes ui_schema_json top-level shape', async () => {
|
||||||
|
const calls: UpdateContractCalls = {};
|
||||||
|
const transaction: TestManagedTransaction = {
|
||||||
|
id: 'tx-tour-page-create',
|
||||||
|
commit() {
|
||||||
|
calls.committed = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
rollback() {
|
||||||
|
calls.rolledBack = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const restoreTransaction = replaceSequelizeTransaction(transaction);
|
||||||
|
const restoreCreate = replaceTourPagesDbApiCreate((options) => {
|
||||||
|
calls.tourPageCreate = options;
|
||||||
|
return Promise.resolve({
|
||||||
|
id: 'page-1',
|
||||||
|
ui_schema_json: options.data.ui_schema_json ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await TourPagesService.create({
|
||||||
|
data: {
|
||||||
|
name: 'Page',
|
||||||
|
slug: 'page',
|
||||||
|
ui_schema_json: '{"canvas":{"width":1920}}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result.ui_schema_json, {
|
||||||
|
canvas: { width: 1920 },
|
||||||
|
elements: [],
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls.tourPageCreate?.data.ui_schema_json, {
|
||||||
|
canvas: { width: 1920 },
|
||||||
|
elements: [],
|
||||||
|
});
|
||||||
|
assert.equal(calls.committed, true);
|
||||||
|
assert.equal(calls.rolledBack, undefined);
|
||||||
|
} finally {
|
||||||
|
restoreCreate();
|
||||||
|
restoreTransaction();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('TourPagesService.update rejects ui_schema_json with non-array elements', async () => {
|
||||||
|
const calls: UpdateContractCalls = {};
|
||||||
|
const restoreFindBy = replaceTourPagesDbApiFindBy((where, options) => {
|
||||||
|
calls.tourPageFindBy = { where, options: options || {} };
|
||||||
|
return Promise.resolve({ id: where.id });
|
||||||
|
});
|
||||||
|
const restoreUpdate = replaceTourPagesDbApiUpdate((options) => {
|
||||||
|
calls.tourPageUpdate = options;
|
||||||
|
return Promise.resolve({ id: options.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
TourPagesService.update({
|
||||||
|
id: 'page-1',
|
||||||
|
data: {
|
||||||
|
ui_schema_json: { elements: {} },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/ui_schema_json\.elements must be an array/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(calls.tourPageFindBy, undefined);
|
||||||
|
assert.equal(calls.tourPageUpdate, undefined);
|
||||||
|
} finally {
|
||||||
|
restoreUpdate();
|
||||||
|
restoreFindBy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
void test('createEntityService update uses object signature and manages own transaction', async () => {
|
void test('createEntityService update uses object signature and manages own transaction', async () => {
|
||||||
const calls: UpdateContractCalls = {};
|
const calls: UpdateContractCalls = {};
|
||||||
const transaction: TestManagedTransaction = {
|
const transaction: TestManagedTransaction = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user