morphit/apps/indexer/test/integration/conversations.test.ts

413 lines
17 KiB
TypeScript

/**
* Integration test — /v1/conversations endpoint SQL.
*
* Exercises the GROUP BY + CASE expression the conversations
* endpoint uses to collapse bidirectional message flows into a
* single "peer" row per counterparty.
*
* Mirrors the SQL in src/api/conversations.ts. The most important
* property being tested: the CASE expression must fold messages
* from alice→bob AND bob→alice into the same "peer=bob" group
* when the query account is alice. Get that wrong and the user
* sees two entries for every conversation — once as peer when
* they replied, once as sender when they initiated.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import {
INTEGRATION_ENABLED,
setupWithMigrations,
truncateAll,
type IntegrationFixture
} from './harness';
// The production SELECT itself — imported, not duplicated (cp448). cp447 flagged
// that this test kept a hand-copied "kept in sync" mirror of the query, which had
// already forced the cp446 owner-join fix to be applied in two places. Importing
// the exported constant makes drift impossible: change the query once, this test
// exercises the change automatically. (Aliased to the old local name so the
// assertions below need no edits.)
import { CONVERSATIONS_SQL as CONVERSATIONS_SELECT } from '../../src/api/conversations';
/** Insert one chat_messages row with sensible defaults. Caller
* provides sender/recipient/created_at; other fields get fillers
* (the endpoint doesn't read them — only the columns the query
* references). source_trx_id must be unique per insert.
*
* `orderPermlink` (optional) sets the plaintext order_permlink
* column (migration 25) — the recipient is the order owner, per
* chat.ts's validator. */
let trxCounter = 0;
async function insertMessage(
fx: IntegrationFixture,
sender: string,
recipient: string,
createdAt: Date,
orderPermlink: string | null = null
): Promise<void> {
trxCounter += 1;
await fx.db.query(
`INSERT INTO chat_messages (
sender, recipient, ciphertext, header, created_at, source_trx_id, order_permlink
) VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7)`,
[
sender,
recipient,
'ciphertext_placeholder', // endpoint doesn't read this
{},
createdAt,
`trx_${trxCounter}`,
orderPermlink
]
);
}
/** Insert an order row (the "RE:" join target). Only the columns
* the conversations query reads matter to the assertions; the rest
* are filled to satisfy NOT-NULL constraints (price_model jsonb,
* payment_methods text[], created_at/updated_at). */
async function insertOrder(
fx: IntegrationFixture,
account: string,
permlink: string,
opts: {
side?: 'buy' | 'sell';
asset?: string;
fiat?: string;
amountMin?: number | null;
amountMax?: number | null;
status?: 'live' | 'cancelled' | 'expired';
} = {}
): Promise<void> {
await fx.db.query(
`INSERT INTO orders (
account, permlink, side, asset, fiat_currency, amount_min, amount_max,
price_model, payment_methods, status, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, '{}'::jsonb, $8, $9, $10, $10)`,
[
account,
permlink,
opts.side ?? 'buy',
opts.asset ?? 'BLURT',
opts.fiat ?? 'MXN',
opts.amountMin ?? null,
opts.amountMax ?? null,
[], // payment_methods text[]
opts.status ?? 'live',
new Date('2026-04-01T00:00:00Z')
]
);
}
describe.skipIf(!INTEGRATION_ENABLED)('conversations endpoint — SQL integration', () => {
let fx: IntegrationFixture;
beforeAll(async () => {
fx = await setupWithMigrations();
});
afterAll(async () => {
if (fx) await fx.teardown();
});
beforeEach(async () => {
await truncateAll(fx);
trxCounter = 0;
});
it('returns empty list for an account with no chat history', async () => {
const result = await fx.db.query(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(0);
});
it('collapses bidirectional messages with the same peer into one row', async () => {
// alice ↔ bob: three messages, mixed direction. Should
// render as ONE row with peer=bob when alice queries.
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'));
await insertMessage(fx, 'bob', 'alice', new Date('2026-04-23T11:00:00Z'));
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T12:00:00Z'));
const result = await fx.db.query<{
peer: string;
last_message_at: Date;
message_count: string;
}>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(1);
expect(result.rows[0]!.peer).toBe('bob');
expect(result.rows[0]!.message_count).toBe('3');
// last_message_at is the MAX of the three.
expect(result.rows[0]!.last_message_at.toISOString()).toBe('2026-04-23T12:00:00.000Z');
});
it('lists multiple peers, sorted newest-first by last message', async () => {
// alice's history: bob (oldest last msg), carol (middle),
// dave (newest). Expect order dave > carol > bob.
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-20T10:00:00Z'));
await insertMessage(fx, 'alice', 'carol', new Date('2026-04-22T10:00:00Z'));
await insertMessage(fx, 'dave', 'alice', new Date('2026-04-23T10:00:00Z'));
const result = await fx.db.query<{ peer: string }>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(3);
expect(result.rows.map((r) => r.peer)).toEqual(['dave', 'carol', 'bob']);
});
it('excludes conversations the account is not part of', async () => {
// bob ↔ carol, eve ↔ dave — alice is in neither.
await insertMessage(fx, 'bob', 'carol', new Date('2026-04-23T10:00:00Z'));
await insertMessage(fx, 'eve', 'dave', new Date('2026-04-23T11:00:00Z'));
// alice ↔ fred — the only one that should show for alice.
await insertMessage(fx, 'alice', 'fred', new Date('2026-04-23T12:00:00Z'));
const result = await fx.db.query<{ peer: string }>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(1);
expect(result.rows[0]!.peer).toBe('fred');
});
it('message_count aggregates correctly per peer', async () => {
// 5 msgs with bob, 2 with carol, 1 with dave.
for (let i = 0; i < 5; i += 1) {
await insertMessage(
fx,
i % 2 === 0 ? 'alice' : 'bob',
i % 2 === 0 ? 'bob' : 'alice',
new Date(`2026-04-23T10:${String(i).padStart(2, '0')}:00Z`)
);
}
await insertMessage(fx, 'alice', 'carol', new Date('2026-04-22T10:00:00Z'));
await insertMessage(fx, 'carol', 'alice', new Date('2026-04-22T11:00:00Z'));
await insertMessage(fx, 'dave', 'alice', new Date('2026-04-21T10:00:00Z'));
const result = await fx.db.query<{
peer: string;
message_count: string;
}>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(3);
const byPeer = Object.fromEntries(result.rows.map((r) => [r.peer, r.message_count]));
expect(byPeer.bob).toBe('5');
expect(byPeer.carol).toBe('2');
expect(byPeer.dave).toBe('1');
});
it('LIMIT clips to the requested number of rows', async () => {
// Insert 5 distinct peers; request LIMIT 3.
const peers = ['bob', 'carol', 'dave', 'eve', 'frank'];
for (let i = 0; i < peers.length; i += 1) {
await insertMessage(fx, 'alice', peers[i]!, new Date(`2026-04-${20 + i}T10:00:00Z`));
}
const result = await fx.db.query<{ peer: string }>(CONVERSATIONS_SELECT, ['alice', 3]);
expect(result.rowCount).toBe(3);
// Newest-first: frank, eve, dave (highest 3 created_ats).
expect(result.rows.map((r) => r.peer)).toEqual(['frank', 'eve', 'dave']);
});
it('returns correct peer when account is the SENDER', async () => {
// alice sent to bob. From alice's perspective, peer=bob.
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'));
const result = await fx.db.query<{ peer: string }>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(1);
expect(result.rows[0]!.peer).toBe('bob');
});
it('returns correct peer when account is the RECIPIENT', async () => {
// bob sent to alice. From alice's perspective, peer=bob.
await insertMessage(fx, 'bob', 'alice', new Date('2026-04-23T10:00:00Z'));
const result = await fx.db.query<{ peer: string }>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(1);
expect(result.rows[0]!.peer).toBe('bob');
});
// ─── "RE: <order>" subline (order LATERAL + orders join) ──────────
type OrderRow = {
peer: string;
order_permlink: string | null;
order_account: string | null;
order_side: string | null;
order_asset: string | null;
order_fiat_currency: string | null;
order_amount_min: string | null;
order_amount_max: string | null;
order_status: string | null;
};
// Ken (cp445) — the inbox card shows "(Live/Expired/Cancelled)" beside the
// RE: line, so a thread about a dead order says so before it is opened.
// NOTE: this SQL is a hand-kept COPY of `src/api/conversations.ts`. It has
// already drifted once; when you change one, change both.
it.each(['live', 'cancelled', 'expired'] as const)(
'surfaces the order status (%s) so the inbox can label the thread',
async (status) => {
await insertOrder(fx, 'bob', `order-${status}`, {
side: 'buy',
asset: 'BLURT',
fiat: 'MXN',
status
});
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'), `order-${status}`);
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rows[0]!.order_status).toBe(status);
}
);
// cp446 (Ken's mockup) — kentest3 talks to mariuszkarowski about TWO different
// orders. That is two discussions, so it is two cards.
it('splits one peer into one thread PER ORDER, like an email inbox', async () => {
await insertOrder(fx, 'bob', 'order-xmr', { side: 'sell', asset: 'XMR', fiat: 'MXN' });
await insertOrder(fx, 'bob', 'order-btc', { side: 'sell', asset: 'BTC', fiat: 'AUD' });
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'), 'order-xmr');
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-24T10:00:00Z'), 'order-btc');
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(2);
// Newest discussion first.
expect(result.rows[0]!.order_permlink).toBe('order-btc');
expect(result.rows[1]!.order_permlink).toBe('order-xmr');
expect(result.rows.every((r) => r.peer === 'bob')).toBe(true);
});
it('an order-less thread is its own card, alongside the order threads', async () => {
await insertOrder(fx, 'bob', 'order-xmr', { side: 'sell', asset: 'XMR', fiat: 'MXN' });
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'), 'order-xmr');
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-24T10:00:00Z'), null);
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(2);
expect(result.rows[0]!.order_permlink).toBeNull();
expect(result.rows[1]!.order_permlink).toBe('order-xmr');
});
// The owner is whichever PARTY owns an order with that permlink — not simply
// the message recipient, which is only the owner for the first message.
it('resolves the order owner from a reply, not just the opening message', async () => {
await insertOrder(fx, 'bob', 'order-xmr', { side: 'sell', asset: 'XMR', fiat: 'MXN' });
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'), 'order-xmr');
// bob REPLIES, so the newest citing message has recipient = alice.
await insertMessage(fx, 'bob', 'alice', new Date('2026-04-23T11:00:00Z'), 'order-xmr');
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(1);
expect(result.rows[0]!.order_account).toBe('bob');
});
it('order fields are all NULL when the conversation references no order', async () => {
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'));
await insertMessage(fx, 'bob', 'alice', new Date('2026-04-23T11:00:00Z'));
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(1);
const r = result.rows[0]!;
expect(r.order_permlink).toBeNull();
expect(r.order_account).toBeNull();
expect(r.order_asset).toBeNull();
});
it('surfaces the order when alice cites BOB\u2019s order (order_account = bob)', async () => {
// bob owns the order; alice messages bob citing it → the
// message recipient (bob) is the order owner.
await insertOrder(fx, 'bob', 'order-abc', {
side: 'buy',
asset: 'BLURT',
fiat: 'MXN',
amountMin: 500,
amountMax: null
});
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'), 'order-abc');
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(1);
const r = result.rows[0]!;
expect(r.order_permlink).toBe('order-abc');
expect(r.order_account).toBe('bob');
expect(r.order_side).toBe('buy');
expect(r.order_asset).toBe('BLURT');
expect(r.order_fiat_currency).toBe('MXN');
expect(r.order_amount_min).toBe('500'); // NUMERIC::text → frontend Number()
expect(r.order_amount_max).toBeNull();
});
it('surfaces the order when BOB cites ALICE\u2019s order (order_account = alice, the recipient)', async () => {
// alice owns the order; bob messages alice citing it → the
// recipient (alice) is the owner. RE: works in both directions.
await insertOrder(fx, 'alice', 'order-mine', { side: 'sell', asset: 'BTC', fiat: 'USD' });
await insertMessage(fx, 'bob', 'alice', new Date('2026-04-23T10:00:00Z'), 'order-mine');
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(1);
const r = result.rows[0]!;
expect(r.order_permlink).toBe('order-mine');
expect(r.order_account).toBe('alice');
expect(r.order_side).toBe('sell');
expect(r.order_asset).toBe('BTC');
});
// cp446 — REPLACES 'picks the MOST RECENT order-carrying message when several
// orders were discussed'. That test pinned the old collapse: several orders,
// one card, showing whichever was cited last. Ken asked for an email inbox, so
// several orders is now several cards — and this same fixture proves it.
// (Caught by CI on real Postgres; the sandbox cannot run the integration suite.)
it('gives each discussed order its own thread, newest first, plus the order-less one', async () => {
await insertOrder(fx, 'bob', 'order-old', { asset: 'BLURT' });
await insertOrder(fx, 'bob', 'order-new', { asset: 'LTC' });
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'), 'order-old');
await insertMessage(fx, 'bob', 'alice', new Date('2026-04-23T11:00:00Z')); // no order
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T12:00:00Z'), 'order-new');
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(3);
// Newest discussion first, by last_message_at.
expect(result.rows[0]!.order_permlink).toBe('order-new');
expect(result.rows[0]!.order_asset).toBe('LTC');
expect(result.rows[1]!.order_permlink).toBeNull(); // the order-less thread
expect(result.rows[2]!.order_permlink).toBe('order-old');
expect(result.rows[2]!.order_asset).toBe('BLURT');
// All three are with the same person.
expect(result.rows.every((r) => r.peer === 'bob')).toBe(true);
});
it('still surfaces a CANCELLED order (rows persist; RE: link stays useful)', async () => {
await insertOrder(fx, 'bob', 'order-dead', { status: 'cancelled', asset: 'XMR' });
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'), 'order-dead');
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rows[0]!.order_permlink).toBe('order-dead');
expect(result.rows[0]!.order_asset).toBe('XMR');
});
it('yields NULL order fields when the cited order row is absent (join miss)', async () => {
// A message cites a permlink but no matching orders row exists
// (should not happen — the validator requires it — but the
// LEFT JOIN must degrade to null, not error or leak a partial).
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'), 'order-ghost');
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(1);
expect(result.rows[0]!.order_permlink).toBeNull();
expect(result.rows[0]!.order_account).toBeNull();
});
it('returns a min/max range order intact', async () => {
await insertOrder(fx, 'bob', 'order-range', {
side: 'buy',
asset: 'BLURT',
fiat: 'EUR',
amountMin: 100,
amountMax: 250
});
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-23T10:00:00Z'), 'order-range');
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
const r = result.rows[0]!;
expect(r.order_amount_min).toBe('100');
expect(r.order_amount_max).toBe('250');
expect(r.order_fiat_currency).toBe('EUR');
});
it('attaches orders per-conversation, not across peers', async () => {
// alice ↔ bob cites bob's order; alice ↔ carol cites nothing.
await insertOrder(fx, 'bob', 'order-bob', { asset: 'BLURT' });
await insertMessage(fx, 'alice', 'bob', new Date('2026-04-22T10:00:00Z'), 'order-bob');
await insertMessage(fx, 'alice', 'carol', new Date('2026-04-23T10:00:00Z')); // newer, no order
const result = await fx.db.query<OrderRow>(CONVERSATIONS_SELECT, ['alice', 200]);
expect(result.rowCount).toBe(2);
const byPeer = Object.fromEntries(result.rows.map((r) => [r.peer, r.order_permlink]));
expect(byPeer.bob).toBe('order-bob');
expect(byPeer.carol).toBeNull();
});
});