fix(feedback): make the "block" severity tier actually reject
The block level is advertised as "comment is rejected immediately", but every non-approved comment was saved with is_approved = false instead of the submission being refused. moderateText now sets an explicit `blocked: true` on the blocking-violation branch -- branching on the reason string in the route would have been fragile -- and the route 400s with code COMMENT_BLOCKED and stores nothing. Everything else that is not approved (moderate/high, the spam and caps checks, and the "Moderation system error" fallback) deliberately omits the flag and keeps the held-for-moderation path, so a moderation failure still fails safe. Also fixes an adjacent defect that made the tier split unobservable: feedbackService.submitFeedback ignored feedbackData.is_approved entirely and hard-derived is_approved from moderate_comments. So a moderate/high word-filter hit on an event with moderation switched OFF was published immediately -- the route's `feedbackData.is_approved = false` was dead code. Now honoured one-directionally: a caller-supplied false is respected, but nothing a caller passes can RELAX the event's setting. That deliberately leaves the route's reputation.autoApprove -> is_approved = true branch inert rather than letting a trusted guest bypass an event's moderation setting. Refs testplan REPORT.md B11. (cherry picked from commit b1b57b1615aaf02fe76e789a86b7e11933288d77)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Word-filter severity tiers, at the submission route.
|
||||
*
|
||||
* The Settings → Moderation UI advertises `block` as "comment is rejected
|
||||
* immediately", but the submit route saved every non-approved comment with
|
||||
* is_approved = false — identical handling to `moderate`/`high`. So the
|
||||
* strongest tier stored the prohibited text anyway and only hid it from the
|
||||
* public list.
|
||||
*
|
||||
* These three cases pin the tiers apart:
|
||||
* block → 4xx, nothing written
|
||||
* moderate / high → 201, stored held-for-moderation (is_approved = false)
|
||||
* low → 201, stored approved (flag-only)
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'block-severity-secret';
|
||||
|
||||
const SLUG = 'block-severity';
|
||||
|
||||
describe('word-filter severity tiers at submission (#B11)', () => {
|
||||
let db; let cleanup; let app;
|
||||
let eventId; let photoId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const comment = (text) => request(app)
|
||||
.post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.send({ feedback_type: 'comment', comment_text: text });
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Block Severity',
|
||||
event_date: '2026-08-01',
|
||||
host_email: '[email protected]',
|
||||
admin_email: '[email protected]',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'block-severity-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/${SLUG}/shot.jpg`,
|
||||
type: 'individual', uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId, feedback_enabled: true, allow_comments: true,
|
||||
moderate_comments: false, require_name_email: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
await db('feedback_word_filters').insert([
|
||||
{ word: 'zzblocked', severity: 'block', is_active: true, created_at: new Date().toISOString() },
|
||||
{ word: 'zzmoderated', severity: 'moderate', is_active: true, created_at: new Date().toISOString() },
|
||||
{ word: 'zzmild', severity: 'low', is_active: true, created_at: new Date().toISOString() },
|
||||
]);
|
||||
require('../../src/services/feedbackModeration').clearCache();
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photo_feedback').where({ photo_id: photoId }).del();
|
||||
});
|
||||
|
||||
it('rejects a "block" match outright and stores nothing', async () => {
|
||||
const res = await comment('this is zzblocked content');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('COMMENT_BLOCKED');
|
||||
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still holds a "moderate" match for moderation', async () => {
|
||||
const res = await comment('this is zzmoderated content');
|
||||
|
||||
expect(res.status).toBeLessThan(400);
|
||||
const rows = await db('photo_feedback').where({ photo_id: photoId });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect([false, 0]).toContain(rows[0].is_approved);
|
||||
expect(rows[0].comment_text).toContain('zzmoderated');
|
||||
});
|
||||
|
||||
it('lets a "low" match through approved (flag only)', async () => {
|
||||
const res = await comment('this is zzmild content');
|
||||
|
||||
expect(res.status).toBeLessThan(400);
|
||||
const rows = await db('photo_feedback').where({ photo_id: photoId });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect([true, 1]).toContain(rows[0].is_approved);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user