Files
picpeak/backend/__tests__/utils/feedbackRatingRemoval.test.js
T
Paul Nothaft d9ad982373 fix(tests): raise migration-boot hook timeout pins to the 120s default (#900)
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>
2026-07-29 12:38:26 +02:00

178 lines
5.8 KiB
JavaScript

/**
* Unit tests for rating removal (#884).
*
* Pins the contract of `feedbackService.submitFeedback` for
* `feedback_type: 'rating'` with `rating: 0` ("clear my rating"):
* - An existing rating row is DELETED (not updated to 0 — a stored 0
* would drag the photo's average down and still count in totals).
* - Photo stats (average_rating) are recalculated after the delete.
* - Rating 0 with no existing rating is a no-op that never inserts a row.
* - Removal is guest-scoped: clearing guest A's rating leaves guest B's
* rating (and the resulting average) intact.
* - Regular re-rating (3 → 5) still updates in place.
*/
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-rating-removal-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'rating-removal-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const feedbackService = require('../../src/services/feedbackService');
const EVENT_SLUG = 'rating-removal-event';
const GUEST_A = 'guest-a-identifier';
const GUEST_B = 'guest-b-identifier';
let db;
let cleanup;
let eventId;
let photoId;
async function rate(rating, guestIdentifier = GUEST_A) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'rating',
rating,
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
async function ratingRows(guestIdentifier) {
const q = db('photo_feedback').where({
photo_id: photoId,
feedback_type: 'rating',
});
if (guestIdentifier) q.where('guest_identifier', guestIdentifier);
return q.select('*');
}
async function photoAverage() {
const photo = await db('photos').where('id', photoId).first();
return Number(photo.average_rating);
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: EVENT_SLUG,
event_type: 'wedding',
event_name: 'Rating Removal 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: 'rating-removal-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];
const r = await db('photos').insert({
event_id: eventId,
filename: 'photo-1.jpg',
path: 'events/rating-removal/1.jpg',
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = r[0]?.id ?? r[0];
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('photo_feedback').where('event_id', eventId).del();
await db('photos').where('id', photoId).update({ average_rating: 0, feedback_count: 0 });
});
describe('rating removal (#884)', () => {
test('rating 0 deletes the existing rating row and resets the average', async () => {
const created = await rate(4);
expect(created.created).toBe(true);
expect(await photoAverage()).toBe(4);
const removed = await rate(0);
expect(removed.removed).toBe(true);
expect(await ratingRows(GUEST_A)).toHaveLength(0);
expect(await photoAverage()).toBe(0);
});
test('rating 0 without an existing rating is a no-op (no 0-row inserted)', async () => {
const r = await rate(0);
expect(r.removed).toBe(true);
expect(await ratingRows()).toHaveLength(0);
expect(await photoAverage()).toBe(0);
});
test('removal is guest-scoped: guest B keeps their rating and the average', async () => {
await rate(2, GUEST_A);
await rate(4, GUEST_B);
expect(await photoAverage()).toBe(3);
const removed = await rate(0, GUEST_A);
expect(removed.removed).toBe(true);
expect(await ratingRows(GUEST_A)).toHaveLength(0);
expect(await ratingRows(GUEST_B)).toHaveLength(1);
expect(await photoAverage()).toBe(4);
});
test('numeric string "0" also clears (truthy-string bypass guard)', async () => {
await rate(4);
const removed = await rate('0');
expect(removed.removed).toBe(true);
expect(await ratingRows(GUEST_A)).toHaveLength(0);
expect(await photoAverage()).toBe(0);
});
test('malformed rating input never clears an existing rating', async () => {
await rate(4);
for (const bad of [undefined, null, 'bad', NaN]) {
const r = await rate(bad);
expect(r.removed).toBeFalsy();
}
expect(await ratingRows(GUEST_A)).toHaveLength(1);
});
test('clearing deletes racy duplicate rating rows, not just the first', async () => {
// Simulate the check-then-insert race: two rating rows for one guest.
const row = {
photo_id: photoId,
event_id: eventId,
feedback_type: 'rating',
guest_identifier: GUEST_A,
is_approved: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
await db('photo_feedback').insert({ ...row, rating: 3 });
await db('photo_feedback').insert({ ...row, rating: 5 });
expect(await ratingRows(GUEST_A)).toHaveLength(2);
const removed = await rate(0);
expect(removed.removed).toBe(true);
expect(await ratingRows(GUEST_A)).toHaveLength(0);
expect(await photoAverage()).toBe(0);
});
test('re-rating with a different value still updates in place', async () => {
await rate(3);
const updated = await rate(5);
expect(updated.updated).toBe(true);
const rows = await ratingRows(GUEST_A);
expect(rows).toHaveLength(1);
expect(rows[0].rating).toBe(5);
expect(await photoAverage()).toBe(5);
});
});