191 lines
7 KiB
TypeScript
191 lines
7 KiB
TypeScript
/**
|
|
* Loyalty welcome + milestone smoke.
|
|
*
|
|
* Exercises trackVerifiedBlurtFee:
|
|
* - First-fee welcome (1 BP, once-per-account).
|
|
* - Milestone crossings (10/50/200/1000 BP).
|
|
* - Cumulative BP target stays correct when both fire.
|
|
* - Idempotent on replay.
|
|
*/
|
|
|
|
import {
|
|
trackVerifiedBlurtFee,
|
|
FIRST_FEE_WELCOME_BP,
|
|
FIRST_FEE_WELCOME_SENTINEL_BLURT,
|
|
LOYALTY_MILESTONES
|
|
} from '../src/indexer/loyalty.ts';
|
|
import { makeMockClient } from '../test/testutils/mockClient.ts';
|
|
|
|
let failures = 0;
|
|
let scenarios = 0;
|
|
|
|
function scenario(name: string, fn: () => void | Promise<void>): Promise<void> {
|
|
scenarios++;
|
|
return Promise.resolve()
|
|
.then(fn)
|
|
.then(
|
|
() => console.log(` ✓ ${name}`),
|
|
(err) => {
|
|
failures++;
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
);
|
|
}
|
|
|
|
console.log('\n── Loyalty welcome + milestones ──────────────────────────');
|
|
|
|
// ─── Welcome fires once on first fee ────────────────────────────
|
|
|
|
await scenario('first verified BLURT fee queues a 1 BP delegation', async () => {
|
|
const mock = makeMockClient([
|
|
// upsert returns previous=0 (fresh row), new=10
|
|
{
|
|
match: /INSERT INTO account_loyalty/,
|
|
rows: [{ previous_total: '0', new_total: '10' }]
|
|
},
|
|
// G6: welcome INSERT now wrapped in SAVEPOINT.
|
|
{ match: /^SAVEPOINT first_fee_welcome_sp$/ },
|
|
// welcome milestone insert succeeds (no unique violation)
|
|
{ match: /INSERT INTO account_loyalty_milestones/, rowCount: 1 },
|
|
{ match: /^RELEASE SAVEPOINT first_fee_welcome_sp$/ },
|
|
// cumulative BP query → 1 BP (just the welcome row)
|
|
{
|
|
match: /SELECT COALESCE\(SUM\(bp_rewarded\)/,
|
|
rows: [{ cumulative_bp: '1' }]
|
|
},
|
|
// queue the delegation
|
|
{ match: /INSERT INTO relay_pending_transfers/, rowCount: 1 }
|
|
]);
|
|
|
|
await trackVerifiedBlurtFee(mock.client, 'alice', 10, 1000, new Date(), 'morphit', 'morphit');
|
|
|
|
// Verify the queue row carries the correct cumulative target.
|
|
const queueRow = mock.queries.find((q) => /relay_pending_transfers/.test(q.text));
|
|
if (!queueRow) throw new Error('no queue row');
|
|
const recipient = queueRow.params[0];
|
|
const cumBp = queueRow.params[1];
|
|
const reason = queueRow.params[2];
|
|
if (recipient !== 'alice') throw new Error(`recipient=${recipient}`);
|
|
if (Number(cumBp) !== 1) throw new Error(`cumBp=${cumBp} expected 1`);
|
|
if (reason !== 'first_listing_fee_welcome') throw new Error(`reason=${reason}`);
|
|
});
|
|
|
|
// ─── Welcome is idempotent ──────────────────────────────────────
|
|
|
|
await scenario('replay of fee does NOT double-queue welcome', async () => {
|
|
const mock = makeMockClient([
|
|
{
|
|
match: /INSERT INTO account_loyalty/,
|
|
rows: [{ previous_total: '10', new_total: '20' }]
|
|
},
|
|
// G6: welcome SAVEPOINT around the collision.
|
|
{ match: /^SAVEPOINT first_fee_welcome_sp$/ },
|
|
// unique violation = welcome already received
|
|
{
|
|
match: /INSERT INTO account_loyalty_milestones/,
|
|
throwError: { code: '23505' }
|
|
},
|
|
{ match: /^ROLLBACK TO SAVEPOINT first_fee_welcome_sp$/ },
|
|
{ match: /^RELEASE SAVEPOINT first_fee_welcome_sp$/ }
|
|
// NO cumulative query, NO queue row — welcome already happened
|
|
]);
|
|
|
|
await trackVerifiedBlurtFee(mock.client, 'alice', 10, 1001, new Date(), 'morphit', 'morphit');
|
|
|
|
const queueRow = mock.queries.find((q) => /relay_pending_transfers/.test(q.text));
|
|
if (queueRow) throw new Error('welcome should not re-queue');
|
|
});
|
|
|
|
// ─── Welcome + milestone fire together ──────────────────────────
|
|
|
|
await scenario('large first fee crosses welcome + 100 BLURT milestone', async () => {
|
|
const mock = makeMockClient([
|
|
// Fresh row, 100 BLURT paid in one go
|
|
{
|
|
match: /INSERT INTO account_loyalty/,
|
|
rows: [{ previous_total: '0', new_total: '100' }]
|
|
},
|
|
// G6: welcome SAVEPOINT + INSERT + RELEASE.
|
|
{ match: /^SAVEPOINT first_fee_welcome_sp$/ },
|
|
{ match: /INSERT INTO account_loyalty_milestones/, rowCount: 1 },
|
|
{ match: /^RELEASE SAVEPOINT first_fee_welcome_sp$/ },
|
|
// Cumulative after welcome → 1 BP
|
|
{
|
|
match: /SELECT COALESCE\(SUM\(bp_rewarded\)/,
|
|
rows: [{ cumulative_bp: '1' }]
|
|
},
|
|
// Welcome queue row
|
|
{ match: /INSERT INTO relay_pending_transfers/, rowCount: 1 },
|
|
// G6: milestone-100 SAVEPOINT + INSERT + RELEASE.
|
|
{ match: /^SAVEPOINT loyalty_ms_100_sp$/ },
|
|
{ match: /INSERT INTO account_loyalty_milestones/, rowCount: 1 },
|
|
{ match: /^RELEASE SAVEPOINT loyalty_ms_100_sp$/ },
|
|
// Cumulative after milestone → 1 + 10 = 11 BP
|
|
{
|
|
match: /SELECT COALESCE\(SUM\(bp_rewarded\)/,
|
|
rows: [{ cumulative_bp: '11' }]
|
|
},
|
|
// Milestone queue row (cumulative = 11)
|
|
{ match: /INSERT INTO relay_pending_transfers/, rowCount: 1 }
|
|
]);
|
|
|
|
await trackVerifiedBlurtFee(mock.client, 'bob', 100, 1002, new Date(), 'morphit', 'morphit');
|
|
|
|
const queueRows = mock.queries.filter((q) => /INSERT INTO relay_pending_transfers/.test(q.text));
|
|
if (queueRows.length !== 2) {
|
|
throw new Error(`expected 2 queue rows, got ${queueRows.length}`);
|
|
}
|
|
const welcome = queueRows[0];
|
|
const milestone = queueRows[1];
|
|
if (Number(welcome!.params[1]) !== 1) throw new Error('welcome should be 1 BP');
|
|
if (Number(milestone!.params[1]) !== 11) {
|
|
throw new Error(`milestone cumulative should be 11, got ${milestone!.params[1]}`);
|
|
}
|
|
});
|
|
|
|
// ─── Welcome BP value is the documented constant ────────────────
|
|
|
|
await scenario('welcome BP = 1', () => {
|
|
if (FIRST_FEE_WELCOME_BP !== 1) throw new Error(String(FIRST_FEE_WELCOME_BP));
|
|
});
|
|
|
|
await scenario('welcome sentinel = 0', () => {
|
|
if (FIRST_FEE_WELCOME_SENTINEL_BLURT !== 0)
|
|
throw new Error(String(FIRST_FEE_WELCOME_SENTINEL_BLURT));
|
|
});
|
|
|
|
// ─── Milestone schedule unchanged ───────────────────────────────
|
|
|
|
await scenario('loyalty milestones still 100/500/2000/10000 → 10/50/200/1000', () => {
|
|
const expected = [
|
|
[100, 10],
|
|
[500, 50],
|
|
[2000, 200],
|
|
[10000, 1000]
|
|
];
|
|
for (let i = 0; i < expected.length; i++) {
|
|
const ms = LOYALTY_MILESTONES[i];
|
|
if (!ms) throw new Error(`missing milestone ${i}`);
|
|
if (ms.thresholdBlurt !== expected[i]![0])
|
|
throw new Error(`threshold[${i}]=${ms.thresholdBlurt}`);
|
|
if (ms.bpReward !== expected[i]![1]) throw new Error(`reward[${i}]=${ms.bpReward}`);
|
|
}
|
|
});
|
|
|
|
// ─── Zero-amount fee no-op ──────────────────────────────────────
|
|
|
|
await scenario('zero-amount fee returns immediately', async () => {
|
|
const mock = makeMockClient();
|
|
await trackVerifiedBlurtFee(mock.client, 'alice', 0, 1003, new Date(), 'morphit', 'morphit');
|
|
if (mock.queries.length !== 0) throw new Error(`leaked queries: ${mock.queries.length}`);
|
|
});
|
|
|
|
console.log(`\n${'─'.repeat(54)}`);
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${scenarios} scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures}/${scenarios} scenarios failed`);
|
|
process.exit(1);
|
|
}
|