diff --git a/backend/src/services/tour_pages.ts b/backend/src/services/tour_pages.ts index 3537ef6..0dfa4a4 100644 --- a/backend/src/services/tour_pages.ts +++ b/backend/src/services/tour_pages.ts @@ -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 { 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 { 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; } diff --git a/backend/tests/update-contracts.test.ts b/backend/tests/update-contracts.test.ts index ba7ce78..3c777d5 100644 --- a/backend/tests/update-contracts.test.ts +++ b/backend/tests/update-contracts.test.ts @@ -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, +): () => 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, +): () => 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, +): () => 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 { @@ -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 = {