morphit/apps/indexer/test/api/accountBalance.test.ts

218 lines
8.2 KiB
TypeScript

/**
* Unit test — GET /v1/account/:account/balance (cp295).
*
* Exercises the HTTP layer with a stubbed BlurtClient (the live RPC
* round-trip is covered by the same proven blurt.getAccount path the
* poller/scanners use and can't run in CI without a chain node). We
* verify: the happy-path body shape + cache header, 404 for a missing
* account, 400 for a malformed name, and 502 for both an RPC throw and
* an incomplete upstream response.
*/
import { describe, expect, it } from 'vitest';
import { Hono } from 'hono';
import { accountBalanceRoute } from '$api/accountBalance';
import type { BlurtClient, ChainAccount, DynamicGlobalProperties } from '$blurt/client';
const AUTH = { weight_threshold: 1, account_auths: [], key_auths: [] };
const FULL_ACCOUNT: ChainAccount = {
name: 'alice',
posting: AUTH,
active: AUTH,
owner: AUTH,
memo_key: 'BLT1111111111111111111111111111111114T1Anm',
balance: '42.500 BLURT',
vesting_shares: '1000000.000000 VESTS',
received_vesting_shares: '50000.000000 VESTS',
delegated_vesting_shares: '20000.000000 VESTS',
voting_manabar: { current_mana: '900000', last_update_time: 1_700_000_000 }
};
const FULL_DGP: DynamicGlobalProperties = {
head_block_number: 12_345,
last_irreversible_block_num: 12_300,
time: '2026-06-19T00:00:00',
total_vesting_fund_blurt: '1000000.000 BLURT',
total_vesting_shares: '2000000.000000 VESTS',
current_supply: '5000000.000 BLURT'
};
/** Mount the route with a partial BlurtClient stub. */
function mount(stub: Partial<BlurtClient>): Hono {
const app = new Hono();
app.route('/v1/account', accountBalanceRoute(stub as unknown as BlurtClient));
return app;
}
type BalanceBody = {
account: {
name: string;
balance: string;
vesting_shares: string;
received_vesting_shares: string;
delegated_vesting_shares: string;
voting_manabar: { current_mana: string; last_update_time: number } | null;
vesting_withdraw_rate: string;
next_vesting_withdrawal: string;
to_withdraw: string;
withdrawn: string;
};
dgp: {
head_block_number: number;
current_supply: string;
total_vesting_fund_blurt: string;
total_vesting_shares: string;
};
};
describe('GET /v1/account/:account/balance', () => {
it('returns account + dgp for an existing account, with a public cache header', async () => {
const app = mount({
getAccount: async () => FULL_ACCOUNT,
getDynamicGlobalProperties: async () => FULL_DGP
});
const res = await app.request('/v1/account/alice/balance');
expect(res.status).toBe(200);
const body = (await res.json()) as BalanceBody;
expect(body.account.name).toBe('alice');
expect(body.account.balance).toBe('42.500 BLURT');
expect(body.account.vesting_shares).toBe('1000000.000000 VESTS');
expect(body.account.received_vesting_shares).toBe('50000.000000 VESTS');
expect(body.account.delegated_vesting_shares).toBe('20000.000000 VESTS');
expect(body.account.voting_manabar?.current_mana).toBe('900000');
expect(body.dgp.total_vesting_fund_blurt).toBe('1000000.000 BLURT');
expect(body.dgp.total_vesting_shares).toBe('2000000.000000 VESTS');
expect(body.dgp.current_supply).toBe('5000000.000 BLURT');
expect(res.headers.get('cache-control')).toContain('public');
expect(res.headers.get('cache-control')).toContain('max-age');
// Regression guard (balance-staleness bug): a balance is mutable
// chain data, so the response must NOT carry a stale-while-
// revalidate window (which serves the last cached copy — or, on a
// failed revalidation against a flaky node, keeps serving it
// indefinitely) and must keep only a tiny max-age. Re-introducing
// either makes the 5s wallet poll silently coast on an old number.
const cc = res.headers.get('cache-control') ?? '';
expect(cc).not.toContain('stale-while-revalidate');
const maxAge = Number(/max-age=(\d+)/.exec(cc)?.[1] ?? '999');
expect(maxAge).toBeLessThanOrEqual(5);
});
it('passes through a null voting_manabar without erroring', async () => {
const app = mount({
getAccount: async () => ({ ...FULL_ACCOUNT, voting_manabar: undefined }),
getDynamicGlobalProperties: async () => FULL_DGP
});
const res = await app.request('/v1/account/alice/balance');
expect(res.status).toBe(200);
const body = (await res.json()) as BalanceBody;
expect(body.account.voting_manabar).toBeNull();
});
it('defaults received/delegated vesting to zero when the node omits them', async () => {
const app = mount({
getAccount: async () => ({
...FULL_ACCOUNT,
received_vesting_shares: undefined,
delegated_vesting_shares: undefined
}),
getDynamicGlobalProperties: async () => FULL_DGP
});
const res = await app.request('/v1/account/alice/balance');
expect(res.status).toBe(200);
const body = (await res.json()) as BalanceBody;
expect(body.account.received_vesting_shares).toBe('0.000000 VESTS');
expect(body.account.delegated_vesting_shares).toBe('0.000000 VESTS');
});
it('404s when the account does not exist on chain', async () => {
const app = mount({
getAccount: async () => null,
getDynamicGlobalProperties: async () => FULL_DGP
});
const res = await app.request('/v1/account/ghost/balance');
expect(res.status).toBe(404);
expect(((await res.json()) as { code: string }).code).toBe('not_found');
});
it('400s on a malformed account name', async () => {
const app = mount({
getAccount: async () => null,
getDynamicGlobalProperties: async () => FULL_DGP
});
const res = await app.request('/v1/account/Invalid_Name!/balance');
expect(res.status).toBe(400);
expect(((await res.json()) as { code: string }).code).toBe('bad_request');
});
it('502s (internal) when the RPC pool throws', async () => {
const app = mount({
getAccount: async () => {
throw new Error('all endpoints failed');
},
getDynamicGlobalProperties: async () => FULL_DGP
});
const res = await app.request('/v1/account/alice/balance');
expect(res.status).toBe(502);
expect(((await res.json()) as { code: string }).code).toBe('internal');
});
it('502s when a node returns an account missing balance fields', async () => {
const app = mount({
// Account present but stripped of balance/vesting — a
// misbehaving node. Must NOT emit a malformed 200 body.
getAccount: async () =>
({ name: 'alice', posting: AUTH, active: AUTH, owner: AUTH, memo_key: 'x' } as ChainAccount),
getDynamicGlobalProperties: async () => FULL_DGP
});
const res = await app.request('/v1/account/alice/balance');
expect(res.status).toBe(502);
});
it('502s when DGP is missing the vesting totals', async () => {
const app = mount({
getAccount: async () => FULL_ACCOUNT,
getDynamicGlobalProperties: async () =>
({
head_block_number: 1,
last_irreversible_block_num: 1,
time: 't'
} as DynamicGlobalProperties)
});
const res = await app.request('/v1/account/alice/balance');
expect(res.status).toBe(502);
});
it('cp439: defaults power-down fields to idle sentinels when the account has none', async () => {
const app = mount({
getAccount: async () => FULL_ACCOUNT, // no vesting_withdraw_rate etc.
getDynamicGlobalProperties: async () => FULL_DGP
});
const res = await app.request('/v1/account/alice/balance');
const body = (await res.json()) as BalanceBody;
expect(body.account.vesting_withdraw_rate).toBe('0.000000 VESTS');
expect(body.account.next_vesting_withdrawal).toBe('1970-01-01T00:00:00');
expect(body.account.to_withdraw).toBe('0');
expect(body.account.withdrawn).toBe('0');
});
it('cp439: forwards an active power-down (normalising numeric totals to strings)', async () => {
const app = mount({
getAccount: async () => ({
...FULL_ACCOUNT,
vesting_withdraw_rate: '1000000.000000 VESTS',
next_vesting_withdrawal: '2026-07-15T12:00:00',
to_withdraw: 4_000_000_000_000, // number off the node
withdrawn: 1_000_000_000_000
}),
getDynamicGlobalProperties: async () => FULL_DGP
});
const res = await app.request('/v1/account/alice/balance');
const body = (await res.json()) as BalanceBody;
expect(body.account.vesting_withdraw_rate).toBe('1000000.000000 VESTS');
expect(body.account.next_vesting_withdrawal).toBe('2026-07-15T12:00:00');
expect(body.account.to_withdraw).toBe('4000000000000');
expect(body.account.withdrawn).toBe('1000000000000');
});
});