import assert from 'node:assert/strict'; import test from 'node:test'; import type { TestContext } from 'node:test'; import type { Transaction } from 'sequelize'; import db from '../../src/db/models/index.ts'; import PublishService from '../../src/services/publish.ts'; import type { ProjectCreatePayload, ProjectModelRecord, ProjectTransitionSettingsFieldMapping, ProjectUiControlSettingsFieldMapping, PublishCloneData, PublishClonePayload, PublishCloneSource, RuntimeEnvironment, TourPageCreatePayload, } from '../../src/types/index.ts'; const suffix = `${Date.now()}-${process.pid}`; const actorId = '094121b9-c567-469a-b256-ba221b7fd5d6'; void test.after(async () => { await db.sequelize.close(); }); async function authenticateWithTimeout(timeoutMs = 1500): Promise { let timeoutId: NodeJS.Timeout | undefined; const timeout = new Promise((_, reject) => { timeoutId = setTimeout( () => reject(new Error(`Database unavailable after ${timeoutMs}ms`)), timeoutMs, ); }); try { await Promise.race([db.sequelize.authenticate(), timeout]); } finally { if (timeoutId !== undefined) { clearTimeout(timeoutId); } } } async function withTransaction( t: TestContext, callback: (transaction: Transaction) => Promise, ): Promise { try { await authenticateWithTimeout(); } catch (error) { const message = error instanceof Error ? error.message : 'unknown error'; t.skip(`Database unavailable: ${message}`); return; } const transaction = await db.sequelize.transaction(); try { await callback(transaction); } finally { await transaction.rollback(); } } async function createProject( slug: string, transaction: Transaction, ): Promise { const data: ProjectCreatePayload = { id: undefined, name: `Publish Test ${slug}`, slug, description: undefined, logo_url: undefined, favicon_url: undefined, og_image_url: undefined, design_width: undefined, design_height: undefined, production_presentation_visibility: 'public', importHash: null, createdById: null, updatedById: null, }; return db.projects.create(data, { transaction }); } interface TourPageSeed { projectId: string; environment: RuntimeEnvironment; name: string; slug: string; sortOrder: number; } function createTourPage( data: TourPageSeed, transaction: Transaction, ) { const payload: TourPageCreatePayload = { id: undefined, projectId: data.projectId, environment: data.environment, source_key: null, name: data.name, slug: data.slug, sort_order: data.sortOrder, background_image_url: `${data.environment}/${data.slug}.jpg`, background_video_url: null, background_embed_url: null, background_audio_url: null, background_audio_autoplay: true, background_audio_loop: true, background_audio_start_time: null, background_audio_end_time: null, background_loop: false, background_video_autoplay: true, background_video_loop: true, background_video_muted: true, background_video_start_time: null, background_video_end_time: null, design_width: null, design_height: null, requires_auth: false, ui_schema_json: { elements: [{ id: `${data.slug}-button`, type: 'button' }], }, global_ui_controls_settings_json: { sound: { enabled: true }, }, importHash: null, createdById: null, updatedById: null, }; return db.tour_pages.create( payload, { transaction }, ); } async function countPages( projectId: string, environment: RuntimeEnvironment, transaction: Transaction, ): Promise { const result = await db.tour_pages.findAndCountAll({ where: { projectId, environment }, include: [], distinct: true, order: [['createdAt', 'ASC']], transaction, }); return result.count; } function createDevAudioTrack( data: { projectId: string; name: string; slug: string; url: string; loop: boolean; volume: number; sort_order: number; is_enabled: boolean; }, transaction: Transaction, ): Promise { return db.project_audio_tracks.create( { ...data, environment: 'dev', createdById: actorId, updatedById: actorId, }, { transaction }, ); } interface TargetAudioTrackSeed { projectId: string; environment: 'stage' | 'production'; name: string; slug: string; url: string; } function createTargetAudioTrack( data: TargetAudioTrackSeed, transaction: Transaction, ): Promise { const payload: PublishClonePayload = { ...data, source_key: 'old-source', createdById: null, updatedById: null, }; return db.project_audio_tracks.bulkCreate( [payload], { transaction, returning: false }, ); } function createTransitionSettings( projectId: string, environment: RuntimeEnvironment, data: Omit, transaction: Transaction, ) { return db.project_transition_settings.create( { id: undefined, source_key: null, ...data, projectId, environment, createdById: null, updatedById: null, }, { transaction }, ); } function createUiControlSettings( projectId: string, environment: RuntimeEnvironment, data: Omit, transaction: Transaction, ) { return db.project_ui_control_settings.create( { id: undefined, source_key: null, ...data, projectId, environment, createdById: null, updatedById: null, }, { transaction }, ); } function cloneData(record: PublishCloneSource | null): PublishCloneData { if (!record) { throw new Error('Expected clone record to exist.'); } return record.toJSON(); } void test('copyDevToStage replaces stage content with dev pages, audio, and settings', async (t) => { await withTransaction(t, async (transaction) => { const project = await createProject( `publish-copy-dev-stage-${suffix}`, transaction, ); const sourcePage = await createTourPage( { projectId: project.id, environment: 'dev', name: 'Dev Lobby', slug: 'lobby', sortOrder: 1, }, transaction, ); await createTourPage( { projectId: project.id, environment: 'stage', name: 'Old Stage Lobby', slug: 'old-lobby', sortOrder: 99, }, transaction, ); const sourceAudio = await createDevAudioTrack( { projectId: project.id, name: 'Narration', slug: 'narration', url: 'audio/narration.mp3', loop: true, volume: 0.7, sort_order: 1, is_enabled: true, }, transaction, ); await createTargetAudioTrack( { projectId: project.id, environment: 'stage', name: 'Old Stage Audio', slug: 'old-stage-audio', url: 'audio/old.mp3', }, transaction, ); const sourceTransition = await createTransitionSettings( project.id, 'dev', { transition_type: 'fade', duration_ms: 450, easing: 'linear', overlay_color: '#111111', }, transaction, ); const sourceTransitionPlain = sourceTransition.get({ plain: true }); const sourceUiControls = await createUiControlSettings( project.id, 'dev', { settings_json: { fullscreen: { enabled: false }, sound: { enabled: true }, }, }, transaction, ); const sourceUiControlsPlain = sourceUiControls.get({ plain: true }); await createUiControlSettings( project.id, 'stage', { settings_json: { old: true }, }, transaction, ); const summary = await PublishService.copyDevToStage( project.id, { id: actorId }, transaction, ); assert.deepEqual(summary, { pages_copied: 1, audios_copied: 1, transition_settings_copied: 1, ui_control_settings_copied: 1, }); assert.equal(await countPages(project.id, 'dev', transaction), 1); assert.equal(await countPages(project.id, 'stage', transaction), 1); const copiedPage = await db.tour_pages.findOne({ where: { projectId: project.id, environment: 'stage', slug: 'lobby', }, transaction, }); assert.ok(copiedPage); assert.notEqual(copiedPage.id, sourcePage.id); assert.equal(copiedPage.source_key, sourcePage.id); assert.deepEqual(copiedPage.ui_schema_json, { elements: [{ id: 'lobby-button', type: 'button' }], }); const stageAudios = await db.project_audio_tracks.findAll({ where: { projectId: project.id, environment: 'stage' }, transaction, }); const copiedAudio = stageAudios.map((row) => row.toJSON()).find( (row) => row.slug === 'narration', ); assert.ok(copiedAudio); assert.equal(copiedAudio.source_key, sourceAudio.id); assert.equal(copiedAudio.url, 'audio/narration.mp3'); const copiedTransition = cloneData( await db.project_transition_settings.findOne({ where: { projectId: project.id, environment: 'stage' }, transaction, }), ); assert.equal(copiedTransition.source_key, sourceTransitionPlain.id); assert.equal(copiedTransition.duration_ms, 450); const copiedUiControls = cloneData( await db.project_ui_control_settings.findOne({ where: { projectId: project.id, environment: 'stage' }, transaction, }), ); assert.equal(copiedUiControls.source_key, sourceUiControlsPlain.id); assert.deepEqual(copiedUiControls.settings_json, { fullscreen: { enabled: false }, sound: { enabled: true }, }); }); }); void test('copyStageToProduction leaves stage content intact while replacing production', async (t) => { await withTransaction(t, async (transaction) => { const project = await createProject( `publish-copy-stage-production-${suffix}`, transaction, ); const sourcePage = await createTourPage( { projectId: project.id, environment: 'stage', name: 'Stage Intro', slug: 'intro', sortOrder: 1, }, transaction, ); await createTourPage( { projectId: project.id, environment: 'production', name: 'Old Production Intro', slug: 'old-intro', sortOrder: 1, }, transaction, ); const summary = await PublishService.copyStageToProduction( project.id, undefined, transaction, ); assert.deepEqual(summary, { pages_copied: 1, audios_copied: 0, transition_settings_copied: 0, ui_control_settings_copied: 0, }); assert.equal(await countPages(project.id, 'stage', transaction), 1); assert.equal(await countPages(project.id, 'production', transaction), 1); const productionPage = await db.tour_pages.findOne({ where: { projectId: project.id, environment: 'production', slug: 'intro', }, transaction, }); assert.ok(productionPage); assert.notEqual(productionPage.id, sourcePage.id); assert.equal(productionPage.source_key, sourcePage.id); }); });