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

763 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import handler from '$indexer/handlers/order';
import { makeCtx } from '../testutils/context';
import { makeMockClient } from '../testutils/mockClient';
import type { ChainOperation } from '$blurt/client';
function validPayload() {
return {
permlink: 'sell-btc-eur-2026-04',
side: 'sell',
asset: 'BTC',
fiat_currency: 'EUR',
amount_min: 50,
amount_max: 5000,
price_model: { kind: 'spread', percent: 1 },
payment_methods: ['sepa', 'revolut']
};
}
/** Build a matching fee transfer sibling op. Amount is "N.NNN BLURT"
* per Graphene convention. Memo must equal `morphit-fee:<permlink>`. */
function feeTransferOp(
sender: string,
amountBlurt: number,
permlink: string,
recipient = 'morphit-fees'
): ChainOperation {
return [
'transfer',
{
from: sender,
to: recipient,
amount: `${amountBlurt.toFixed(3)} BLURT`,
memo: `morphit-fee:${permlink}`
}
] as const;
}
describe('order handler', () => {
it('inserts a valid order', async () => {
// No siblingOps provided → fee_status='missing'. Path: no
// Sybil-count probe (skipped when transfer is null), just
// the INSERT.
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
const r = await handler(makeCtx({ signer: 'alice', payload: validPayload() }), mock.client);
expect(r).toEqual({ ok: true });
expect(mock.queries).toHaveLength(1);
});
it('accepts optional fields left out', async () => {
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
const p = validPayload();
// Drop optionals
const partial: Record<string, unknown> = { ...p };
delete partial.amount_min;
delete partial.amount_max;
const r = await handler(makeCtx({ payload: partial }), mock.client);
expect(r).toEqual({ ok: true });
});
it('rejects bad permlink charset', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({ payload: { ...validPayload(), permlink: 'HAS_CAPS' } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'permlink_bad_chars' });
expect(mock.queries).toHaveLength(0);
});
it('rejects unknown asset', async () => {
const mock = makeMockClient();
// Part 122 cp49 deep-deep A-2: this test previously used
// 'ETH' as the unknown-asset stand-in. ETH became a real
// tradable asset at cp47, silently breaking this test
// (handler returned ok:true instead of asset_invalid; the
// vitest unit test path was not part of the standalone
// smoke battery so the breakage went undetected for 2
// checkpoints). Cp49 fixes inline and pins the structural
// defense in cp49 LL #53 (handler-test-stand-in-meta-
// assertion-smoke): synthetic non-ticker '__UNKNOWN__'
// with underscores rejects from the canonical ticker
// regex which enforces uppercase letters only —
// mathematically cannot become a real ticker. Same
// pattern as cp48-O1's UNKNOWN_STANDIN closure but
// extended in scope to vitest unit tests.
const r = await handler(makeCtx({ payload: { ...validPayload(), asset: '__UNKNOWN__' } }), mock.client);
expect(r).toEqual({ ok: false, reason: 'asset_invalid' });
});
// ─── cp425: barter (accepted_assets) validation ─────────────────
// A BARTER (goods/services) order settles in one of a SET of cryptos
// the seller accepts. accepted_assets is REQUIRED (non-empty crypto set)
// for BARTER and FORBIDDEN for every crypto asset (they settle in
// themselves). Each entry must be a real crypto ticker, never a goods
// asset (no barter-for-barter).
function validBarterPayload() {
return {
permlink: 'sell-barter-mxn-2026-07',
side: 'sell',
asset: 'BARTER',
fiat_currency: 'MXN',
amount_min: 100,
amount_max: 500,
price_model: { kind: 'fixed', price: 250 },
// payment_methods semantics for barter are an increment-3 (post-
// flow) decision; the handler only requires a valid 1-12 array.
payment_methods: ['in-person'],
accepted_assets: ['XMR', 'BTC']
};
}
it('accepts a valid barter order (asset=BARTER + crypto accepted_assets)', async () => {
// BLURT default fee, no siblingOps → fee_status missing → just INSERT.
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
const r = await handler(makeCtx({ signer: 'alice', payload: validBarterPayload() }), mock.client);
expect(r).toEqual({ ok: true });
expect(mock.queries).toHaveLength(1);
});
it('rejects a barter order with no accepted_assets', async () => {
const mock = makeMockClient();
const p: Record<string, unknown> = { ...validBarterPayload() };
delete p.accepted_assets;
const r = await handler(makeCtx({ payload: p }), mock.client);
expect(r).toEqual({ ok: false, reason: 'accepted_assets_required_for_barter' });
expect(mock.queries).toHaveLength(0);
});
it('rejects a barter order with an empty accepted_assets set', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({ payload: { ...validBarterPayload(), accepted_assets: [] } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'accepted_assets_required_for_barter' });
expect(mock.queries).toHaveLength(0);
});
it('rejects a barter order that accepts a goods asset (no barter-for-barter)', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({ payload: { ...validBarterPayload(), accepted_assets: ['XMR', 'BARTER'] } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'accepted_assets_entry_not_crypto' });
expect(mock.queries).toHaveLength(0);
});
it('rejects a barter order that accepts an unknown ticker', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({ payload: { ...validBarterPayload(), accepted_assets: ['XMR', 'NOTACOIN'] } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'accepted_assets_entry_unknown' });
expect(mock.queries).toHaveLength(0);
});
it('rejects a CRYPTO order that carries accepted_assets (forbidden)', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({ payload: { ...validPayload(), accepted_assets: ['XMR'] } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'accepted_assets_not_permitted_for_asset' });
expect(mock.queries).toHaveLength(0);
});
it('rejects unknown side', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({ payload: { ...validPayload(), side: 'short' } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'side_invalid' });
});
it('rejects amount_min > amount_max', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: { ...validPayload(), amount_min: 100, amount_max: 50 }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'amount_min_exceeds_max' });
});
it('rejects negative amount_min', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: { ...validPayload(), amount_min: -1 }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'amount_min_negative' });
});
it('rejects empty payment_methods', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({ payload: { ...validPayload(), payment_methods: [] } }),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'payment_methods_bad_count' });
});
it('rejects payment_methods with too many entries', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: {
...validPayload(),
payment_methods: Array(13).fill('cash')
}
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'payment_methods_bad_count' });
});
it('rejects non-string payment_methods entry', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: { ...validPayload(), payment_methods: [123] }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'payment_method_item_invalid' });
});
// Finding L regression: size cap on price_model JSONB payload.
// The 4KB cap is enforced at intake; this test ensures future
// refactoring doesn't silently remove it.
it('rejects price_model exceeding 4KB serialized', async () => {
const mock = makeMockClient();
// Build a price_model that serializes to >4KB. A single
// string field ~4100 chars is over the cap.
const huge = 'x'.repeat(4100);
const r = await handler(
makeCtx({
payload: { ...validPayload(), price_model: { padding: huge } }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'price_model_too_large' });
// Critically: the INSERT must NOT run — if it did, a payload
// past the cap would hit the DB despite failing validation.
expect(mock.queries).toHaveLength(0);
});
it('accepts price_model right under the 4KB cap', async () => {
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
// Just under the cap — 3900 chars + wrapping overhead < 4096.
const r = await handler(
makeCtx({
payload: {
...validPayload(),
price_model: { padding: 'x'.repeat(3900) }
}
}),
mock.client
);
expect(r).toEqual({ ok: true });
});
it('accepts a valid ISO expires_at', async () => {
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
const r = await handler(
makeCtx({
payload: { ...validPayload(), expires_at: '2027-01-01T00:00:00Z' }
}),
mock.client
);
expect(r).toEqual({ ok: true });
});
it('rejects unparseable expires_at', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: { ...validPayload(), expires_at: 'not a date' }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'expires_at_unparseable' });
});
it('rejects oversized terms', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: { ...validPayload(), terms: 'x'.repeat(2049) }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'terms_too_long' });
});
it('cp422: accepts multi-line markdown terms (TAB/LF/CR permitted)', async () => {
// Regression: the terms field is a multi-line markdown textarea,
// but the strict forbidden-char regex covered the whole C0 range
// (\u0000-\u001F), rejecting every newline. Result: any order with
// multi-line terms was dropped on-chain AFTER its fee settled.
// Newlines + tabs must validate. Fee path is orthogonal here (no
// sibling transfer → inserts with fee_status='missing').
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
const r = await handler(
makeCtx({
signer: 'alice',
payload: {
...validPayload(),
terms: '# Big header\n\n**Bold** and *italic* text.\n\n> A blockquote line.\n\n1. First\n2. Second\n\n- a bullet\ttab too'
},
siblingOps: []
}),
mock.client
);
expect(r).toEqual({ ok: true });
});
it('cp422: still rejects a bidi override (RLO) in terms', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: { ...validPayload(), terms: 'cash\u202Eonly' }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'terms_forbidden_char' });
});
});
describe('order handler — fee verification', () => {
it('sets fee_status=missing when no sibling transfer', async () => {
// Only the INSERT runs (no Sybil count query, since transfer
// is null). The INSERT's 14th param is the fee_status.
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
const r = await handler(
makeCtx({
signer: 'alice',
payload: validPayload(),
siblingOps: []
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries).toHaveLength(1);
// fee_status is the 15th parameter (1-indexed param[14]); v.expires_at was inserted between blockTime and fee_status of the INSERT.
expect(mock.queries[0]!.params[14]).toBe('missing');
});
it('sets fee_status=verified on exact matching transfer', async () => {
// Valid tier-1 fee at $0.125 base and $0.002/BLURT = 62.5 BLURT.
// Sybil count probe returns 0 → nth=1 → expected 62.5.
const mock = makeMockClient([
{ match: 'SELECT COUNT', rows: [{ n: '0' }] },
{ match: 'INSERT INTO orders' }
]);
const payload = validPayload();
const r = await handler(
makeCtx({
signer: 'alice',
payload,
siblingOps: [feeTransferOp('alice', 62.5, payload.permlink)]
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries).toHaveLength(2);
expect(mock.queries[1]!.params[14]).toBe('verified');
});
it('accepts a transfer within the Model-A price-drift band (cp372)', async () => {
const mock = makeMockClient([
{ match: 'SELECT COUNT', rows: [{ n: '0' }] },
{ match: 'INSERT INTO orders' }
]);
const payload = validPayload();
// Model A (cp372): the BLURT acceptance floor is the chain-pinned
// base widened by FEE_PRICE_TOLERANCE (15%), so a user paying the
// live-DISPLAYED canonical amount isn't rejected when BLURT has
// appreciated up to 15% since the last chain-pin re-pin. Expected
// 62.5 → floor 62.5 × (1 0.15) = 53.125. 54 is ~13.6% below the
// pinned base — inside the band → verified. (The old 0.1% rounding
// tolerance is subsumed: Math.max(config.feeTolerance=0.001,
// FEE_PRICE_TOLERANCE=0.15) = 0.15, so 15% is always the floor.)
const r = await handler(
makeCtx({
signer: 'alice',
payload,
siblingOps: [feeTransferOp('alice', 54, payload.permlink)]
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries[1]!.params[14]).toBe('verified');
});
it('sets fee_status=underpaid below the Model-A price-drift floor (cp372)', async () => {
const mock = makeMockClient([
{ match: 'SELECT COUNT', rows: [{ n: '0' }] },
{ match: 'INSERT INTO orders' }
]);
const payload = validPayload();
// 52 is ~16.8% below the 62.5 expected — past the 15%
// FEE_PRICE_TOLERANCE floor (53.125) → underpaid. A payment can
// drift up to 15% below the pinned base (the displayed fee tracks
// the live USD target between re-pins); beyond that it's a genuine
// underpayment, not price drift.
const r = await handler(
makeCtx({
signer: 'alice',
payload,
siblingOps: [feeTransferOp('alice', 52, payload.permlink)]
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries[1]!.params[14]).toBe('underpaid');
});
it('ignores transfer from a different sender', async () => {
// Someone else paid a fee for alice's order? We don't treat
// that as a valid payment — fee must come from the signer.
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
const payload = validPayload();
const r = await handler(
makeCtx({
signer: 'alice',
payload,
siblingOps: [feeTransferOp('eve', 62.5, payload.permlink)]
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries).toHaveLength(1);
expect(mock.queries[0]!.params[14]).toBe('missing');
});
it('ignores transfer with wrong permlink in memo', async () => {
// Memo doesn't match the order's permlink — not this order's
// fee payment.
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
const payload = validPayload();
const r = await handler(
makeCtx({
signer: 'alice',
payload,
siblingOps: [feeTransferOp('alice', 62.5, 'different-permlink')]
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries[0]!.params[14]).toBe('missing');
});
it('ignores transfer to wrong recipient account', async () => {
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
const payload = validPayload();
const r = await handler(
makeCtx({
signer: 'alice',
payload,
siblingOps: [feeTransferOp('alice', 62.5, payload.permlink, 'wrong-account')]
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries[0]!.params[14]).toBe('missing');
});
it('uses escalated fee when signer has 3 existing orders', async () => {
// 3 existing → nth=4 → multiplier 1.25 → expected 78.125 BLURT.
const mock = makeMockClient([
{ match: 'SELECT COUNT', rows: [{ n: '3' }] },
{ match: 'INSERT INTO orders' }
]);
const payload = validPayload();
// Pay the correct escalated amount.
const r = await handler(
makeCtx({
signer: 'alice',
payload,
siblingOps: [feeTransferOp('alice', 78.125, payload.permlink)]
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries[1]!.params[14]).toBe('verified');
});
it('rejects tier-1 fee when signer is actually at tier 4', async () => {
// Signer has 3 existing → nth=4 → expected 78.125. If they
// only pay 62.5 (tier-1 amount), that's a 20% underpayment
// and should be rejected.
const mock = makeMockClient([
{ match: 'SELECT COUNT', rows: [{ n: '3' }] },
{ match: 'INSERT INTO orders' }
]);
const payload = validPayload();
const r = await handler(
makeCtx({
signer: 'alice',
payload,
siblingOps: [feeTransferOp('alice', 62.5, payload.permlink)]
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries[1]!.params[14]).toBe('underpaid');
});
});
describe('order handler — waived_first_buy (ADR-0011)', () => {
function waivedPayload() {
// Phase-3 / cp369 update: the waiver is BLURT-only, and the
// first-buy floor is a $1 USD-equivalent VALUE (amount_min is
// a fiat value, not a BLURT quantity). Override the BTC default
// from validPayload() and set an amount above the $1 floor.
return {
...validPayload(),
side: 'buy',
asset: 'BLURT',
amount_min: 4,
amount_max: 50,
fee_method: 'waived_first_buy'
};
}
it('accepts a valid waived first buy — no prior orders, waiver unclaimed', async () => {
// Mock SQL sequence:
// 1. SELECT COUNT(*) FROM orders WHERE account = signer → '0'
// 2. INSERT INTO accounts ... ON CONFLICT UPDATE ...
// RETURNING first_buy_waived_at (rowCount=1, claim won)
// 3. INSERT INTO orders ...
const mock = makeMockClient([
{ match: 'SELECT COUNT(*)', rows: [{ n: '0' }] },
{
match: 'INSERT INTO accounts',
rows: [{ first_buy_waived_at: new Date() }],
rowCount: 1
},
{ match: 'INSERT INTO orders' }
]);
const r = await handler(makeCtx({ signer: 'grandma', payload: waivedPayload() }), mock.client);
expect(r).toEqual({ ok: true });
expect(mock.queries).toHaveLength(3);
// The waived-branch INSERT hardcodes fee_status='verified'
// as a SQL literal (not a parameter). Verify it's present
// in the query text rather than looking for a param.
expect(mock.queries[2]!.text).toContain("'verified'");
});
it('rejects waived_first_buy when side is sell', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'alice',
payload: { ...waivedPayload(), side: 'sell' }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'waiver_requires_buy' });
// No SQL should have fired — the side check runs before DB
// access.
expect(mock.queries).toHaveLength(0);
});
it('rejects waived_first_buy when the account has prior orders', async () => {
// COUNT returns '2' → the waiver is no longer their first.
const mock = makeMockClient([{ match: 'SELECT COUNT(*)', rows: [{ n: '2' }] }]);
const r = await handler(makeCtx({ signer: 'bob', payload: waivedPayload() }), mock.client);
expect(r).toEqual({ ok: false, reason: 'waiver_not_first_order' });
// Only the count query ran; no INSERT attempted.
expect(mock.queries).toHaveLength(1);
});
it('rejects waived_first_buy when the waiver was already claimed', async () => {
// COUNT returns '0' (no prior orders in index — maybe the
// account re-registered, or the index was rebuilt), but the
// UPSERT returns rowCount=0 because first_buy_waived_at is
// already set. Race / re-use protection.
const mock = makeMockClient([
{ match: 'SELECT COUNT(*)', rows: [{ n: '0' }] },
{ match: 'INSERT INTO accounts', rowCount: 0 }
]);
const r = await handler(makeCtx({ signer: 'carol', payload: waivedPayload() }), mock.client);
expect(r).toEqual({ ok: false, reason: 'waiver_already_used' });
expect(mock.queries).toHaveLength(2);
});
it('rejects fee_method=btc when external_tx_id is missing', async () => {
// Sub-phase 4b: BTC fee payment is now supported but requires
// `external_tx_id` so the indexer can verify the on-chain BTC
// payment. Without it, reject early.
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: { ...validPayload(), fee_method: 'btc' }
}),
mock.client
);
expect(r).toEqual({
ok: false,
reason: 'external_tx_id_required_for_btc_xmr'
});
});
it('rejects fee_method=xmr when external_tx_id is missing', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: { ...validPayload(), fee_method: 'xmr' }
}),
mock.client
);
expect(r).toEqual({
ok: false,
reason: 'external_tx_id_required_for_btc_xmr'
});
});
it('Part 108++: rejects fee_method=xmr when tx_proof is missing', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: {
...validPayload(),
fee_method: 'xmr',
external_tx_id: 'a'.repeat(64)
}
}),
mock.client
);
expect(r).toEqual({
ok: false,
reason: 'tx_proof_required_for_xmr'
});
});
it('Part 108++: rejects fee_method=xmr when tx_proof has wrong prefix', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: {
...validPayload(),
fee_method: 'xmr',
external_tx_id: 'a'.repeat(64),
tx_proof: 'NotARealProofPrefix' + 'a'.repeat(64)
}
}),
mock.client
);
expect(r).toEqual({
ok: false,
reason: 'tx_proof_malformed_prefix'
});
});
it('Part 108++: rejects fee_method=xmr when tx_proof is too short', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: {
...validPayload(),
fee_method: 'xmr',
external_tx_id: 'a'.repeat(64),
tx_proof: 'OutProofV2tooshort'
}
}),
mock.client
);
expect(r).toEqual({
ok: false,
reason: 'tx_proof_malformed_length'
});
});
it('Part 108++: rejects fee_method=xmr when tx_proof has bad charset', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: {
...validPayload(),
fee_method: 'xmr',
external_tx_id: 'a'.repeat(64),
tx_proof: 'OutProofV2' + 'a'.repeat(60) + '\nbadchar'
}
}),
mock.client
);
expect(r).toEqual({
ok: false,
reason: 'tx_proof_malformed_charset'
});
});
it('Part 108++: fee_method=btc does NOT require tx_proof (only XMR does)', async () => {
// BTC verification path uses the multi-explorer cross-check;
// it does not need a per-payment proof from the user. This
// test confirms the handler's structural validator doesn't
// accidentally apply the XMR proof requirement to BTC orders.
// We don't actually configure a BTC verifier in the test
// harness, so the order will fail at a later check (e.g.
// `fee_method_not_configured_btc`) — but it must NOT fail
// with any tx_proof_* reason. That's the assertion.
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: {
...validPayload(),
fee_method: 'btc',
external_tx_id: 'a'.repeat(64)
// NO tx_proof field — handler should accept this for BTC.
}
}),
mock.client
);
// Whatever the rejection reason is, it must NOT be one of the
// new Part 108++ tx_proof_* validator codes.
if (!r.ok) {
expect(r.reason).not.toMatch(/^tx_proof/);
}
});
it('rejects fee_method with an unrecognized value', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: { ...validPayload(), fee_method: 'gold' }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'fee_method_unknown' });
});
it('rejects fee_method with a non-string value', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
payload: { ...validPayload(), fee_method: 42 }
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'fee_method_not_string' });
});
it('accepts omitted fee_method (back-compat with ADR-0009)', async () => {
// Pre-ADR-0011 orders don't have a fee_method field at all.
// The handler must treat them as ordinary BLURT-paid orders.
const mock = makeMockClient([{ match: 'INSERT INTO orders' }]);
const payload = validPayload(); // no fee_method field
const r = await handler(makeCtx({ payload }), mock.client);
expect(r).toEqual({ ok: true });
// Only the orders INSERT — no accounts UPSERT (not a waiver).
expect(mock.queries).toHaveLength(1);
});
});