* feat(feedback): let guests remove their star rating (#884) Clicking your current rating again clears it. rating: 0 is the wire contract: the validator now accepts 0, and the service deletes the guest's rating row (instead of storing a 0 that would drag the photo average down) and recalculates photo stats. The lightbox stars send 0 on a same-star click; PhotoRating already did, but the backend rejected it with a 400 until now. * fix(feedback): harden the rating-clear path (#884 review round) External review follow-ups: numerically normalize the clear sentinel so a numeric-string "0" can't slip into the update/insert paths (validator now also toInt()s), delete the full guest-scoped rating set on clear so racy duplicate rows can't survive in the average (same defense as the reaction path), and refresh the visible average/count after the identity-modal submit path like the direct paths do. * fix(feedback): round-2 review fixes for rating clear (#884) - Clear sentinel matches only an explicit 0 / "0" — malformed input (undefined, NaN, garbage strings) can no longer delete a rating. - Lightbox survives the photo list shrinking while open (clearing your rating under the Rated filter drops the photo on refetch): index is re-anchored and the lightbox closes when the list empties, instead of crashing on an out-of-range index. - Story layout gets the same same-star-to-clear behavior, keyed off the session-local my-rating map, and an explicit 0 no longer falls back to displaying the photo average. * fix(feedback): refresh guest-scoped caches after rating changes (#884 review round 3) - GalleryView's onFeedbackChange now also invalidates ['my-feedback', slug]: in guest identity mode the Rated/Liked filter membership and chip counts come from that query (#538), so a cleared rating never left the Rated filter until the 30s staleTime lapsed. - PhotoRating invalidates gallery-photos + my-feedback on success: the parent refetch fires optimistically in onMutate and could capture pre-mutation state, with nothing refreshing after the server accepted. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
435c558704
commit
6a048d08bd
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 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: '[email protected]',
|
||||
admin_email: '[email protected]',
|
||||
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];
|
||||
}, 30000);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user