Files
picpeak/backend/__tests__/utils/feedbackSettingsUpdate.test.js
T
Paul Nothaft 89dc9623c1 fix(feedback): persist guest feedback settings, unshadow the guest route (#1030) (#1031)
Enabling Guest Feedback on an event could silently do nothing.

1. `updateEventFeedbackSettings` spread the request body straight into the
   knex UPDATE. The admin event form posts its whole client-side state,
   including three keys that were never columns on event_feedback_settings
   (`enable_rate_limiting`, `rate_limit_window_minutes`,
   `rate_limit_max_requests`), so the write threw and the route answered 500.
   Writable columns are now whitelisted; identity columns and timestamps stay
   server-managed.

2. EventDetailsPage swallowed that 500 in a bare `catch {}` ("Error already
   handled by mutation" — it is a different request), so the admin was left
   looking at "Event updated successfully" while the toggle never persisted.
   The error is surfaced now and the settings query is invalidated on success.

3. gallery.js declared a duplicate `GET /:slug/feedback-settings`. server.js
   mounts galleryRoutes before galleryFeedback, so it shadowed the real
   handler and dropped the per-guest caps (#655) from the guest payload — the
   gallery could never render the favorite/like limits or their counters.

Timestamps are written as ISO strings so they round-trip on both engines.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:50:52 +02:00

162 lines
6.0 KiB
JavaScript

/**
* Regression tests for the feedback-settings write path (#1030).
*
* The admin event form posts its whole client-side feedback state back,
* including three keys that were never columns on event_feedback_settings:
* `enable_rate_limiting`, `rate_limit_window_minutes` and
* `rate_limit_max_requests`. Spreading those into the knex UPDATE threw,
* the route answered 500, and EventDetailsPage swallowed it — so the admin
* saw "Event updated successfully" while "Enable feedback" never persisted
* and guests could not leave any feedback.
*
* Pinned here:
* - UI-only keys are dropped, not written, on BOTH the insert (no row yet)
* and update (row exists) branches.
* - Every real column still round-trips.
* - Identity columns can't be mass-assigned through the settings body.
* - gallery.js no longer declares a duplicate GET /:slug/feedback-settings.
* server.js mounts galleryRoutes before galleryFeedback, so the duplicate
* shadowed the real handler and dropped the #655 per-guest caps from the
* guest payload.
*/
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-feedback-settings-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-settings-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const feedbackService = require('../../src/services/feedbackService');
// Exactly what EventDetailsPage holds in state before its settings GET
// resolves — the three rate-limit keys are UI-only.
const ADMIN_FORM_BODY = {
feedback_enabled: true,
allow_ratings: true,
allow_likes: true,
allow_comments: true,
allow_favorites: true,
allow_reactions: true,
require_name_email: false,
moderate_comments: true,
show_feedback_to_guests: true,
enable_rate_limiting: false,
rate_limit_window_minutes: 15,
rate_limit_max_requests: 10,
};
let db;
let cleanup;
let eventId;
async function insertEvent(slug) {
const inserted = await db('events').insert({
slug,
event_type: 'wedding',
event_name: 'Feedback Settings Test',
event_date: '2026-06-22',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-share`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return inserted[0]?.id ?? inserted[0];
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
eventId = await insertEvent('feedback-settings-test');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('updateEventFeedbackSettings ignores UI-only keys (#1030)', () => {
test('insert branch: enabling feedback on an event with no settings row persists', async () => {
const freshEventId = await insertEvent('feedback-settings-fresh');
const result = await feedbackService.updateEventFeedbackSettings(freshEventId, ADMIN_FORM_BODY);
expect(result.feedback_enabled).toBeTruthy();
const row = await db('event_feedback_settings').where('event_id', freshEventId).first();
expect(row).toBeTruthy();
expect(row.feedback_enabled).toBeTruthy();
expect(row).not.toHaveProperty('enable_rate_limiting');
});
test('update branch: flipping the toggle on an existing row persists', async () => {
await feedbackService.updateEventFeedbackSettings(eventId, { feedback_enabled: false });
expect((await feedbackService.getEventFeedbackSettings(eventId)).feedback_enabled).toBeFalsy();
const result = await feedbackService.updateEventFeedbackSettings(eventId, ADMIN_FORM_BODY);
expect(result.feedback_enabled).toBeTruthy();
const rows = await db('event_feedback_settings').where('event_id', eventId);
expect(rows).toHaveLength(1);
expect(rows[0].feedback_enabled).toBeTruthy();
});
test('every real column round-trips', async () => {
const result = await feedbackService.updateEventFeedbackSettings(eventId, {
...ADMIN_FORM_BODY,
allow_comments: false,
show_feedback_to_guests: false,
identity_mode: 'guest',
max_favorites_per_guest: 10,
max_likes_per_guest: 5,
});
expect(result.allow_comments).toBeFalsy();
expect(result.show_feedback_to_guests).toBeFalsy();
expect(result.identity_mode).toBe('guest');
expect(result.max_favorites_per_guest).toBe(10);
expect(result.max_likes_per_guest).toBe(5);
});
test('identity columns cannot be mass-assigned through the settings body', async () => {
const otherEventId = await insertEvent('feedback-settings-other');
const before = await db('event_feedback_settings').where('event_id', eventId).first();
await feedbackService.updateEventFeedbackSettings(eventId, {
feedback_enabled: true,
id: 99999,
event_id: otherEventId,
});
const after = await db('event_feedback_settings').where('event_id', eventId).first();
expect(after.id).toBe(before.id);
expect(after.event_id).toBe(eventId);
expect(await db('event_feedback_settings').where('event_id', otherEventId).first()).toBeUndefined();
});
});
describe('guest feedback-settings route is not shadowed (#1030)', () => {
test('gallery.js does not declare GET /:slug/feedback-settings', () => {
const source = fs.readFileSync(
path.resolve(__dirname, '..', '..', 'src', 'routes', 'gallery.js'), 'utf8',
);
expect(source).not.toMatch(/router\.get\(\s*['"]\/:slug\/feedback-settings['"]/);
});
test('galleryFeedback.js still serves it, including the #655 per-guest caps', () => {
const source = fs.readFileSync(
path.resolve(__dirname, '..', '..', 'src', 'routes', 'galleryFeedback.js'), 'utf8',
);
expect(source).toMatch(/router\.get\(\s*['"]\/:slug\/feedback-settings['"]/);
expect(source).toMatch(/max_favorites_per_guest/);
expect(source).toMatch(/max_likes_per_guest/);
});
});