457 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|