From 9fe9bd77fa076c7f82cb961f5045e15937a9e88b Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:36:26 +0200 Subject: [PATCH] test(slideshow): backend route tests for public + admin endpoints 25 tests over two files, using the integration test-DB helper (real sqlite, all migrations): - slideshowPublic: resolveSlideshow guards (feature-flag kill-switch -> 404, unknown/null token, expired/draft/archived), the watermark cascade (global look + per-event on/off + source->URL resolution + "null when no logo"), image fit, and /session minting (token + cookie). Regression-guards the app_settings reads (vs the nonexistent `settings` table bug). - slideshowAdmin: generate/disable/regenerate, PATCH display + watermark mode, feature-flag 403, no-token 401, and PUT /admin/settings/slideshow validation + clamping. Both generate and PATCH assert success despite events having no `updated_at` column (the original 500). --- .../__tests__/routes/slideshowAdmin.test.js | 200 +++++++++++++++ .../__tests__/routes/slideshowPublic.test.js | 232 ++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 backend/__tests__/routes/slideshowAdmin.test.js create mode 100644 backend/__tests__/routes/slideshowPublic.test.js diff --git a/backend/__tests__/routes/slideshowAdmin.test.js b/backend/__tests__/routes/slideshowAdmin.test.js new file mode 100644 index 00000000..45c08fbe --- /dev/null +++ b/backend/__tests__/routes/slideshowAdmin.test.js @@ -0,0 +1,200 @@ +/** + * HTTP route tests for the ADMIN Live Slideshow endpoints: + * POST /api/admin/events/:id/slideshow/generate + * POST /api/admin/events/:id/slideshow/disable + * PATCH /api/admin/events/:id/slideshow + * PUT /api/admin/settings/slideshow (global preset + watermark + fit) + * + * Pins the contracts + the two regressions hit during the build: + * - the events table has NO `updated_at` column, so these writes must NOT set + * it (else every call 500s — that was the original "Generate" failure); + * - the `slideshow` feature flag gates these endpoints (403 when off); + * - PUT /admin/settings/slideshow validates + clamps every key. + */ +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-admin-')), '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, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb'); +const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag'); + +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 insertEvent(db, adminId, over = {}) { + const base = { + slug: `ev-${Math.random().toString(16).slice(2)}`, + 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/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, + created_by: adminId, + created_at: new Date().toISOString(), + ...over, + }; + const r = await db('events').insert(base).returning('id'); + return r[0]?.id ?? r[0]; +} + +describe('admin Live Slideshow endpoints', () => { + let db; let cleanup; let app; let adminId; let token; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId } = await seedMinimal(db)); + await assignAdminRole(db, adminId, 'super_admin'); + token = mintAdminToken(adminId); + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/admin/events', require('../../src/routes/adminEvents')); + app.use('/api/admin/settings', require('../../src/routes/adminSettings')); + // 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 }); + }); + }); + + afterAll(async () => { await cleanup(); }); + + beforeEach(async () => { + await db('events').del(); + await db('app_settings').del(); + await setFlag(db, 'slideshow', true); + }); + + const auth = (req) => req.set('Authorization', `Bearer ${token}`); + + describe('generate / disable', () => { + it('mints a share token (no updated_at column → must not 500)', async () => { + const id = await insertEvent(db, adminId); + const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`)); + expect(res.status).toBe(200); + expect(typeof res.body.show_share_token).toBe('string'); + expect(res.body.show_share_token).toHaveLength(64); + expect(res.body.slideshow_url).toContain(`/show/${res.body.show_share_token}`); + const row = await db('events').where({ id }).first(); + expect(row.show_share_token).toBe(res.body.show_share_token); + }); + + it('regenerate rotates the token', async () => { + const id = await insertEvent(db, adminId, { show_share_token: 'old-token' }); + const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`)); + expect(res.status).toBe(200); + expect(res.body.show_share_token).not.toBe('old-token'); + }); + + it('disable nulls the token', async () => { + const id = await insertEvent(db, adminId, { show_share_token: 'live-token' }); + const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/disable`)); + expect(res.status).toBe(200); + const row = await db('events').where({ id }).first(); + expect(row.show_share_token == null).toBe(true); + }); + + it('403 when the slideshow feature is off', async () => { + const id = await insertEvent(db, adminId); + await setFlag(db, 'slideshow', false); + const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`)); + expect(res.status).toBe(403); + }); + + it('401 without an admin token', async () => { + const id = await insertEvent(db, adminId); + const res = await request(app).post(`/api/admin/events/${id}/slideshow/generate`); + expect(res.status).toBe(401); + }); + }); + + describe('PATCH /:id/slideshow', () => { + it('persists display + watermark mode (no updated_at column → must not 500)', async () => { + const id = await insertEvent(db, adminId); + const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ + show_interval_ms: 9000, + show_transition: 'cut', + show_transition_ms: 300, + show_watermark: true, + show_colorfilter: 'bw', + }); + expect(res.status).toBe(200); + const row = await db('events').where({ id }).first(); + expect(row.show_interval_ms).toBe(9000); + expect(row.show_transition).toBe('cut'); + expect(row.show_transition_ms).toBe(300); + expect(row.show_colorfilter).toBe('bw'); + expect(row.show_watermark === 1 || row.show_watermark === true).toBe(true); + }); + + it('show_watermark=null sets the column to NULL (inherit global)', async () => { + const id = await insertEvent(db, adminId, { show_watermark: 1 }); + const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_watermark: null }); + expect(res.status).toBe(200); + const row = await db('events').where({ id }).first(); + expect(row.show_watermark == null).toBe(true); + }); + + it('400 on an invalid transition', async () => { + const id = await insertEvent(db, adminId); + const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_transition: 'wormhole' }); + expect(res.status).toBe(400); + }); + }); + + describe('PUT /api/admin/settings/slideshow', () => { + const getSetting = async (key) => { + const row = await db('app_settings').where({ setting_key: key }).first(); + return row ? JSON.parse(row.setting_value) : undefined; + }; + + it('persists the global preset + watermark + fit, clamping out-of-range values', async () => { + const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({ + slideshow_fit: 'contain', + slideshow_interval_ms: 9000, + slideshow_transition: 'slide', + slideshow_transition_ms: 250, + slideshow_colorfilter: 'sepia', + slideshow_watermark_enabled: true, + slideshow_watermark_opacity: 999, // clamp -> 100 + slideshow_watermark_size: 99, // clamp -> 40 + }); + expect(res.status).toBe(200); + expect(await getSetting('slideshow_fit')).toBe('contain'); + expect(await getSetting('slideshow_interval_ms')).toBe(9000); + expect(await getSetting('slideshow_transition')).toBe('slide'); + expect(await getSetting('slideshow_transition_ms')).toBe(250); + expect(await getSetting('slideshow_colorfilter')).toBe('sepia'); + expect(await getSetting('slideshow_watermark_enabled')).toBe(true); + expect(await getSetting('slideshow_watermark_opacity')).toBe(100); + expect(await getSetting('slideshow_watermark_size')).toBe(40); + }); + + it('coerces an invalid fit / transition to the safe default', async () => { + const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({ + slideshow_fit: 'banana', + slideshow_transition: 'wormhole', + }); + expect(res.status).toBe(200); + expect(await getSetting('slideshow_fit')).toBe('cover'); + expect(await getSetting('slideshow_transition')).toBe('crossfade'); + }); + }); +}); diff --git a/backend/__tests__/routes/slideshowPublic.test.js b/backend/__tests__/routes/slideshowPublic.test.js new file mode 100644 index 00000000..5d13fbe5 --- /dev/null +++ b/backend/__tests__/routes/slideshowPublic.test.js @@ -0,0 +1,232 @@ +/** + * 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 request = require('supertest'); +const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helpers/crmDb'); +const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag'); + +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; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + app = buildRouteApp('/api/gallery', require('../../src/routes/gallery')); + }); + + afterAll(async () => { await cleanup(); }); + + beforeEach(async () => { + await db('events').del(); + await db('app_settings').del(); + await db('feature_flags').del(); + invalidateFeatureFlagCache(); + await setFlag(db, 'slideshow', true); + }); + + const stateUrl = (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('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); + }); + }); +});