fix(api/v1): accept color_theme + create feedback row on event create (#550)
POST /v1/events was a strict subset of the admin create path: it did not accept color_theme on the body, and it skipped the event_feedback_settings insert that adminEvents.js does. Two visible bugs followed. 1. Editing an API-created event in the admin UI snapped the theme picker to GALLERY_THEME_PRESETS.default (EventDetailsPage.tsx falls through to the default preset when event.color_theme is falsy), and saving wrote that default back. Inherited themes were silently clobbered. 2. The "Enable Guest Feedback by default" admin setting (#520) did not apply to API-created events. With no event_feedback_settings row the gallery UI reads feedback as off, regardless of event_default_feedback_enabled. Fix mirrors the admin path: - color_theme accepted on the request body (optional, persisted as-is — preset name or JSON-encoded ThemeConfig, same shape adminEvents stores). - feedback_enabled accepted on the request body; when omitted, falls back to the event_default_feedback_enabled global setting (same behaviour adminEvents.js:511-520 implements via readBooleanSetting). - event_feedback_settings row inserted when feedback resolves to true, using the same sub-flag defaults as the admin form (everything on except require_name_email). OpenAPI JSDoc updated so docs.picpeak.app picks up the new fields. Tests cover all four scenarios — explicit color_theme persisted, JSON theme persisted verbatim, explicit feedback_enabled creates the row, omitted feedback_enabled honours the global setting, and a validator regression for non-boolean feedback_enabled.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Regression tests for issue #550.
|
||||
*
|
||||
* Two related bugs in POST /v1/events:
|
||||
* 1. color_theme was not accepted on the request body and never written
|
||||
* to the events row. Editing such an event later in the admin UI
|
||||
* snapped the theme picker to GALLERY_THEME_PRESETS.default and
|
||||
* saving overwrote whatever theme was inherited visually.
|
||||
* 2. event_feedback_settings row was never created, so the gallery UI
|
||||
* read it as "feedback off" regardless of the global
|
||||
* event_default_feedback_enabled toggle (#520).
|
||||
*
|
||||
* Test pattern mirrors events.category.test.js — queue up db() chains
|
||||
* with db.__setImplementations() in the exact order the handler invokes
|
||||
* them, then assert against the captured payloads.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const buildChain = ({ firstResult, insertResult, returningResult } = {}) => {
|
||||
const chain = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orWhere: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
first: jest.fn().mockResolvedValue(firstResult),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
jest.mock('../../../database/db', () => {
|
||||
const dbMock = jest.fn();
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.__setImplementations = (...chains) => {
|
||||
dbMock.mockReset();
|
||||
chains.forEach((chain) => {
|
||||
dbMock.mockImplementationOnce(() => chain);
|
||||
});
|
||||
};
|
||||
return {
|
||||
db: dbMock,
|
||||
logActivity: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../middleware/apiTokenAuth', () => ({
|
||||
apiTokenAuth: (req, _res, next) => {
|
||||
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
|
||||
req.admin = { id: 1, username: 'token-admin' };
|
||||
next();
|
||||
},
|
||||
requireApiScope: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// bcrypt.hash is awaited twice per request (real path + dummy path).
|
||||
// Stub it to a constant so tests don't burn CPU on bcrypt rounds.
|
||||
jest.mock('bcrypt', () => ({
|
||||
hash: jest.fn().mockResolvedValue('$2b$10$mocked-hash'),
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/shareLinkService', () => ({
|
||||
buildShareLinkVariants: jest.fn().mockResolvedValue({
|
||||
shareUrl: 'https://example.test/gallery/some-slug?t=abc',
|
||||
shareLinkToStore: '/gallery/some-slug?t=abc',
|
||||
}),
|
||||
}));
|
||||
|
||||
// Webhook fire is in a try/catch; stub to silence the predictable
|
||||
// failure log so test output stays clean.
|
||||
jest.mock('../../../services/webhookService', () => ({
|
||||
fire: jest.fn().mockResolvedValue(undefined),
|
||||
buildEventSubject: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const { db } = require('../../../database/db');
|
||||
const eventsRouter = require('../events');
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/', eventsRouter);
|
||||
return app;
|
||||
};
|
||||
|
||||
const BASE_BODY = {
|
||||
event_name: 'Issue 550 Wedding',
|
||||
event_type: 'wedding',
|
||||
event_date: '2026-06-15',
|
||||
require_password: false,
|
||||
};
|
||||
|
||||
describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('persists color_theme to the events row when provided', async () => {
|
||||
// db() call sequence for this body (feedback_enabled omitted, no
|
||||
// customer_phone, no slug collision):
|
||||
// 1. app_settings.where('event_default_feedback_enabled').first()
|
||||
// 2. events.where({ slug }).first() ← uniqueness probe
|
||||
// 3. events.insert(...).returning('id')
|
||||
// No event_feedback_settings insert because the global setting
|
||||
// returns nothing (feedback stays off) — covered separately below.
|
||||
const settingChain = buildChain({ firstResult: null });
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 42 }] });
|
||||
db.__setImplementations(settingChain, slugChain, insertChain);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, color_theme: 'default' })
|
||||
.expect(201);
|
||||
|
||||
const insertedRow = insertChain.insert.mock.calls[0][0];
|
||||
expect(insertedRow).toMatchObject({
|
||||
event_name: 'Issue 550 Wedding',
|
||||
color_theme: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
|
||||
db.__setImplementations(
|
||||
buildChain({ firstResult: null }),
|
||||
buildChain({ firstResult: null }),
|
||||
buildChain({ returningResult: [{ id: 43 }] }),
|
||||
);
|
||||
|
||||
const customTheme = JSON.stringify({ primaryColor: '#ff0066' });
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, color_theme: customTheme })
|
||||
.expect(201);
|
||||
|
||||
const insertedRow = db.mock.results[2].value.insert.mock.calls[0][0];
|
||||
expect(insertedRow.color_theme).toBe(customTheme);
|
||||
});
|
||||
|
||||
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
|
||||
// 3 db() calls when feedback_enabled is sent explicitly (the
|
||||
// settings probe is skipped because feedbackEnabledInput !== undefined):
|
||||
// 1. slug probe, 2. events insert, 3. feedback insert
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
|
||||
const feedbackInsertChain = buildChain();
|
||||
db.__setImplementations(slugChain, insertChain, feedbackInsertChain);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, feedback_enabled: true })
|
||||
.expect(201);
|
||||
|
||||
// db('event_feedback_settings') is the 3rd invocation.
|
||||
expect(db).toHaveBeenNthCalledWith(3, 'event_feedback_settings');
|
||||
|
||||
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
|
||||
expect(feedbackRow).toMatchObject({ event_id: 50 });
|
||||
// formatBoolean() returns 1/0 on SQLite and true/false on PG. Either
|
||||
// way the value must be truthy/falsy in the right places — assert by
|
||||
// coercion so the test stays driver-agnostic.
|
||||
expect(Boolean(feedbackRow.feedback_enabled)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_ratings)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_likes)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_comments)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_favorites)).toBe(true);
|
||||
expect(Boolean(feedbackRow.require_name_email)).toBe(false);
|
||||
expect(Boolean(feedbackRow.moderate_comments)).toBe(true);
|
||||
expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true);
|
||||
});
|
||||
|
||||
it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
|
||||
// settings probe returns a serialized "true" — fallback should kick
|
||||
// in and the feedback row should still be written.
|
||||
const settingChain = buildChain({
|
||||
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
|
||||
});
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
|
||||
const feedbackInsertChain = buildChain();
|
||||
db.__setImplementations(settingChain, slugChain, insertChain, feedbackInsertChain);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send(BASE_BODY)
|
||||
.expect(201);
|
||||
|
||||
expect(db).toHaveBeenNthCalledWith(4, 'event_feedback_settings');
|
||||
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => {
|
||||
const settingChain = buildChain({ firstResult: null });
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 52 }] });
|
||||
db.__setImplementations(settingChain, slugChain, insertChain);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send(BASE_BODY)
|
||||
.expect(201);
|
||||
|
||||
// Only 3 db() calls — the event_feedback_settings table is never
|
||||
// touched because feedback_enabled resolved to false.
|
||||
expect(db).toHaveBeenCalledTimes(3);
|
||||
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
|
||||
});
|
||||
|
||||
it('rejects non-boolean feedback_enabled with 400', async () => {
|
||||
// Validators run before any db() call, so no chain queueing needed.
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { generateThumbnail } = require('../../services/imageProcessor');
|
||||
const logger = require('../../utils/logger');
|
||||
const { slugify } = require('../../utils/slug');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -87,6 +89,8 @@ const photoUpload = multer({
|
||||
* require_password: { type: boolean, default: true }
|
||||
* password: { type: string, nullable: true, description: "Required when require_password is true." }
|
||||
* expires_at: { type: string, format: date-time, nullable: true }
|
||||
* color_theme: { type: string, nullable: true, description: "Preset name (e.g. 'default') or JSON-encoded ThemeConfig. Persisted as-is on the event row." }
|
||||
* feedback_enabled: { type: boolean, nullable: true, description: "Enable guest feedback for this gallery. When omitted, falls back to the global event_default_feedback_enabled setting." }
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Event created
|
||||
@@ -117,7 +121,9 @@ router.post(
|
||||
body('admin_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional({ nullable: true }).isString().isLength({ min: 6 }),
|
||||
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601()
|
||||
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
|
||||
body('color_theme').optional({ nullable: true }).isString().trim(),
|
||||
body('feedback_enabled').optional().isBoolean()
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -127,9 +133,28 @@ router.post(
|
||||
event_name, event_type, event_date,
|
||||
customer_name = null, customer_email = null, customer_phone = null,
|
||||
admin_email = null, require_password = true, password,
|
||||
expires_at = null
|
||||
expires_at = null,
|
||||
color_theme = null,
|
||||
feedback_enabled: feedbackEnabledInput
|
||||
} = req.body;
|
||||
|
||||
// Issue #550 — mirror the admin POST path so API-created events
|
||||
// pick up the global "Enable Guest Feedback by default" toggle
|
||||
// (event_default_feedback_enabled). Without this, the UI reads
|
||||
// a missing event_feedback_settings row as "feedback off"
|
||||
// regardless of the admin's chosen default.
|
||||
let feedbackEnabledFallback = false;
|
||||
if (feedbackEnabledInput === undefined) {
|
||||
const setting = await db('app_settings').where('setting_key', 'event_default_feedback_enabled').first();
|
||||
if (setting) {
|
||||
try {
|
||||
const parsed = JSON.parse(setting.setting_value);
|
||||
if (typeof parsed === 'boolean') feedbackEnabledFallback = parsed;
|
||||
} catch { /* keep false */ }
|
||||
}
|
||||
}
|
||||
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
|
||||
|
||||
if (require_password && (!password || password.length < 6)) {
|
||||
return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' });
|
||||
}
|
||||
@@ -174,12 +199,36 @@ router.post(
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
is_draft: false,
|
||||
// Issue #550 — without this, editing an API-created event in the
|
||||
// admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default
|
||||
// and saving overwrites whatever theme was inherited visually.
|
||||
color_theme,
|
||||
...(customer_name ? { customer_name } : {}),
|
||||
...(customer_email ? { customer_email } : {}),
|
||||
...(persistPhone ? { customer_phone: persistPhone } : {})
|
||||
}).returning('id');
|
||||
const id = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Issue #550 — mirror adminEvents.js: create event_feedback_settings
|
||||
// row when feedback is enabled, so the gallery actually shows
|
||||
// feedback UI. Sub-flags default to the same values the admin form
|
||||
// ships with (everything on except require_name_email).
|
||||
if (feedback_enabled) {
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: id,
|
||||
feedback_enabled: formatBoolean(true),
|
||||
allow_ratings: formatBoolean(true),
|
||||
allow_likes: formatBoolean(true),
|
||||
allow_comments: formatBoolean(true),
|
||||
allow_favorites: formatBoolean(true),
|
||||
require_name_email: formatBoolean(false),
|
||||
moderate_comments: formatBoolean(true),
|
||||
show_feedback_to_guests: formatBoolean(true),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
await logActivity('event_created', { via: 'api_v1', event_type }, id, {
|
||||
type: 'admin', id: req.admin.id, name: req.admin.username
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user