39948-vm/backend/tests/integration/publish-service.test.ts
2026-07-02 14:22:33 +02:00

457 lines
11 KiB
TypeScript

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<void> {
let timeoutId: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, 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<void>,
): Promise<void> {
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<ProjectModelRecord> {
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<number> {
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<PublishCloneSource> {
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<void> {
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<ProjectTransitionSettingsFieldMapping, 'id' | 'source_key'>,
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<ProjectUiControlSettingsFieldMapping, 'id' | 'source_key'>,
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);
});
});