morphit/apps/indexer/test/handlers/feedback.test.ts

445 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import handler from '$indexer/handlers/feedback';
import { makeCtx } from '../testutils/context';
import { makeMockClient } from '../testutils/mockClient';
describe('feedback handler', () => {
it('inserts a valid feedback record', async () => {
const mock = makeMockClient([
{
match: 'FROM chat_messages',
rows: [{ from_a: '2', from_b: '2', span_seconds: '900', has_recip_flag: false }]
},
{ match: 'INSERT INTO feedback' }
]);
const r = await handler(
makeCtx({
signer: 'alice',
payload: { subject: 'bob', rating: 5, comment: 'Smooth trade' }
}),
mock.client
);
expect(r).toEqual({ ok: true });
});
it('rejects self-review', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'alice',
payload: { subject: 'alice', rating: 5 }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'self_review' });
expect(mock.queries).toHaveLength(0);
});
it('rejects rating out of 1..5', async () => {
const mock = makeMockClient();
for (const bad of [0, 6, -1, 2.5]) {
const r = await handler(
makeCtx({
signer: 'alice',
payload: { subject: 'bob', rating: bad }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'rating_out_of_range' });
}
});
it('rejects non-integer rating', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'alice',
payload: { subject: 'bob', rating: 3.7 }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'rating_out_of_range' });
});
it('rejects invalid subject account name', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'alice',
payload: { subject: 'X', rating: 4 }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'subject_invalid' });
});
it('translates pg unique-violation (23505) into duplicate_feedback', async () => {
const pgErr = Object.assign(new Error('duplicate key'), { code: '23505' });
const mock = makeMockClient([
{
match: 'FROM chat_messages',
rows: [{ from_a: '2', from_b: '2', span_seconds: '900', has_recip_flag: false }]
},
{ match: 'INSERT INTO feedback', throwError: pgErr }
]);
const r = await handler(
makeCtx({
signer: 'alice',
payload: { subject: 'bob', rating: 5 }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'duplicate_feedback' });
});
it('propagates non-unique-violation errors (poller will roll block back)', async () => {
const pgErr = Object.assign(new Error('connection lost'), {
code: '08006'
});
const mock = makeMockClient([
{
match: 'FROM chat_messages',
rows: [{ from_a: '2', from_b: '2', span_seconds: '900', has_recip_flag: false }]
},
{ match: 'INSERT INTO feedback', throwError: pgErr }
]);
await expect(
handler(
makeCtx({
signer: 'alice',
payload: { subject: 'bob', rating: 5 }
}),
mock.client
)
).rejects.toThrow('connection lost');
});
it('rejects oversized comment', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'alice',
payload: {
subject: 'bob',
rating: 5,
comment: 'x'.repeat(2049)
}
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'comment_too_long' });
});
});
describe('feedback handler — delayed welcome bonus (ADR-0011)', () => {
it('queues 10+10 welcome bonus when subject is a brand-new trader', async () => {
// Mock sequence (post-§8 refactor): conformance SELECT →
// feedback INSERT → SAVEPOINT → INSERT INTO accounts
// ON CONFLICT DO UPDATE (rowCount=1 means the subject's
// first_trade_complete_at just flipped) → INSERT into
// relay_pending_transfers → RELEASE SAVEPOINT. The
// welcome bonus only fires when the feedback cites an
// order_permlink (anti-Sybil gate).
const mock = makeMockClient([
{ match: 'FROM orders', rowCount: 1 },
{
match: 'FROM chat_messages',
rows: [{ from_a: '2', from_b: '2', span_seconds: '900', has_recip_flag: false }]
},
{ match: 'INSERT INTO feedback' },
{ match: 'SAVEPOINT' },
{
match: 'FROM orders\n\t\t\t WHERE account',
rows: [{ operator_tag: 'morphit' }],
rowCount: 1
},
{ match: 'INSERT INTO accounts', rowCount: 1 },
{ match: 'INSERT INTO relay_pending_transfers' },
{ match: 'RELEASE SAVEPOINT' }
]);
const r = await handler(
makeCtx({
signer: 'alice',
payload: {
subject: 'grandma',
rating: 5,
order_permlink: 'sell-btc-eur-2026-04'
}
}),
mock.client
);
expect(r).toEqual({ ok: true });
// Verify the INSERT into relay_pending_transfers carries the
// right subject and timestamp parameters.
const rptInsert = mock.queries.find((q) =>
q.text.includes('INSERT INTO relay_pending_transfers')
);
expect(rptInsert).toBeDefined();
expect(rptInsert!.params[0]).toBe('grandma');
});
it('does NOT queue bonus when ON CONFLICT DO UPDATE returns rowCount=0 (subject not new)', async () => {
// rowCount=0 means an existing accounts row already had a
// non-NULL first_trade_complete_at, so the upsert's WHERE
// clause didn't match. Result: no welcome bonus fires.
const mock = makeMockClient([
{ match: 'FROM orders', rowCount: 1 },
{
match: 'FROM chat_messages',
rows: [{ from_a: '2', from_b: '2', span_seconds: '900', has_recip_flag: false }]
},
{ match: 'INSERT INTO feedback' },
{ match: 'SAVEPOINT' },
{
match: 'FROM orders\n\t\t\t WHERE account',
rows: [{ operator_tag: 'morphit' }],
rowCount: 1
},
{ match: 'INSERT INTO accounts', rowCount: 0 },
{ match: 'RELEASE SAVEPOINT' }
]);
const r = await handler(
makeCtx({
signer: 'alice',
payload: {
subject: 'established',
rating: 5,
order_permlink: 'sell-btc-eur-2026-04'
}
}),
mock.client
);
expect(r).toEqual({ ok: true });
// No relay_pending_transfers insert should have fired.
const rptInsert = mock.queries.find((q) =>
q.text.includes('INSERT INTO relay_pending_transfers')
);
expect(rptInsert).toBeUndefined();
});
it('welcome bonus failure is isolated — feedback still succeeds', async () => {
// The accounts upsert throws. Our savepoint-rollback path
// handles it. Feedback INSERT succeeded before the savepoint
// opened, so the overall op is still ok:true.
const mock = makeMockClient([
{ match: 'FROM orders', rowCount: 1 },
{
match: 'FROM chat_messages',
rows: [{ from_a: '2', from_b: '2', span_seconds: '900', has_recip_flag: false }]
},
{ match: 'INSERT INTO feedback' },
{ match: 'SAVEPOINT' },
{
match: 'FROM orders\n\t\t\t WHERE account',
rows: [{ operator_tag: 'morphit' }],
rowCount: 1
},
{
match: 'INSERT INTO accounts',
throwError: new Error('simulated db hiccup')
},
{ match: 'ROLLBACK TO SAVEPOINT' }
]);
const r = await handler(
makeCtx({
signer: 'alice',
payload: {
subject: 'grandma',
rating: 5,
order_permlink: 'sell-btc-eur-2026-04'
}
}),
mock.client
);
expect(r).toEqual({ ok: true });
// The rollback must have fired to keep the outer transaction
// from being poisoned.
const rollback = mock.queries.find((q) => q.text.includes('ROLLBACK TO SAVEPOINT'));
expect(rollback).toBeDefined();
});
it('writes both welcome bonus rows in a single INSERT statement', async () => {
// Batching to minimise client round-trips. Check the SQL
// contains both VALUES clauses.
const mock = makeMockClient([
{ match: 'FROM orders', rowCount: 1 },
{
match: 'FROM chat_messages',
rows: [{ from_a: '2', from_b: '2', span_seconds: '900', has_recip_flag: false }]
},
{ match: 'INSERT INTO feedback' },
{ match: 'SAVEPOINT' },
{
match: 'FROM orders\n\t\t\t WHERE account',
rows: [{ operator_tag: 'morphit' }],
rowCount: 1
},
{ match: 'INSERT INTO accounts', rowCount: 1 },
{ match: 'INSERT INTO relay_pending_transfers' },
{ match: 'RELEASE SAVEPOINT' }
]);
await handler(
makeCtx({
signer: 'alice',
payload: {
subject: 'grandma',
rating: 5,
order_permlink: 'sell-btc-eur-2026-04'
}
}),
mock.client
);
const rptInsert = mock.queries.find((q) =>
q.text.includes('INSERT INTO relay_pending_transfers')
)!;
// Both rows + kind labels should be visible in the SQL text.
expect(rptInsert.text).toContain('welcome_bonus_liquid');
expect(rptInsert.text).toContain('welcome_bonus_vesting');
});
});
describe('feedback handler — provable-counterparty gate (cp420)', () => {
it('rejects a ghost review: no chat with the subject', async () => {
// from_a/from_b both 0 = the reviewer never had a
// conversation with the subject. Pre-cp420 this inserted (chat
// was only a badge); now it is a hard gate.
const mock = makeMockClient([
{
match: 'FROM chat_messages',
rows: [{ from_a: '0', from_b: '0', span_seconds: null, has_recip_flag: false }]
}
]);
const r = await handler(
makeCtx({ signer: 'alice', payload: { subject: 'bob', rating: 5 } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'no_verified_counterparty' });
// The INSERT must never have run.
expect(mock.queries.some((q) => q.text.includes('INSERT INTO feedback'))).toBe(false);
});
it('rejects a one-way review: subject never replied', async () => {
// Reviewer sent 3 messages, subject sent 0 — a one-way blast, not
// a two-way trade conversation. No inbound reply ⇒ no review.
const mock = makeMockClient([
{
match: 'FROM chat_messages',
rows: [{ from_a: '3', from_b: '0', span_seconds: '600', has_recip_flag: false }]
}
]);
const r = await handler(
makeCtx({ signer: 'alice', payload: { subject: 'bob', rating: 5 } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'no_verified_counterparty' });
});
it('rejects a review from a flagged suspicious-reciprocity pair', async () => {
// Bidirectional chat exists, but the sockpuppet detector already
// flagged this pair — blocked from reviewing each other.
const mock = makeMockClient([
{
match: 'FROM chat_messages',
rows: [{ from_a: '5', from_b: '5', span_seconds: '9000', has_recip_flag: true }]
}
]);
const r = await handler(
makeCtx({ signer: 'alice', payload: { subject: 'bob', rating: 5 } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'no_verified_counterparty' });
});
it('rejects a two-way chat below the verified-chat bar (1 each way, no 15-min span)', async () => {
// 1 message each way, no 15-min span — a real but ultra-fast
// exchange. Ken chose the STRICT gate (cp421): this is below the
// ≥2-each-way + ≥15-min bar, so it is rejected. (Under the older
// looser bidirectional-only gate this would have been accepted.)
const mock = makeMockClient([
{
match: 'FROM chat_messages',
rows: [{ from_a: '1', from_b: '1', span_seconds: null, has_recip_flag: false }]
}
]);
const r = await handler(
makeCtx({ signer: 'alice', payload: { subject: 'bob', rating: 5 } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'no_verified_counterparty' });
expect(mock.queries.some((q) => q.text.includes('INSERT INTO feedback'))).toBe(false);
});
it('rejects a two-way chat with enough messages but under the 15-min span', async () => {
// ≥2 each way but only a 5-minute span — fails the sustained-
// conversation requirement of the strict gate.
const mock = makeMockClient([
{
match: 'FROM chat_messages',
rows: [{ from_a: '4', from_b: '4', span_seconds: '300', has_recip_flag: false }]
}
]);
const r = await handler(
makeCtx({ signer: 'alice', payload: { subject: 'bob', rating: 5 } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'no_verified_counterparty' });
});
it('accepts a substantiated conversation (≥2 each way, ≥15-min span, unflagged)', async () => {
// The strict gate's accept case: 2 each way + a 900s (15-min)
// span + no reciprocity flag = a real, verifiable trade convo.
const mock = makeMockClient([
{
match: 'FROM chat_messages',
rows: [{ from_a: '2', from_b: '2', span_seconds: '900', has_recip_flag: false }]
},
{ match: 'INSERT INTO feedback' }
]);
const r = await handler(
makeCtx({ signer: 'alice', payload: { subject: 'bob', rating: 5 } }),
mock.client
);
expect(r).toEqual({ ok: true });
});
it('order citation accepts an order posted by the REVIEWER (maker reviewing taker on /my/orders)', async () => {
// The direction /my/orders uses: the maker (signer=alice, who
// posted the order) reviews the taker (bob), citing the maker's
// OWN order. Pre-cp420 the ownership check was `account = subject`
// and rejected this (account=alice≠bob). Now it is
// `account IN (subject, reviewer)`.
const mock = makeMockClient([
{ match: 'FROM orders', rowCount: 1 },
{
match: 'FROM chat_messages',
rows: [{ from_a: '2', from_b: '2', span_seconds: '900', has_recip_flag: false }]
},
{ match: 'INSERT INTO feedback' },
{ match: 'SAVEPOINT' },
{
match: 'FROM orders\n\t\t\t WHERE account',
rows: [{ operator_tag: 'morphit' }],
rowCount: 1
},
{ match: 'INSERT INTO accounts', rowCount: 0 },
{ match: 'RELEASE SAVEPOINT' }
]);
const r = await handler(
makeCtx({
signer: 'alice',
payload: { subject: 'bob', rating: 5, order_permlink: 'buy-btc-usd-2026-07' }
}),
mock.client
);
expect(r).toEqual({ ok: true });
// The ownership check must consult BOTH parties (account IN) and
// pass the reviewer (signer) as a bound param — not just subject.
const ownership = mock.queries.find(
(q) => q.text.includes('FROM orders') && q.text.includes('account IN')
);
expect(ownership).toBeDefined();
expect(ownership!.params).toContain('alice'); // reviewer/signer
expect(ownership!.params).toContain('bob'); // subject
});
});