* feat(feedback): a third identity mode with one shared colour tag per photo (#1197) Split out of #1178, where @boergu asked for a colour tag with no identity dimension at all: not everyone sharing a device's state, but everyone — on any device — sharing the PHOTO's state. Guest A marks it green, guest B later marks it orange, and the tag simply becomes orange. One collaboratively-agreed verdict per photo instead of per-person tallies. identity_mode gains 'shared'. The mode is scoped to the colour tag: likes, ratings, comments, favourites and reactions stay per-visitor exactly as in 'simple', because that is what was asked for and widening it would change what every other control means. Stored as an ordinary photo_feedback row under a reserved identifier rather than as a column on photos. That is what keeps the rest of the system working untouched — the per-colour tally simply has exactly one entry, so dominant_color_label, color_label_count, the admin colour filter and the XMP/CSV export that #745 reads all keep their existing shapes, and no consumer has to learn a second one. The identifier cannot be claimed: real ones are sha256 hashes or server-minted UUIDs, and the per-guest write path rejects it outright. Last write wins, inside a transaction that locks the photo row. Without the lock two guests tapping different colours in the same instant both read 'no tag', both insert, and the photo ends up carrying two shared tags — the per-guest tally this mode exists to remove. Re-sending the colour already on a photo clears it, from any guest: the same toggle every other colour path uses, and the only way to remove a tag without inventing a second control. Switching modes is non-destructive. Existing per-guest labels are left alone and simply not read while shared is on; the shared tag starts empty rather than collapsing marks nobody agreed on, and switching back restores every original exactly. An event can hold both sets, only one of which is live. The tag stays visible with show_feedback_to_guests off — it arrives through the per-viewer channel, being the photo's own state rather than someone else's opinion — while the per-colour tallies stay hidden. The colour filters answer from it for the same reason, so a gallery with sharing off cannot show colours on tiles that no filter can find. Attribution is gone by design, and the settings panel says so before an operator picks the mode. Decisions (1), (4) and (5) from the issue were settled up front, as it asked. Decision (3) turned out not to need anything: guest colour filters already read my_color_label, and the admin's my_color_labels filters photo_admin_marks (#1183), not guest identity — so nothing collapses on either side. * fix(feedback): shared mode saves on Postgres, and dormant labels stay dormant (#1197) Three findings from external review, all confirmed against source before fixing. **The mode could not be saved on Postgres at all.** Migration 078 created identity_mode with a CHECK constraint pinned to ('simple','guest'), guarded on `client === 'pg'` — so SQLite never has it and no SQLite test can see it, while the database every default production install runs rejects the new value outright. Migration 192 drops and re-adds the constraint with 'shared' included; its down() resets any event using the mode to 'simple' first, or the narrower constraint could not be restored. Verified against a real Postgres on a scratch database: the insert fails before, succeeds after, up() is re-runnable, and down() puts the old constraint back. **Dormant labels were still being read.** Switching modes is deliberately non-destructive, which leaves both sets of colour labels in the table with only one live — and every read that did not say which set it meant kept counting the other. The per-colour tallies, color_label_count, the admin grid badge, the XMP/CSV export, both admin colour filters and the guest colour filter all saw labels the mode does not show; switching back exposed the shared row as an anonymous other guest's dot. The settings panel promises these are 'kept but not shown', and that has to mean every surface, not just the badge. Scoped at the source — the two count helpers resolve the mode themselves — so the admin grid and the export are fixed without touching either. **The create form's identity mode was dropped.** CreateEventPage has always rendered the chooser and the create route never read it, so a gallery created as 'guest' came out 'simple' and had to be set again on the event afterwards. A pre-existing bug that adding a third option made worse; threaded through now, which fixes it for all three modes. Six regression tests, each verified to fail against the un-fixed code. * fix(feedback): keep every colour surface consistent across a mode change (#1197) Second review round, four findings, all confirmed in source first. **Stored counters went stale on a mode switch.** photos.color_label_count is denormalized and recomputed on feedback writes, so changing identity_mode — which changes nothing about the rows, only which of them are live — left the old mode's totals on the tiles, the admin grid and the filter summary until each photo happened to be touched again. On a finished gallery that is never. Recounted for the event when the mode actually changes, as two statements rather than a per-photo recompute: four of the five counters cannot have moved. **Duplicating an event dropped the mode**, the same shape as the create-form bug from the last round — a gallery cloned to reuse its proofing setup came back in 'simple'. **The event feedback summary counted dormant labels**, inflating total_feedback in the admin analytics and the guest /feedback-summary while every other surface hid them. **The swatch trusted its optimistic guess over the server.** In shared mode the tag belongs to the photo, so another guest can move it between this viewer's last read and their click: a viewer still showing green clicks green, the server sets green because the tag had become red meanwhile, and the optimistic 'same colour, so clear' blanked the swatch against a server that holds one. The response already says which happened, so it is used. The per-guest modes are unaffected — only the guest can move their own label, so guess and answer always agreed there. Three regression tests, each verified to fail against the un-fixed code. * fix(feedback): shared tag is not a participant, and the keyboard path reconciles too (#1197) Third review round, two findings. **feedback_count counted the shared tag as a guest.** It is COUNT(DISTINCT guest identity) across all feedback types, and the reserved identifier looked like a person: a photo with one rating and a shared tag reported two. The column is exported as rating_count (photoExportService), so merely tagging a photo inflated its rating count in the CSV and JSON exports. **The lightbox keyboard path still trusted its own guess.** The reconciliation from the last round covered clicks through PhotoColorLabels, but the proofing shortcuts call PhotoLightbox.submitColorLabel directly and set local state from a locally computed toggle. That is the path a proofing client actually uses, so it had the divergence the previous fix was for: another guest moves the tag, this viewer presses the key, the server sets a colour and the swatch blanks. Both branches now read the outcome off the response. One regression test, verified to fail against the un-fixed code. * fix(feedback): identity-mode lookup must survive a migration-time caller (#1197) updatePhotoFeedbackStats is called from migrations as well as from the request path — migration 186's duplicate-photo dedupe (#1162) recomputes the survivor's totals — and a migration runs against a half-built schema where event_feedback_settings need not exist yet. The new inner join threw there, which took the whole stats update down with it, so the reparented rows were never counted and eight assertions in the 186 suite failed. Falls back to 'simple', which is the right answer rather than merely a safe one: an install with no feedback settings table has no event in shared mode, so the non-shared scope is exactly correct. Caught by CI, not by me — I had been running affected suites rather than the full one after each review round. * fix(feedback): atomic shared-tag write, scoped feedback list, safe PG fallback (#1197) Round 4 of external review, and one of the three is about the fix I made for the CI failure two rounds ago. **The identity-mode fallback could poison a Postgres transaction.** The join was wrapped in try/catch so a migration-time caller with a half-built schema would fall back to 'simple'. On Postgres a failed statement aborts the entire transaction, so catching it and carrying on left the caller's trx poisoned and the aggregate that follows failed with 'current transaction is aborted' — defeating the very compatibility the fallback was added for. It now asks whether the table exists before issuing the join, which is safe to ask and aborts nothing. Memoised once true, since a table does not un-create itself and this sits on the feedback write path. **The shared-tag stats were recomputed after the commit.** A failure there returned 500 for a tag that had already been written, so the client reverted its swatch and the next tap on the same colour toggled the committed tag off instead of setting it. Two concurrent writers could also race their aggregate updates. Recomputed inside the transaction now, while the photo row is still locked. **The raw feedback list still carried both label sets.** Only the tallies and my_feedback had been scoped, so a dormant per-guest label was still visible to anyone reading the list — and with sharing off it came back flagged is_mine. getPhotoFeedback now filters colour labels to the active set. One test for the list; the migration suite that caught the original CI regression still passes. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
71eaf25d94
commit
22e00f80b6
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* The shared colour tag (#1197).
|
||||
*
|
||||
* A third identity model, requested in #1178: not "everyone shares a device's
|
||||
* state" but "everyone, on any device, shares the PHOTO's state". One
|
||||
* identity-less colour tag per photo, and whoever writes last wins.
|
||||
*
|
||||
* Stored as an ordinary photo_feedback row under a reserved identifier rather
|
||||
* than as a column on photos, which is what keeps the per-colour tallies, the
|
||||
* filters, the moderation queue and the XMP/CSV export working unchanged: the
|
||||
* tally simply has exactly one entry.
|
||||
*
|
||||
* The mode is scoped to the colour tag. Likes, ratings and the rest stay
|
||||
* per-guest, so the last test here is as important as the first.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
const { SHARED_COLOR_LABEL_IDENTITY } = require('../../src/constants/colorLabels');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'shared-tag-secret';
|
||||
|
||||
const SLUG = 'shared-color-tag';
|
||||
|
||||
describe('shared colour tag (#1197)', () => {
|
||||
let db; let cleanup; let app; let feedbackService;
|
||||
let eventId; let photoId; let otherPhotoId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
// Two different devices: distinct UA strings give distinct
|
||||
// generateGuestIdentifier hashes, which is exactly how `simple` mode tells
|
||||
// two anonymous guests apart.
|
||||
const asGuest = (ua) => ({ 'Authorization': `Bearer ${galleryToken()}`, 'User-Agent': ua });
|
||||
|
||||
const tag = (ua, color, id = photoId) => request(app)
|
||||
.post(`/api/gallery/${SLUG}/photos/${id}/feedback`)
|
||||
.set(asGuest(ua))
|
||||
.send({ feedback_type: 'color_label', color_label: color });
|
||||
|
||||
const photosFor = async (ua, query = '') => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos${query}`)
|
||||
.set(asGuest(ua));
|
||||
expect(res.status).toBe(200);
|
||||
return Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
};
|
||||
const photoFor = async (ua) => (await photosFor(ua)).find((p) => p.id === photoId);
|
||||
|
||||
const feedbackFor = async (ua) => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
|
||||
.set(asGuest(ua));
|
||||
expect(res.status).toBe(200);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
const sharedRows = () => db('photo_feedback').where({
|
||||
photo_id: photoId, feedback_type: 'color_label',
|
||||
guest_identifier: SHARED_COLOR_LABEL_IDENTITY,
|
||||
});
|
||||
|
||||
const setMode = (mode) => db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ identity_mode: mode });
|
||||
const setSharing = (on) => db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ show_feedback_to_guests: on });
|
||||
|
||||
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: 'Shared Colour Tag',
|
||||
event_date: '2026-08-01',
|
||||
host_email: '[email protected]',
|
||||
admin_email: '[email protected]',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'shared-tag-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: 'a.jpg', path: 'events/shared/a.jpg',
|
||||
type: 'individual', uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [p2] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'b.jpg', path: 'events/shared/b.jpg',
|
||||
type: 'individual', uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
otherPhotoId = typeof p2 === 'object' ? p2.id : p2;
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId, feedback_enabled: true, allow_likes: true,
|
||||
allow_color_labels: true, moderate_comments: false,
|
||||
show_feedback_to_guests: true, identity_mode: 'shared',
|
||||
});
|
||||
|
||||
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(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photo_feedback').where({ event_id: eventId }).del();
|
||||
await db('photos').where('event_id', eventId).update({ color_label_count: 0, like_count: 0 });
|
||||
await setMode('shared');
|
||||
await setSharing(true);
|
||||
});
|
||||
|
||||
describe('one tag per photo, last write wins', () => {
|
||||
it('lets a second guest overwrite the first guest\'s colour', async () => {
|
||||
// The request in the reporter's words: "if guest A marks a photo green
|
||||
// and guest B later marks the same photo orange, the shared tag simply
|
||||
// becomes orange".
|
||||
expect((await tag('device-A', 'green')).status).toBe(200);
|
||||
expect((await tag('device-B', 'blue')).status).toBe(200);
|
||||
|
||||
const rows = await sharedRows().select('color_label');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].color_label).toBe('blue');
|
||||
});
|
||||
|
||||
it('shows the same tag to a guest who never set one', async () => {
|
||||
await tag('device-A', 'green');
|
||||
// Different device, different identifier — in simple mode this would
|
||||
// read back null, which is the whole reason the mode exists.
|
||||
expect((await photoFor('device-B')).my_color_label).toBe('green');
|
||||
});
|
||||
|
||||
it('stores no attribution against the tag', async () => {
|
||||
await tag('device-A', 'green');
|
||||
const row = await sharedRows().first();
|
||||
expect(row.guest_id).toBeFalsy();
|
||||
expect(row.guest_name).toBeFalsy();
|
||||
expect(row.guest_email).toBeFalsy();
|
||||
});
|
||||
|
||||
it('clears the tag when any guest re-sends the colour already on it', async () => {
|
||||
await tag('device-A', 'green');
|
||||
// B, not A: clearing is not owned by whoever set it.
|
||||
expect((await tag('device-B', 'green')).status).toBe(200);
|
||||
|
||||
expect(await sharedRows().first()).toBeFalsy();
|
||||
expect((await photoFor('device-A')).my_color_label).toBeFalsy();
|
||||
});
|
||||
|
||||
it('leaves exactly one tag when two guests write at the same instant', async () => {
|
||||
// The race the transaction exists for: both writers read "no tag", both
|
||||
// insert, and the photo ends up carrying two shared tags — a per-guest
|
||||
// tally in the one mode that is supposed to have none.
|
||||
await Promise.all([
|
||||
tag('device-A', 'green'),
|
||||
tag('device-B', 'red'),
|
||||
tag('device-C', 'blue'),
|
||||
]);
|
||||
|
||||
const rows = await sharedRows().select('color_label');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(['green', 'red', 'blue']).toContain(rows[0].color_label);
|
||||
});
|
||||
|
||||
it('keeps the tag on the photo it was set on', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await tag('device-B', 'red', otherPhotoId);
|
||||
|
||||
const photos = await photosFor('device-C');
|
||||
expect(photos.find((p) => p.id === photoId).my_color_label).toBe('green');
|
||||
expect(photos.find((p) => p.id === otherPhotoId).my_color_label).toBe('red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the aggregates keep their existing shape', () => {
|
||||
it('reports the tally as a single colour with a count of one', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await tag('device-B', 'red');
|
||||
|
||||
// Unchanged consumers — grid badge, XMP export, admin filter — read
|
||||
// this map and dominantColorLabel() over it. One entry, so the dominant
|
||||
// colour is simply the tag.
|
||||
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({ red: 1 });
|
||||
const photo = await db('photos').where('id', photoId).first();
|
||||
expect(photo.color_label_count).toBe(1);
|
||||
});
|
||||
|
||||
it('does not render the tag a second time as another viewer\'s dot', async () => {
|
||||
await tag('device-A', 'green');
|
||||
// The shared row is not filed under device-B, so without the mode check
|
||||
// it would come back as "someone else's label" and the tile would show
|
||||
// the same green twice — once as the badge, once as a dot beside it.
|
||||
const photo = await photoFor('device-B');
|
||||
expect(photo.my_color_label).toBe('green');
|
||||
expect(photo.other_color_labels || []).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with show_feedback_to_guests off', () => {
|
||||
it('still shows the tag — it is the photo\'s state, not someone else\'s opinion', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setSharing(false);
|
||||
|
||||
expect((await photoFor('device-B')).my_color_label).toBe('green');
|
||||
expect((await feedbackFor('device-B')).my_feedback.color_label).toBe('green');
|
||||
});
|
||||
|
||||
it('still hides the per-colour tallies', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setSharing(false);
|
||||
expect((await feedbackFor('device-B')).color_labels).toEqual({});
|
||||
});
|
||||
|
||||
it('answers a colour filter from the shared tag', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setSharing(false);
|
||||
|
||||
// The aggregate half of this filter is gated on sharing; in shared mode
|
||||
// the tag counts as the viewer's own, so the filter still works.
|
||||
const filtered = await photosFor('device-B', '?filter=color:green');
|
||||
expect(filtered.map((p) => p.id)).toEqual([photoId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('switching modes is not destructive', () => {
|
||||
it('ignores per-guest labels while shared, and gives them back afterwards', async () => {
|
||||
await setMode('simple');
|
||||
await tag('device-A', 'green');
|
||||
await tag('device-B', 'red');
|
||||
const perGuestRows = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'color_label' }).count('* as c').first();
|
||||
expect(Number(perGuestRows.c)).toBe(2);
|
||||
|
||||
await setMode('shared');
|
||||
// Nothing collapsed, nothing guessed: the shared tag starts empty.
|
||||
expect((await photoFor('device-A')).my_color_label).toBeFalsy();
|
||||
expect(await sharedRows().first()).toBeFalsy();
|
||||
|
||||
await setMode('simple');
|
||||
// ...and every original mark is exactly where its owner left it.
|
||||
expect((await photoFor('device-A')).my_color_label).toBe('green');
|
||||
expect((await photoFor('device-B')).my_color_label).toBe('red');
|
||||
});
|
||||
|
||||
it('keeps the shared tag intact across a round trip through simple mode', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setMode('simple');
|
||||
await setMode('shared');
|
||||
expect((await photoFor('device-B')).my_color_label).toBe('green');
|
||||
});
|
||||
|
||||
// "Kept but not shown" has to mean every surface, not just the badge. The
|
||||
// dormant set is still sitting in photo_feedback, so a read that does not
|
||||
// say which set it means will happily count, tally, filter and export it.
|
||||
describe('the dormant set stays out of every reading of the live one', () => {
|
||||
const seedDormantPerGuestLabels = async () => {
|
||||
await setMode('simple');
|
||||
await tag('device-A', 'green');
|
||||
await tag('device-B', 'red', otherPhotoId);
|
||||
await setMode('shared');
|
||||
};
|
||||
|
||||
it('keeps dormant labels out of the per-colour tallies', async () => {
|
||||
await seedDormantPerGuestLabels();
|
||||
// The lightbox renders this map. Green belongs to a per-guest row the
|
||||
// mode does not use, so the photo reads as untagged.
|
||||
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({});
|
||||
|
||||
await tag('device-C', 'blue');
|
||||
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({ blue: 1 });
|
||||
});
|
||||
|
||||
it('keeps dormant labels out of color_label_count', async () => {
|
||||
await seedDormantPerGuestLabels();
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
|
||||
|
||||
await tag('device-C', 'blue');
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps dormant labels out of the admin grid and the XMP/CSV export', async () => {
|
||||
await seedDormantPerGuestLabels();
|
||||
// getEventColorLabelCounts feeds dominant_color_label, which is what
|
||||
// the admin badge shows and what xmp:Label round-trips into Lightroom.
|
||||
const map = await feedbackService.getEventColorLabelCounts(eventId, [photoId, otherPhotoId]);
|
||||
expect(map[photoId]).toBeUndefined();
|
||||
expect(map[otherPhotoId]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps dormant labels out of the raw feedback list too', async () => {
|
||||
// The per-colour tallies were scoped, but the endpoint also returns the
|
||||
// rows themselves. Those carried both sets, so a dormant per-guest
|
||||
// label was still visible to anyone reading the list — and with
|
||||
// sharing off it came back flagged as the caller's own.
|
||||
await seedDormantPerGuestLabels();
|
||||
const body = await feedbackFor('device-A');
|
||||
expect(body.feedback.filter((f) => f.feedback_type === 'color_label')).toHaveLength(0);
|
||||
expect(body.my_feedback.color_label).toBeFalsy();
|
||||
|
||||
await tag('device-A', 'blue');
|
||||
const after = await feedbackFor('device-B');
|
||||
const labels = after.feedback.filter((f) => f.feedback_type === 'color_label');
|
||||
expect(labels.map((f) => f.color_label)).toEqual(['blue']);
|
||||
});
|
||||
|
||||
it('does not answer a guest colour filter from a dormant label', async () => {
|
||||
await seedDormantPerGuestLabels();
|
||||
expect(await photosFor('device-A', '?filter=color:green')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not show the shared tag as another guest\'s dot after switching back', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setMode('simple');
|
||||
// The shared row belongs to nobody, so in a per-guest mode it is not
|
||||
// "another viewer's label" either — it is simply not in play.
|
||||
const photo = await photoFor('device-B');
|
||||
expect(photo.my_color_label).toBeFalsy();
|
||||
expect(photo.other_color_labels || []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('recounts the stored per-photo counter when the mode changes', async () => {
|
||||
// color_label_count is denormalized and recomputed on feedback writes.
|
||||
// A mode switch changes which rows are live without any write, so
|
||||
// without an explicit recount the tiles and the admin summary keep
|
||||
// reporting the old mode's totals until each photo is touched again —
|
||||
// on a finished gallery, never.
|
||||
await setMode('simple');
|
||||
await tag('device-A', 'green');
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
|
||||
|
||||
await feedbackService.updateEventFeedbackSettings(eventId, { identity_mode: 'shared' });
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
|
||||
|
||||
await feedbackService.updateEventFeedbackSettings(eventId, { identity_mode: 'simple' });
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
|
||||
});
|
||||
|
||||
it('does not count the shared tag as a participant', async () => {
|
||||
// feedback_count is COUNT(DISTINCT guest identity) and is exported as
|
||||
// rating_count. The shared tag has a reserved identifier rather than a
|
||||
// person's, so counting it made tagging a photo look like a second
|
||||
// guest had left feedback — and inflated the exported rating count.
|
||||
await request(app)
|
||||
.post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
|
||||
.set(asGuest('device-A'))
|
||||
.send({ feedback_type: 'rating', rating: 5 });
|
||||
expect((await db('photos').where('id', photoId).first()).feedback_count).toBe(1);
|
||||
|
||||
await tag('device-B', 'green');
|
||||
expect((await db('photos').where('id', photoId).first()).feedback_count).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps dormant labels out of the event feedback summary', async () => {
|
||||
await seedDormantPerGuestLabels();
|
||||
// Feeds the admin analytics total_feedback and the guest
|
||||
// /feedback-summary response.
|
||||
const summary = await feedbackService.getEventFeedbackSummary(eventId);
|
||||
expect(Number(summary.stats.total_color_labels)).toBe(0);
|
||||
|
||||
await tag('device-C', 'blue');
|
||||
expect(Number((await feedbackService.getEventFeedbackSummary(eventId)).stats.total_color_labels)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not count the shared tag once the event is back on per-guest labels', async () => {
|
||||
await tag('device-A', 'green');
|
||||
await setMode('simple');
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
|
||||
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the mode is scoped to the colour tag', () => {
|
||||
it('keeps likes per-guest in shared mode', async () => {
|
||||
const like = (ua) => request(app)
|
||||
.post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
|
||||
.set(asGuest(ua))
|
||||
.send({ feedback_type: 'like' });
|
||||
|
||||
expect((await like('device-A')).status).toBe(200);
|
||||
expect((await photoFor('device-A')).is_liked).toBe(true);
|
||||
// B has not liked it. If 'shared' leaked past colour labels, this would
|
||||
// come back true and B's click would un-like A's like.
|
||||
expect((await photoFor('device-B')).is_liked).toBe(false);
|
||||
|
||||
await like('device-B');
|
||||
const photo = await db('photos').where('id', photoId).first();
|
||||
expect(photo.like_count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the reserved identity cannot be claimed', () => {
|
||||
it('refuses a per-guest write that arrives under it', async () => {
|
||||
// Not reachable through the routes — a guest identifier is either a
|
||||
// sha256 hex or a server-minted UUID — but a future caller must not be
|
||||
// able to write the photo's shared tag as if it were their own.
|
||||
await expect(feedbackService.submitFeedback(
|
||||
photoId, eventId,
|
||||
{ feedback_type: 'color_label', color_label: 'green' },
|
||||
SHARED_COLOR_LABEL_IDENTITY,
|
||||
)).rejects.toThrow('Reserved guest identifier');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user