morphit/apps/indexer/test/integration/feedback-suppression.test.ts

338 lines
13 KiB
TypeScript

/**
* Integration tests — /v1/accounts/:account/feedback +
* /v1/accounts/:account/feedback-given suppression flag.
*
* Part 118 closure of the gap caught after Part 117 sealed:
* the per-row `suppressed: boolean` flag on both endpoints
* previously only checked Signal A (related_accounts) and
* Signal B (suspicious_reciprocity). The summary aggregate
* at the top of the /feedback handler already correctly
* excluded Signal C (one_way_pile_on.attacking_reviewers) per
* Part 113's design — but the per-row flag did not, so a
* Signal C-flagged reviewer's row appeared on the subject
* profile WITHOUT the suppression chip while still being
* excluded from the headline rating. That's exactly the
* displayed-list-vs-summary inconsistency Finding R15 was
* meant to prevent for A+B.
*
* These tests exercise the full SQL pair-check across all
* three signal types against real Postgres. The unit-test
* tier can't meaningfully cover this — the suppression
* decision depends on jsonb_array_elements + jsonb operators
* (Signal C), greatest/least pair normalization (A+B), and
* the WHERE-EXISTS OR-chain in the same query. Postgres is
* the source of truth.
*
* Tests run only if TEST_DATABASE_URL is set; otherwise
* `describe.skipIf` skips the suite cleanly.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { Hono } from 'hono';
import { feedbackByAccountRoute } from '../../src/api/feedback';
import {
INTEGRATION_ENABLED,
setupWithMigrations,
truncateAll,
type IntegrationFixture
} from './harness';
interface FeedbackItem {
id: number;
reviewer: string;
subject: string;
rating: number;
suppressed: boolean;
has_verified_chat: boolean;
}
interface FeedbackResponse {
summary: {
count: number;
weighted_rating: number;
by_rating: Record<string, number>;
};
items: FeedbackItem[];
}
async function insertFeedback(
fx: IntegrationFixture,
reviewer: string,
subject: string,
rating: number,
trxId: string
): Promise<void> {
const permlink = `order-${trxId}`;
// The /feedback summary INNER JOINs orders on (subject, order_permlink)
// — cp124 H5, relying on the intake-time guarantee that the cited order
// exists, belongs to the subject, and is fee-verified. Seed that order so
// the feedback row survives the join and is counted (or, when a signal
// flags it, correctly EXCLUDED by suppression rather than silently dropped
// by a missing join). Without this the summary always reads 0.
await fx.db.query(
`INSERT INTO orders (
account, permlink, side, asset, fiat_currency, price_model,
payment_methods, status, fee_status, created_at, updated_at
) VALUES ($1, $2, 'buy', 'BLURT', 'MXN', '{}'::jsonb, $3, 'live', 'verified', NOW(), NOW())
ON CONFLICT (account, permlink) DO NOTHING`,
[subject, permlink, []]
);
await fx.db.query(
`INSERT INTO feedback (
reviewer, subject, rating, order_permlink, created_at, source_trx_id
) VALUES ($1, $2, $3, $4, NOW(), $5)`,
[reviewer, subject, rating, permlink, trxId]
);
}
/** Insert a (reviewer, subject) pair into related_accounts as a
* Signal A flag. The detector uses LEAST/GREATEST normalization,
* so the schema requires account_a < account_b by name; we
* enforce that here. */
async function flagSignalA(
fx: IntegrationFixture,
account_a: string,
account_b: string
): Promise<void> {
const [lo, hi] = account_a < account_b ? [account_a, account_b] : [account_b, account_a];
await fx.db.query(
`INSERT INTO related_accounts (account_a, account_b, reason, detected_at)
VALUES ($1, $2, 'same_creator_close_first_activity', NOW())`,
[lo, hi]
);
}
/** Insert a (reviewer, subject) pair into suspicious_reciprocity
* as a Signal B flag. */
async function flagSignalB(
fx: IntegrationFixture,
account_a: string,
account_b: string
): Promise<void> {
const [lo, hi] = account_a < account_b ? [account_a, account_b] : [account_b, account_a];
await fx.db.query(
`INSERT INTO suspicious_reciprocity (
account_a, account_b, mutual_review_count, avg_rating, detected_at
) VALUES ($1, $2, 2, 5.00, NOW())`,
[lo, hi]
);
}
/** Insert a Signal C pile-on detection. `subject` is the
* victim; `attackers` is the list of reviewers flagged for
* that subject. Schema: `attacking_reviewers JSONB NOT NULL`
* with the canonical `{reviewer, rating_avg, count,
* first_activity_at}` shape emitted by the detector at
* signals.ts. The lookup in feedback.ts only reads the
* `reviewer` field; the other fields are decoration matching
* the production shape for cleanliness. */
async function flagSignalC(
fx: IntegrationFixture,
subject: string,
attackers: readonly string[]
): Promise<void> {
const attackerObjects = attackers.map((r) => ({
reviewer: r,
rating_avg: 1.5,
count: 1,
first_activity_at: new Date().toISOString()
}));
await fx.db.query(
`INSERT INTO one_way_pile_on (
subject, attacking_reviewers, avg_rating, review_count,
review_window_days, activity_cluster_days
) VALUES ($1, $2::jsonb, 1.5, $3, 7, 14)`,
[subject, JSON.stringify(attackerObjects), attackers.length]
);
}
/** Mount the feedback route on a fresh Hono app and dispatch
* a GET against it; return the parsed body. */
async function fetchFeedback(
fx: IntegrationFixture,
account: string
): Promise<FeedbackResponse> {
const app = new Hono();
app.route('/v1/accounts', feedbackByAccountRoute(fx.db));
const res = await app.request(`/v1/accounts/${account}/feedback`);
expect(res.status).toBe(200);
return (await res.json()) as FeedbackResponse;
}
async function fetchFeedbackGiven(
fx: IntegrationFixture,
account: string
): Promise<FeedbackResponse> {
const app = new Hono();
app.route('/v1/accounts', feedbackByAccountRoute(fx.db));
const res = await app.request(`/v1/accounts/${account}/feedback-given`);
expect(res.status).toBe(200);
return (await res.json()) as FeedbackResponse;
}
describe.skipIf(!INTEGRATION_ENABLED)('feedback API — suppression flag covers Signals A+B+C', () => {
let fx: IntegrationFixture;
beforeAll(async () => {
fx = await setupWithMigrations();
});
afterAll(async () => {
if (fx) await fx.teardown();
});
beforeEach(async () => {
await truncateAll(fx);
});
describe('/feedback (received) — per-row suppressed flag', () => {
it('clean review is NOT suppressed and DOES count in summary', async () => {
await insertFeedback(fx, 'alice', 'bob', 5, 'trx-clean');
const body = await fetchFeedback(fx, 'bob');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(false);
expect(body.summary.count).toBe(1);
expect(body.summary.weighted_rating).toBe(5);
});
it('Signal A (related_accounts) flagged → suppressed=true, summary excludes', async () => {
await insertFeedback(fx, 'alice', 'bob', 5, 'trx-a');
await flagSignalA(fx, 'alice', 'bob');
const body = await fetchFeedback(fx, 'bob');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(true);
expect(body.summary.count).toBe(0);
});
it('Signal B (suspicious_reciprocity) flagged → suppressed=true, summary excludes', async () => {
await insertFeedback(fx, 'alice', 'bob', 5, 'trx-b');
await flagSignalB(fx, 'alice', 'bob');
const body = await fetchFeedback(fx, 'bob');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(true);
expect(body.summary.count).toBe(0);
});
it('Signal C (one_way_pile_on) flagged → suppressed=true, summary excludes (Part 118 fix)', async () => {
// This is the regression case. Pre-Part-118 the row
// appeared with suppressed=false WHILE summary count
// was 0 — list/summary visibly disagreed.
await insertFeedback(fx, 'alice', 'bob', 1, 'trx-c');
await flagSignalC(fx, 'bob', ['alice']);
const body = await fetchFeedback(fx, 'bob');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(true);
expect(body.summary.count).toBe(0);
});
it('Signal C flags only the specific (subject, reviewer) — other reviewers stay clean', async () => {
await insertFeedback(fx, 'alice', 'bob', 1, 'trx-c-alice');
await insertFeedback(fx, 'charlie', 'bob', 5, 'trx-clean-charlie');
// Only alice is in the attackers list.
await flagSignalC(fx, 'bob', ['alice']);
const body = await fetchFeedback(fx, 'bob');
expect(body.items.length).toBe(2);
const byReviewer = new Map(body.items.map((i) => [i.reviewer, i]));
expect(byReviewer.get('alice')!.suppressed).toBe(true);
expect(byReviewer.get('charlie')!.suppressed).toBe(false);
expect(body.summary.count).toBe(1);
expect(body.summary.weighted_rating).toBe(5);
});
it('Signal C multi-attacker — every named reviewer in the JSONB array is suppressed', async () => {
await insertFeedback(fx, 'alice', 'bob', 1, 'trx-multi-a');
await insertFeedback(fx, 'charlie', 'bob', 2, 'trx-multi-c');
await insertFeedback(fx, 'dan', 'bob', 5, 'trx-multi-d');
await flagSignalC(fx, 'bob', ['alice', 'charlie']);
const body = await fetchFeedback(fx, 'bob');
const byReviewer = new Map(body.items.map((i) => [i.reviewer, i]));
expect(byReviewer.get('alice')!.suppressed).toBe(true);
expect(byReviewer.get('charlie')!.suppressed).toBe(true);
expect(byReviewer.get('dan')!.suppressed).toBe(false);
expect(body.summary.count).toBe(1);
});
it('overlapping signals — A AND C on same pair still produces single suppressed=true', async () => {
// Both signals flag the same (reviewer, subject) pair.
// The OR-chain in the per-row query means either is
// sufficient; the row should be suppressed once.
await insertFeedback(fx, 'alice', 'bob', 1, 'trx-overlap');
await flagSignalA(fx, 'alice', 'bob');
await flagSignalC(fx, 'bob', ['alice']);
const body = await fetchFeedback(fx, 'bob');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(true);
expect(body.summary.count).toBe(0);
});
it('Signal C on a DIFFERENT subject does not suppress unrelated rows', async () => {
// owpo flags charlie as attacker against eve. alice's
// review of bob is unrelated and must stay clean.
await insertFeedback(fx, 'alice', 'bob', 5, 'trx-unrelated');
await flagSignalC(fx, 'eve', ['charlie']);
const body = await fetchFeedback(fx, 'bob');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(false);
expect(body.summary.count).toBe(1);
});
});
describe('/feedback-given (given) — per-row suppressed flag', () => {
it('clean review is NOT suppressed', async () => {
await insertFeedback(fx, 'alice', 'bob', 5, 'trx-given-clean');
const body = await fetchFeedbackGiven(fx, 'alice');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(false);
});
it('Signal A flag on (alice, bob) shows suppressed=true in alice\'s given list', async () => {
await insertFeedback(fx, 'alice', 'bob', 5, 'trx-given-a');
await flagSignalA(fx, 'alice', 'bob');
const body = await fetchFeedbackGiven(fx, 'alice');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(true);
});
it('Signal B flag on (alice, bob) shows suppressed=true in alice\'s given list', async () => {
await insertFeedback(fx, 'alice', 'bob', 5, 'trx-given-b');
await flagSignalB(fx, 'alice', 'bob');
const body = await fetchFeedbackGiven(fx, 'alice');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(true);
});
it('Signal C — alice in attackers list against bob → suppressed=true on alice\'s row (Part 118 fix)', async () => {
// Reviewer alice is the fixed account on /feedback-given;
// subject bob varies. The per-row check looks up
// (subject=row.subject, reviewer=$1) in
// owpo.attacking_reviewers.
await insertFeedback(fx, 'alice', 'bob', 1, 'trx-given-c');
await flagSignalC(fx, 'bob', ['alice']);
const body = await fetchFeedbackGiven(fx, 'alice');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(true);
});
it('Signal C — alice NOT in attackers list → her row stays unsuppressed even if subject has a pile-on', async () => {
// bob got piled on by charlie + dan, but NOT alice.
// alice's review of bob is unrelated to the pile-on.
await insertFeedback(fx, 'alice', 'bob', 4, 'trx-given-c-unrelated');
await flagSignalC(fx, 'bob', ['charlie', 'dan']);
const body = await fetchFeedbackGiven(fx, 'alice');
expect(body.items.length).toBe(1);
expect(body.items[0]!.suppressed).toBe(false);
});
it('Signal C — alice in attackers across MULTIPLE subjects → both rows suppressed', async () => {
// Alice is a serial Signal C attacker: flagged against
// bob AND against charlie. Her own /feedback-given
// view should show BOTH rows as suppressed.
await insertFeedback(fx, 'alice', 'bob', 1, 'trx-multi-b');
await insertFeedback(fx, 'alice', 'charlie', 2, 'trx-multi-c');
await flagSignalC(fx, 'bob', ['alice', 'dan']);
await flagSignalC(fx, 'charlie', ['alice', 'eve']);
const body = await fetchFeedbackGiven(fx, 'alice');
expect(body.items.length).toBe(2);
expect(body.items.every((i) => i.suppressed === true)).toBe(true);
});
});
});