DB validation improvements
This commit is contained in:
parent
9130efdb6b
commit
a3dfdccd0d
@ -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;
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user