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>
241 lines
7.9 KiB
JavaScript
241 lines
7.9 KiB
JavaScript
/**
|
|
* Unit tests for the per-guest favorite/like cap (#655).
|
|
*
|
|
* Pins the contract of `feedbackService.submitFeedback` around the cap:
|
|
* - null / 0 cap means unlimited (back-compat for installs that don't
|
|
* enable the feature).
|
|
* - At-cap ADD returns `{ limit_reached, limit, current_count }` rather
|
|
* than inserting — the route layer translates that into the structured
|
|
* 403 the UI listens for.
|
|
* - Toggle-off (un-favoriting) is ALWAYS allowed, regardless of cap state.
|
|
* A guest at 10/10 can still free a slot.
|
|
* - Limit reduction (admin lowers 20 → 10 while a guest has 15 already)
|
|
* grandfathers existing rows — new adds blocked, removals always allowed.
|
|
* - Caps are per-feedback-type: filling the favorite quota doesn't block
|
|
* likes on the same photo, and vice versa.
|
|
*/
|
|
|
|
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-limit-')), 'db.sqlite',
|
|
);
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-limit-test-secret';
|
|
|
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
|
|
|
const feedbackService = require('../../src/services/feedbackService');
|
|
|
|
const EVENT_SLUG = 'cap-test-event';
|
|
const GUEST_A = 'guest-a-identifier';
|
|
const GUEST_B = 'guest-b-identifier';
|
|
|
|
let db;
|
|
let cleanup;
|
|
let eventId;
|
|
let photoIds;
|
|
|
|
async function setEventFeedbackSettings(overrides) {
|
|
const base = {
|
|
feedback_enabled: 1,
|
|
allow_ratings: 1,
|
|
allow_likes: 1,
|
|
allow_comments: 0,
|
|
allow_favorites: 1,
|
|
require_name_email: 0,
|
|
moderate_comments: 0,
|
|
show_feedback_to_guests: 1,
|
|
identity_mode: 'simple',
|
|
max_favorites_per_guest: null,
|
|
max_likes_per_guest: null,
|
|
...overrides,
|
|
};
|
|
const existing = await db('event_feedback_settings').where('event_id', eventId).first();
|
|
if (existing) {
|
|
await db('event_feedback_settings').where('event_id', eventId).update(base);
|
|
} else {
|
|
await db('event_feedback_settings').insert({
|
|
event_id: eventId,
|
|
...base,
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function favorite(photoId, guestIdentifier = GUEST_A) {
|
|
return feedbackService.submitFeedback(photoId, eventId, {
|
|
feedback_type: 'favorite',
|
|
ip_address: '127.0.0.1',
|
|
user_agent: 'jest',
|
|
}, guestIdentifier);
|
|
}
|
|
|
|
async function like(photoId, guestIdentifier = GUEST_A) {
|
|
return feedbackService.submitFeedback(photoId, eventId, {
|
|
feedback_type: 'like',
|
|
ip_address: '127.0.0.1',
|
|
user_agent: 'jest',
|
|
}, guestIdentifier);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
await seedMinimal(db);
|
|
const inserted = await db('events').insert({
|
|
slug: EVENT_SLUG,
|
|
event_type: 'wedding',
|
|
event_name: 'Cap Test',
|
|
event_date: '2026-06-22',
|
|
host_email: 'host@example.com',
|
|
admin_email: 'admin@example.com',
|
|
password_hash: 'x',
|
|
share_link: `/gallery/${EVENT_SLUG}/share`,
|
|
share_token: 'cap-test-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');
|
|
eventId = inserted[0]?.id ?? inserted[0];
|
|
|
|
// Seed 15 photos so we can test caps comfortably up to that count.
|
|
photoIds = [];
|
|
for (let i = 1; i <= 15; i += 1) {
|
|
const r = await db('photos').insert({
|
|
event_id: eventId,
|
|
filename: `photo-${i}.jpg`,
|
|
path: `events/cap/${i}.jpg`,
|
|
type: 'individual',
|
|
uploaded_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
photoIds.push(r[0]?.id ?? r[0]);
|
|
}
|
|
}, 120000);
|
|
|
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
|
|
|
beforeEach(async () => {
|
|
await db('photo_feedback').where('event_id', eventId).del();
|
|
});
|
|
|
|
describe('per-guest favorite cap (#655)', () => {
|
|
test('null cap = unlimited (back-compat for installs without #655)', async () => {
|
|
await setEventFeedbackSettings({ max_favorites_per_guest: null });
|
|
for (const id of photoIds.slice(0, 12)) {
|
|
const r = await favorite(id);
|
|
expect(r.limit_reached).toBeFalsy();
|
|
expect(r.created).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('cap = 0 also = unlimited (UI convenience for "no limit")', async () => {
|
|
await setEventFeedbackSettings({ max_favorites_per_guest: 0 });
|
|
for (const id of photoIds.slice(0, 12)) {
|
|
const r = await favorite(id);
|
|
expect(r.limit_reached).toBeFalsy();
|
|
}
|
|
});
|
|
|
|
test('cap = 10: favorites 1..10 succeed, 11 returns limit_reached', async () => {
|
|
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
|
|
for (const id of photoIds.slice(0, 10)) {
|
|
const r = await favorite(id);
|
|
expect(r.created).toBe(true);
|
|
}
|
|
const r11 = await favorite(photoIds[10]);
|
|
expect(r11.limit_reached).toBe(true);
|
|
expect(r11.limit).toBe(10);
|
|
expect(r11.current_count).toBe(10);
|
|
expect(r11.feedback_type).toBe('favorite');
|
|
});
|
|
|
|
test('toggle-off at the cap frees a slot (un-favoriting always allowed)', async () => {
|
|
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
|
|
for (const id of photoIds.slice(0, 5)) {
|
|
await favorite(id);
|
|
}
|
|
const blocked = await favorite(photoIds[5]);
|
|
expect(blocked.limit_reached).toBe(true);
|
|
|
|
// Un-favorite one — toggle off path returns { removed: true }
|
|
const removed = await favorite(photoIds[0]);
|
|
expect(removed.removed).toBe(true);
|
|
|
|
// Now the previously-blocked slot fits
|
|
const after = await favorite(photoIds[5]);
|
|
expect(after.created).toBe(true);
|
|
});
|
|
|
|
test('limit reduction grandfathers existing rows; new adds blocked', async () => {
|
|
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
|
|
for (const id of photoIds.slice(0, 10)) {
|
|
await favorite(id);
|
|
}
|
|
// Admin lowers the cap to 5 while the guest already has 10
|
|
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
|
|
// Existing 10 stay
|
|
const count = await db('photo_feedback')
|
|
.where({ event_id: eventId, feedback_type: 'favorite', guest_identifier: GUEST_A })
|
|
.count('* as c').first();
|
|
expect(parseInt(count.c, 10)).toBe(10);
|
|
// New adds blocked
|
|
const blocked = await favorite(photoIds[10]);
|
|
expect(blocked.limit_reached).toBe(true);
|
|
expect(blocked.limit).toBe(5);
|
|
expect(blocked.current_count).toBe(10);
|
|
// Removals still allowed
|
|
const removed = await favorite(photoIds[0]);
|
|
expect(removed.removed).toBe(true);
|
|
});
|
|
|
|
test('cap is per-guest: guest B is unaffected by guest A hitting the cap', async () => {
|
|
await setEventFeedbackSettings({ max_favorites_per_guest: 3 });
|
|
for (const id of photoIds.slice(0, 3)) {
|
|
await favorite(id, GUEST_A);
|
|
}
|
|
expect((await favorite(photoIds[3], GUEST_A)).limit_reached).toBe(true);
|
|
|
|
// Guest B starts at 0
|
|
for (const id of photoIds.slice(0, 3)) {
|
|
const r = await favorite(id, GUEST_B);
|
|
expect(r.created).toBe(true);
|
|
}
|
|
expect((await favorite(photoIds[3], GUEST_B)).limit_reached).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('per-guest like cap (#655)', () => {
|
|
test('favorite cap does NOT block likes on the same photo (per-type)', async () => {
|
|
await setEventFeedbackSettings({
|
|
max_favorites_per_guest: 3,
|
|
max_likes_per_guest: null,
|
|
});
|
|
for (const id of photoIds.slice(0, 3)) {
|
|
await favorite(id);
|
|
}
|
|
expect((await favorite(photoIds[3])).limit_reached).toBe(true);
|
|
|
|
// Likes still unlimited
|
|
for (const id of photoIds.slice(0, 10)) {
|
|
const r = await like(id);
|
|
expect(r.created).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('like cap returns LIKE_LIMIT_REACHED-shaped payload', async () => {
|
|
await setEventFeedbackSettings({ max_likes_per_guest: 2 });
|
|
await like(photoIds[0]);
|
|
await like(photoIds[1]);
|
|
const r = await like(photoIds[2]);
|
|
expect(r.limit_reached).toBe(true);
|
|
expect(r.feedback_type).toBe('like');
|
|
expect(r.limit).toBe(2);
|
|
expect(r.current_count).toBe(2);
|
|
});
|
|
});
|