d9ad982373
The 3.97.0-beta.0 release PR (#899) failed its backend Tests job on slideshowPublic.test.js: bootCrmDb's full migration chain crossed the suite's explicit 30s beforeAll timeout argument on a slow runner. #860 raised the config default and the jest.setTimeout pins to 120s, but hook-ARGUMENT pins override the config default and were left behind — same time-bomb, different syntax. Every beforeAll that boots the migration chain and pinned 30s/60s is raised to 120000 (16 suites). Untouched on purpose: the three suites whose pinned hooks don't run migrations (webhookDelivery, imageProcessor.storage, storageBackend) and publicQuotes' 30s pin on the rate-limit lockout test — neither grows with the migration chain. No test logic changed. Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
344 lines
14 KiB
JavaScript
344 lines
14 KiB
JavaScript
/**
|
|
* HTTP route tests for the PUBLIC Live Slideshow surface (backend/src/routes/gallery.js):
|
|
* GET /:slug/show/:token/state (cheap settings + photo-count poll)
|
|
* GET /:slug/show/:token/session (mints the gallery JWT + cookie)
|
|
*
|
|
* These pin the two pieces of logic where real bugs lived during the build:
|
|
* - resolveSlideshow: the `slideshow` feature flag is a MASTER kill-switch
|
|
* (404 when off), plus token / expiry / draft / archived / inactive guards.
|
|
* - slideshowSettings: the watermark cascade (global look + per-event on/off),
|
|
* image fit, and the fact that globals are read from `app_settings`
|
|
* (regression for the getSetting→nonexistent-`settings`-table bug).
|
|
*/
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.TEST_DATABASE_PATH = path.join(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-show-pub-')), 'db.sqlite'
|
|
);
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
|
|
|
|
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const request = require('supertest');
|
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
|
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
|
|
const { invalidateSlideshowGlobals } = require('../../src/utils/slideshowGlobals');
|
|
|
|
const SLUG = 'wedding-test';
|
|
const TOKEN = 'show-tok-abcdef';
|
|
|
|
async function setFlag(db, key, on) {
|
|
await db('feature_flags').where({ key }).del();
|
|
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
|
|
invalidateFeatureFlagCache();
|
|
}
|
|
|
|
async function setSetting(db, key, value, type = 'slideshow') {
|
|
await db('app_settings').where({ setting_key: key }).del();
|
|
await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: type, updated_at: new Date() });
|
|
}
|
|
|
|
async function insertEvent(db, over = {}) {
|
|
const base = {
|
|
slug: SLUG,
|
|
event_type: 'wedding',
|
|
event_name: 'Test Wedding',
|
|
event_date: '2026-05-29',
|
|
host_email: 'host@example.com',
|
|
admin_email: 'admin@example.com',
|
|
password_hash: 'x',
|
|
share_link: `/gallery/${SLUG}/share-${Math.random().toString(16).slice(2)}`,
|
|
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
|
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
|
is_active: 1,
|
|
is_archived: 0,
|
|
is_draft: 0,
|
|
show_share_token: TOKEN,
|
|
created_at: new Date().toISOString(),
|
|
...over,
|
|
};
|
|
const r = await db('events').insert(base).returning('id');
|
|
return r[0]?.id ?? r[0];
|
|
}
|
|
|
|
describe('public Live Slideshow routes', () => {
|
|
let db; let cleanup; let app;
|
|
|
|
// bootCrmDb runs the full migration set against a fresh SQLite file. The
|
|
// chain keeps growing, and a 30s pin here blocked the 3.97.0-beta.0
|
|
// release PR on a slow runner. Hook-argument timeouts OVERRIDE the 120s
|
|
// jest.config default (same trap as the jest.setTimeout pins raised in
|
|
// #860) — keep this at 120000, matching the config.
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
await seedMinimal(db);
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
// Both routers mount under /api/gallery in production; the display-only
|
|
// guard lives on download routes (gallery) + the feedback POST (galleryFeedback).
|
|
app.use('/api/gallery', require('../../src/routes/gallery'));
|
|
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
|
// eslint-disable-next-line no-unused-vars
|
|
app.use((err, req, res, next) => {
|
|
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
|
});
|
|
}, 120000);
|
|
|
|
afterAll(async () => { await cleanup(); });
|
|
|
|
beforeEach(async () => {
|
|
await db('events').del();
|
|
await db('app_settings').del();
|
|
await db('feature_flags').del();
|
|
invalidateFeatureFlagCache();
|
|
invalidateSlideshowGlobals();
|
|
await setFlag(db, 'slideshow', true);
|
|
});
|
|
|
|
// QR overlay: supertest's Host is loopback, and a loopback base is now
|
|
// suppressed rather than encoded — the kiosk passes its reachable
|
|
// window.location.origin, so the QR tests do the same.
|
|
const KIOSK_ORIGIN = 'https://gallery.example.com';
|
|
const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state?origin=${encodeURIComponent(KIOSK_ORIGIN)}`;
|
|
const stateUrlNoOrigin = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`;
|
|
|
|
describe('resolveSlideshow guards', () => {
|
|
it('200 + per-event display settings on a live link', async () => {
|
|
await insertEvent(db, {
|
|
show_interval_ms: 8000,
|
|
show_transition: 'kenburns',
|
|
show_transition_ms: 1200,
|
|
show_colorfilter: 'sepia',
|
|
});
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
interval_ms: 8000,
|
|
transition: 'kenburns',
|
|
transition_ms: 1200,
|
|
colorfilter: 'sepia',
|
|
fit: 'cover',
|
|
photo_count: 0,
|
|
watermark: null,
|
|
});
|
|
});
|
|
|
|
it('404 when the slideshow feature flag is OFF (master kill-switch)', async () => {
|
|
await insertEvent(db);
|
|
await setFlag(db, 'slideshow', false);
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('404 on an unknown token', async () => {
|
|
await insertEvent(db);
|
|
const res = await request(app).get(stateUrl('not-the-token'));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('404 when the share token is null (link never minted / disabled)', async () => {
|
|
await insertEvent(db, { show_share_token: null });
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('404 when the event has expired', async () => {
|
|
await insertEvent(db, { expires_at: new Date(Date.now() - 1000).toISOString() });
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('404 when the event is a draft', async () => {
|
|
await insertEvent(db, { is_draft: 1 });
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('404 when the event is archived', async () => {
|
|
await insertEvent(db, { is_archived: 1 });
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('slideshowSettings — image fit (global, live)', () => {
|
|
it('reflects the global slideshow_fit setting', async () => {
|
|
await insertEvent(db);
|
|
await setSetting(db, 'slideshow_fit', 'contain');
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.fit).toBe('contain');
|
|
});
|
|
});
|
|
|
|
describe('slideshowSettings — watermark cascade (global look + per-event on/off)', () => {
|
|
async function enableGlobalWatermark() {
|
|
await setSetting(db, 'slideshow_watermark_enabled', true);
|
|
await setSetting(db, 'slideshow_watermark_source', 'logo');
|
|
await setSetting(db, 'slideshow_watermark_position', 'top-left');
|
|
await setSetting(db, 'slideshow_watermark_opacity', 40);
|
|
await setSetting(db, 'slideshow_watermark_style', 'original');
|
|
await setSetting(db, 'slideshow_watermark_size', 9);
|
|
await setSetting(db, 'branding_logo_url', '/uploads/logos/light.svg', 'branding');
|
|
}
|
|
|
|
it('inherits the global watermark when show_watermark is NULL', async () => {
|
|
await insertEvent(db, { show_watermark: null });
|
|
await enableGlobalWatermark();
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.body.watermark).toEqual({
|
|
url: '/uploads/logos/light.svg',
|
|
position: 'top-left',
|
|
opacity: 40,
|
|
style: 'original',
|
|
size: 9,
|
|
});
|
|
});
|
|
|
|
it('resolves the dark logo / favicon sources', async () => {
|
|
await insertEvent(db, { show_watermark: null });
|
|
await enableGlobalWatermark();
|
|
await setSetting(db, 'slideshow_watermark_source', 'favicon');
|
|
await setSetting(db, 'branding_favicon_url', '/uploads/favicons/f.png', 'branding');
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.body.watermark.url).toBe('/uploads/favicons/f.png');
|
|
});
|
|
|
|
it('per-event OFF override hides the watermark even when the global is on', async () => {
|
|
await insertEvent(db, { show_watermark: 0 });
|
|
await enableGlobalWatermark();
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.body.watermark).toBeNull();
|
|
});
|
|
|
|
it('per-event ON override shows the watermark even when the global is off', async () => {
|
|
await insertEvent(db, { show_watermark: 1 });
|
|
await enableGlobalWatermark();
|
|
await setSetting(db, 'slideshow_watermark_enabled', false);
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.body.watermark).not.toBeNull();
|
|
expect(res.body.watermark.url).toBe('/uploads/logos/light.svg');
|
|
});
|
|
|
|
it('null when enabled but no logo URL is configured', async () => {
|
|
await insertEvent(db, { show_watermark: null });
|
|
await setSetting(db, 'slideshow_watermark_enabled', true);
|
|
// no branding_logo_url set
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.body.watermark).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('slideshowSettings — QR overlay cascade (#837)', () => {
|
|
async function enableGlobalQr() {
|
|
await setSetting(db, 'slideshow_qr_enabled', true);
|
|
await setSetting(db, 'slideshow_qr_position', 'top-right');
|
|
await setSetting(db, 'slideshow_qr_opacity', 80);
|
|
await setSetting(db, 'slideshow_qr_size', 18);
|
|
}
|
|
|
|
it('inherits the global QR overlay when show_qr is NULL', async () => {
|
|
await insertEvent(db, { show_qr: null });
|
|
await enableGlobalQr();
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.body.qr).toMatchObject({
|
|
position: 'top-right',
|
|
opacity: 80,
|
|
size: 18,
|
|
});
|
|
// Share-link QR ships as a PNG data URI — no client QR lib needed.
|
|
expect(res.body.qr.data_url).toMatch(/^data:image\/png;base64,/);
|
|
});
|
|
|
|
it('is null by default (global off, no override)', async () => {
|
|
await insertEvent(db, { show_qr: null });
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.body.qr).toBeNull();
|
|
});
|
|
|
|
it('per-event OFF override hides the QR even when the global is on', async () => {
|
|
await insertEvent(db, { show_qr: 0 });
|
|
await enableGlobalQr();
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.body.qr).toBeNull();
|
|
});
|
|
|
|
it('per-event ON override shows the QR even when the global is off', async () => {
|
|
await insertEvent(db, { show_qr: 1 });
|
|
const res = await request(app).get(stateUrl());
|
|
expect(res.body.qr).not.toBeNull();
|
|
expect(res.body.qr.data_url).toMatch(/^data:image\/png;base64,/);
|
|
// Look falls back to the global defaults.
|
|
expect(res.body.qr.position).toBe('bottom-left');
|
|
});
|
|
|
|
it('suppresses the QR when no guest-reachable origin exists (loopback base, no kiosk origin)', async () => {
|
|
await insertEvent(db, { show_qr: 1 });
|
|
const res = await request(app).get(stateUrlNoOrigin());
|
|
// Encoding localhost would send scanning phones to THEIR localhost —
|
|
// no QR beats a broken QR (codex review of #848, confirmation round).
|
|
expect(res.body.qr).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('display-only token guards (#646 review concern 1)', () => {
|
|
// Mint a real slideshow JWT, then prove it is denied on the
|
|
// download / upload / feedback routes (display-only contract).
|
|
async function slideshowJwt() {
|
|
await insertEvent(db);
|
|
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
|
|
expect(res.status).toBe(200);
|
|
return res.body.token;
|
|
}
|
|
|
|
it('403 on whole-gallery download', async () => {
|
|
const jwt = await slideshowJwt();
|
|
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`).set('Authorization', `Bearer ${jwt}`);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('403 on single-photo download', async () => {
|
|
const jwt = await slideshowJwt();
|
|
const res = await request(app).get(`/api/gallery/${SLUG}/download/1`).set('Authorization', `Bearer ${jwt}`);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('403 on bulk download-selected', async () => {
|
|
const jwt = await slideshowJwt();
|
|
const res = await request(app).post(`/api/gallery/${SLUG}/download-selected`).set('Authorization', `Bearer ${jwt}`).send({ photoIds: [1] });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('403 on feedback POST', async () => {
|
|
const jwt = await slideshowJwt();
|
|
const res = await request(app).post(`/api/gallery/${SLUG}/photos/1/feedback`).set('Authorization', `Bearer ${jwt}`).send({ feedback_type: 'like' });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('GET /session', () => {
|
|
it('mints a token + sets the gallery cookie on a valid link', async () => {
|
|
await insertEvent(db);
|
|
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
|
|
expect(res.status).toBe(200);
|
|
expect(typeof res.body.token).toBe('string');
|
|
expect(res.body.token.length).toBeGreaterThan(20);
|
|
expect(res.body.event).toMatchObject({ event_name: 'Test Wedding' });
|
|
expect(res.body).toHaveProperty('settings');
|
|
expect(res.body).toHaveProperty('photo_count', 0);
|
|
expect(res.headers['set-cookie']).toBeDefined();
|
|
});
|
|
|
|
it('404 when the feature is off', async () => {
|
|
await insertEvent(db);
|
|
await setFlag(db, 'slideshow', false);
|
|
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
});
|