fix(feedback): align word-filter severity vocabulary with the admin UI
WordFilterManager.tsx sends low/moderate/high/block; the validator only accepted mild/moderate/severe, so 3 of the 4 UI levels 400'd with "Invalid severity level" -- including "block", the strongest advertised tier. Aligning isIn() alone would have made "block" accepted but semantically inert: feedbackModeration.js branches on 'severe'/'moderate', so "block" would fall through to the flag-only branch and behave as the weakest level. Map the UI vocabulary onto the existing outcomes instead, per the legend the UI itself renders: block -> reject, moderate/high -> needs approval, low -> flag only. 'severe' stays an accepted alias in the blocking predicate so any row written through the old validator (the field is optional, so a direct API caller could have stored one) keeps blocking. No data migration needed: the column is a bare varchar(20) default 'moderate' with no CHECK, no enum and no seed rows, and 'mild' already lands in the flag-only branch that 'low' now means. Refs testplan REPORT.md #2 (Part 3, J.11).
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Word-filter severity vocabulary. The Settings → Moderation UI offers
|
||||
* low / moderate / high / block, but the validator only accepted the
|
||||
* unrelated mild / moderate / severe set, so 3 of the 4 levels — including
|
||||
* "block", the strongest tier — 400'd on every add.
|
||||
*/
|
||||
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-wfsev-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'wfsev-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-wfsev-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('word filter severity levels', () => {
|
||||
let db; let cleanup; let app; let superTok;
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${superTok}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId: superId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, superId, 'super_admin');
|
||||
superTok = mintAdminToken(superId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/feedback', require('../../src/routes/adminFeedback'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it.each(['low', 'moderate', 'high', 'block'])('accepts severity "%s"', async (severity) => {
|
||||
const word = `zzsev${severity}`;
|
||||
const res = await auth(request(app).post('/api/admin/feedback/word-filters'))
|
||||
.send({ word, severity });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('feedback_word_filters').where({ word }).first();
|
||||
expect(row.severity).toBe(severity);
|
||||
});
|
||||
|
||||
it('still rejects a severity outside the vocabulary', async () => {
|
||||
const res = await auth(request(app).post('/api/admin/feedback/word-filters'))
|
||||
.send({ word: 'zzsevbogus', severity: 'catastrophic' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.errors.some((e) => e.path === 'severity')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks a comment matching a "block" filter and only flags a "low" one', async () => {
|
||||
const moderation = require('../../src/services/feedbackModeration');
|
||||
moderation.clearCache();
|
||||
|
||||
const blocked = await moderation.moderateText('this is zzsevblock speech');
|
||||
expect(blocked.approved).toBe(false);
|
||||
expect(blocked.violations.map((v) => v.word)).toEqual(['zzsevblock']);
|
||||
|
||||
const flagged = await moderation.moderateText('this is zzsevlow speech');
|
||||
expect(flagged.approved).toBe(true);
|
||||
expect(flagged.flagged).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -66,17 +66,20 @@ class FeedbackModerationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for severe violations
|
||||
if (violations.some(v => v.severity === 'severe')) {
|
||||
// Check for blocking violations. 'severe' is the legacy vocabulary the
|
||||
// validator used to accept before it was aligned with the UI's
|
||||
// low/moderate/high/block levels — rows stored under it still apply.
|
||||
const isBlocking = (v) => v.severity === 'block' || v.severity === 'severe';
|
||||
if (violations.some(isBlocking)) {
|
||||
return {
|
||||
approved: false,
|
||||
reason: 'Content contains prohibited words',
|
||||
violations: violations.filter(v => v.severity === 'severe')
|
||||
violations: violations.filter(isBlocking)
|
||||
};
|
||||
}
|
||||
|
||||
// Check for moderate violations
|
||||
if (violations.some(v => v.severity === 'moderate')) {
|
||||
// Check for moderate/high violations
|
||||
if (violations.some(v => v.severity === 'moderate' || v.severity === 'high')) {
|
||||
return {
|
||||
approved: false,
|
||||
reason: 'Content requires moderation',
|
||||
@@ -84,7 +87,7 @@ class FeedbackModerationService {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for mild violations (may just flag for review)
|
||||
// Check for low-severity violations (may just flag for review)
|
||||
if (violations.length > 0) {
|
||||
return {
|
||||
approved: true,
|
||||
|
||||
@@ -241,7 +241,7 @@ const validateWordFilter = [
|
||||
.withMessage('Word must be between 2 and 100 characters'),
|
||||
body('severity')
|
||||
.optional()
|
||||
.isIn(['mild', 'moderate', 'severe'])
|
||||
.isIn(['low', 'moderate', 'high', 'block'])
|
||||
.withMessage('Invalid severity level')
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user