DB validation improvements

This commit is contained in:
Dmitri 2026-07-02 12:01:47 +02:00
parent 9130efdb6b
commit a3dfdccd0d
2 changed files with 209 additions and 4 deletions

View File

@ -81,6 +81,60 @@ const isStructuredUiSchema = (
): value is TourPageStructuredUiSchema =>
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 = (
uiSchema: TourPageData['ui_schema_json'],
): TourPageStructuredUiSchema | string | null => {
@ -623,11 +677,12 @@ class TourPagesService extends BaseService {
): Promise<TourPageRecord> {
assertCreateOptions(options, 'Service');
const { data, currentUser } = options;
const normalizedData = normalizeTourPageUiSchemaData(data);
// Process reversed videos and get updated ui_schema_json
const updatedData =
await TourPagesService.processReversedVideosAndUpdateSchema(
data,
normalizedData,
currentUser,
);
@ -645,6 +700,7 @@ class TourPagesService extends BaseService {
): Promise<TourPageRecord> {
assertUpdateOptions(options, 'Service');
const { id, data, currentUser } = options;
const normalizedData = normalizeTourPageUiSchemaData(data);
// Fetch existing page to get projectId (not included in update request body)
const existingPage = await Tour_pagesDBApi.findBy(
@ -652,9 +708,11 @@ class TourPagesService extends BaseService {
buildFindByOptions(options),
);
const projectId =
existingPage?.projectId || data.projectId || data.project_id;
existingPage?.projectId ||
normalizedData.projectId ||
normalizedData.project_id;
const dataWithPageContext: TourPageData = {
...data,
...normalizedData,
id,
};
if (projectId !== undefined) {
@ -726,7 +784,11 @@ class TourPagesService extends BaseService {
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');
return data;
}

View File

@ -3,7 +3,9 @@ import test from 'node:test';
import db from '../src/db/models/index.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 TourPagesService from '../src/services/tour_pages.ts';
import GenericDBApi from '../src/db/api/base.api.ts';
import ProjectElementDefaultsDBApi from '../src/db/api/project_element_defaults.ts';
import { createEntityService } from '../src/factories/service.factory.ts';
@ -17,6 +19,9 @@ import type {
EntityServiceDbApi,
RuntimeContext,
RuntimeEnvironment,
TourPageCreateOptions,
TourPageRecord,
TourPageUpdateOptions,
} from '../src/types/index.ts';
interface TestTransactionOptions {
@ -58,6 +63,9 @@ interface UpdateContractCalls {
serviceDeleteByIds?: unknown;
serviceRemove?: unknown;
variantSoftDelete?: { payload: DbData; options: unknown };
tourPageCreate?: TourPageCreateOptions;
tourPageUpdate?: TourPageUpdateOptions;
tourPageFindBy?: { where: { id: string }; options: TestTransactionOptions };
associationThis?: TestAssociationRecord;
committed?: 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(
calls: UpdateContractCalls,
): 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 () => {
const calls: UpdateContractCalls = {};
const transaction: TestManagedTransaction = {