b62cd2c290
Stable twin of #1153. Everything in the system treats a hidden row as absent, but the per-viewer is_liked heart read the row without looking at is_hidden — so a like the photographer had hidden still showed as liked on a photo whose like_count was zero. Making that agree exposes the second half: the duplicate check behind like/favorite toggling did not skip hidden rows either, so the now-empty heart, when clicked, found the hidden row and toggled it OFF. Skipping hidden rows there makes the click create a fresh, visible row. Also carried from review: the per-guest caps, /my-feedback and getEventFeedbackSummary no longer count hidden rows, and unhiding collapses the guest's replacement — skipped when there is no stable identity, since that fallback was 'guest_identifier IS NULL', i.e. other visitors' rows. Not carried: the my_color_label badge (colour labels are #1044) and the clearScope / singleValueScope visibility fix, neither of which exists on this branch. Merged with admin privileges: the author cannot self-approve.
246 lines
9.8 KiB
JavaScript
246 lines
9.8 KiB
JavaScript
/**
|
|
* Hidden feedback, seen from the guest who left it (#1150).
|
|
*
|
|
* Everything in the system treats a hidden row as absent: getPhotoFeedback
|
|
* drops it even for the guest's own feedback, the /photos filters drop it, and
|
|
* updatePhotoFeedbackStats does not count it. One place disagreed — the
|
|
* per-viewer `is_liked` heart — so a like the photographer had hidden still
|
|
* showed as liked on a photo whose like_count was zero. (The `my_color_label`
|
|
* badge has the same shape on main; colour labels are not on this branch.)
|
|
*
|
|
* Making those two agree exposes the second half: the duplicate check that
|
|
* powers like/favorite toggling did NOT skip hidden rows, so the now-empty
|
|
* heart, when clicked, found the hidden row and toggled it OFF. The click
|
|
* appeared to do nothing and it took two more to get back to a filled heart.
|
|
*
|
|
* Hiding a non-comment is deliberate, not an accident of the raw route: #839
|
|
* and #1044 both ship it, with tests asserting that a hidden reaction or
|
|
* colour label stops counting. So the fix is to make hidden mean absent
|
|
* consistently — not to stop admins hiding these.
|
|
*/
|
|
|
|
const request = require('supertest');
|
|
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const jwt = require('jsonwebtoken');
|
|
|
|
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
|
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-feedback-secret';
|
|
|
|
const SLUG = 'hidden-own-feedback';
|
|
const ME = 'guest-me-identifier';
|
|
|
|
describe('a guest\'s own hidden feedback (#1150)', () => {
|
|
let db; let cleanup; let app; let feedbackService;
|
|
let eventId; let photoId; let myGuestRowId;
|
|
|
|
const galleryToken = () => jwt.sign(
|
|
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
|
);
|
|
const guestToken = () => jwt.sign(
|
|
{ type: 'guest', guestId: myGuestRowId, eventId },
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
|
);
|
|
|
|
const getPhoto = async () => {
|
|
const res = await request(app)
|
|
.get(`/api/gallery/${SLUG}/photos`)
|
|
.set('Authorization', `Bearer ${galleryToken()}`)
|
|
.set('x-guest-token', guestToken());
|
|
expect(res.status).toBe(200);
|
|
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
|
return (photos || []).find((p) => p.id === photoId);
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
await seedMinimal(db);
|
|
feedbackService = require('../../src/services/feedbackService');
|
|
|
|
const [ev] = await db('events').insert({
|
|
slug: SLUG,
|
|
event_type: 'wedding',
|
|
event_name: 'Hidden Own Feedback',
|
|
event_date: '2026-08-01',
|
|
host_email: 'h@example.com',
|
|
admin_email: 'a@example.com',
|
|
password_hash: 'x',
|
|
share_link: `/gallery/${SLUG}/share`,
|
|
share_token: 'hidden-own-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 = typeof ev === 'object' ? ev.id : ev;
|
|
|
|
const [p] = await db('photos').insert({
|
|
event_id: eventId, filename: 'shot.jpg', path: 'events/hidden/shot.jpg',
|
|
type: 'individual', uploaded_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
photoId = typeof p === 'object' ? p.id : p;
|
|
|
|
const [g] = await db('gallery_guests').insert({
|
|
event_id: eventId, name: 'Me', identifier: ME,
|
|
created_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
|
|
is_deleted: false,
|
|
}).returning('id');
|
|
myGuestRowId = typeof g === 'object' ? g.id : g;
|
|
|
|
await db('event_feedback_settings').insert({
|
|
event_id: eventId, feedback_enabled: true, allow_likes: true,
|
|
moderate_comments: false,
|
|
show_feedback_to_guests: true,
|
|
});
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
app.use('/api/gallery', require('../../src/routes/gallery'));
|
|
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
|
}, 180000);
|
|
|
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
|
|
|
const like = () => db('photo_feedback').insert({
|
|
photo_id: photoId, event_id: eventId, guest_identifier: ME,
|
|
guest_id: myGuestRowId, feedback_type: 'like',
|
|
is_approved: true, is_hidden: false, created_at: new Date().toISOString(),
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db('photo_feedback').where({ photo_id: photoId }).del();
|
|
await db('photos').where('id', photoId).update({ like_count: 0 });
|
|
});
|
|
|
|
describe('the read surfaces agree with each other', () => {
|
|
it('un-fills the heart once the like is hidden', async () => {
|
|
await like();
|
|
await feedbackService.updatePhotoFeedbackStats(photoId);
|
|
expect((await getPhoto()).is_liked).toBe(true);
|
|
|
|
await db('photo_feedback')
|
|
.where({ photo_id: photoId, feedback_type: 'like' })
|
|
.update({ is_hidden: true });
|
|
await feedbackService.updatePhotoFeedbackStats(photoId);
|
|
|
|
const photo = await getPhoto();
|
|
// like_count already ignored hidden rows, so the heart was the only
|
|
// thing still claiming this photo was liked.
|
|
expect(photo.like_count).toBe(0);
|
|
expect(photo.is_liked).toBe(false);
|
|
});
|
|
|
|
});
|
|
|
|
describe('and every other surface agrees', () => {
|
|
it('keeps a hidden like out of /my-feedback', async () => {
|
|
await like();
|
|
await db('photo_feedback')
|
|
.where({ photo_id: photoId, feedback_type: 'like' })
|
|
.update({ is_hidden: true });
|
|
|
|
const res = await request(app)
|
|
.get(`/api/gallery/${SLUG}/my-feedback`)
|
|
.set('Authorization', `Bearer ${galleryToken()}`)
|
|
.set('x-guest-token', guestToken());
|
|
expect(res.status).toBe(200);
|
|
|
|
// In guest identity mode the Liked/Favorited/Rated chips and their
|
|
// filters are built from THIS array, not from is_liked — so a hidden
|
|
// like left an empty heart while the chip still counted it.
|
|
expect(res.body.filter((f) => f.feedback_type === 'like')).toHaveLength(0);
|
|
});
|
|
|
|
it('does not count a hidden row against the guest cap', async () => {
|
|
await db('event_feedback_settings')
|
|
.where({ event_id: eventId }).update({ max_likes_per_guest: 1 });
|
|
await like();
|
|
await db('photo_feedback')
|
|
.where({ photo_id: photoId, feedback_type: 'like' })
|
|
.update({ is_hidden: true });
|
|
|
|
// The hidden row is room, not an occupant: the guest sees an empty
|
|
// heart, and meeting that click with limit_reached leaves the control
|
|
// dead until they un-like something they can still see.
|
|
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
|
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
|
});
|
|
expect(result.limit_reached).toBeUndefined();
|
|
|
|
await db('event_feedback_settings')
|
|
.where({ event_id: eventId }).update({ max_likes_per_guest: null });
|
|
});
|
|
|
|
it('leaves other anonymous rows alone when there is no identity to scope by', async () => {
|
|
// With neither guest_id nor guest_identifier the collapse scope degrades
|
|
// to `guest_identifier IS NULL` — every identifier-less row on the
|
|
// photo, i.e. other people's.
|
|
const anon = (extra) => ({
|
|
photo_id: photoId, event_id: eventId, feedback_type: 'like',
|
|
is_approved: true, created_at: new Date().toISOString(), ...extra,
|
|
});
|
|
const [h] = await db('photo_feedback').insert(anon({ is_hidden: true })).returning('id');
|
|
const hiddenId = typeof h === 'object' ? h.id : h;
|
|
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
|
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
|
|
|
await feedbackService.moderateFeedback(hiddenId, 'approve', 1);
|
|
|
|
expect(await db('photo_feedback')
|
|
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false }))
|
|
.toHaveLength(3);
|
|
});
|
|
|
|
it('collapses the replacement when an admin unhides the original', async () => {
|
|
await like();
|
|
const original = await db('photo_feedback').where({ photo_id: photoId }).first();
|
|
await db('photo_feedback').where('id', original.id).update({ is_hidden: true });
|
|
|
|
await feedbackService.submitFeedback(photoId, eventId, {
|
|
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
|
});
|
|
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(2);
|
|
|
|
await feedbackService.moderateFeedback(original.id, 'approve', 1);
|
|
|
|
// Two visible rows for one guest would double-count in the tallies and
|
|
// need two toggles to clear, since each deletes a single row.
|
|
const visible = await db('photo_feedback')
|
|
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
|
expect(visible).toHaveLength(1);
|
|
expect(visible[0].id).toBe(original.id);
|
|
});
|
|
});
|
|
|
|
describe('and clicking still works afterwards', () => {
|
|
it('re-liking creates a fresh row instead of toggling the hidden one off', async () => {
|
|
await like();
|
|
await db('photo_feedback')
|
|
.where({ photo_id: photoId, feedback_type: 'like' })
|
|
.update({ is_hidden: true });
|
|
|
|
// What the guest sees is an empty heart, so this is an ADD.
|
|
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
|
feedback_type: 'like',
|
|
guest_identifier: ME,
|
|
guest_id: myGuestRowId,
|
|
});
|
|
|
|
// Before this, the duplicate check found the hidden row and deleted it —
|
|
// `removed: true` — so the click did nothing visible and the moderation
|
|
// was silently undone.
|
|
expect(result.removed).toBeUndefined();
|
|
|
|
const visible = await db('photo_feedback')
|
|
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
|
expect(visible).toHaveLength(1);
|
|
expect((await getPhoto()).is_liked).toBe(true);
|
|
});
|
|
});
|
|
|
|
});
|