harb/services/ponder/tests/stats.test.ts
openhands 76560fd26b fix: Ponder: add test infrastructure + coverage for helpers (target 95%) (#287)
- Add vitest ^2 + @vitest/coverage-v8 ^2 as devDependencies
- Add `test` and `test:coverage` scripts to package.json
- Create vitest.config.ts with resolve.alias to mock ponder virtual modules
  (ponder:schema, ponder:registry) and point kraiken-lib/version to source
- Add coverage/ to .gitignore
- Add tests/**/* and vitest.config.ts to tsconfig.json include
- Create tests/__mocks__/ponder-schema.ts and ponder-registry.ts stubs
- Create tests/stats.test.ts — 48 tests covering ring buffer logic,
  segment updates, hourly advancement, projections, ETH reserve snapshots,
  all exported async helpers with mock Ponder contexts
- Create tests/version.test.ts — 14 tests covering isCompatibleVersion,
  getVersionMismatchError, and validateContractVersion (compatible / mismatch /
  error paths, existing-meta upsert path)
- Create tests/abi.test.ts — 6 tests covering validateAbi and validateContractAbi

Tests placed at tests/ (not src/tests/) so Ponder's Vite build does not
attempt to execute test files as event handlers on startup.

Result: 68 tests pass, 100% line/statement/function coverage on all helpers
(stats.ts, version.ts, abi.ts, logger.ts) — exceeds 95% target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 22:53:01 +00:00

772 lines
31 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
makeEmptyRingBuffer,
parseRingBuffer,
serializeRingBuffer,
checkBlockHistorySufficient,
updateHourlyData,
ensureStatsExists,
recordEthReserveSnapshot,
updateEthReserve,
markPositionsUpdated,
refreshOutstandingStake,
refreshMinStake,
RING_BUFFER_SEGMENTS,
MINIMUM_BLOCKS_FOR_RINGBUFFER,
type StatsContext,
} from '../src/helpers/stats.js';
// Constants duplicated from the mock so tests don't re-import ponder:schema
const HOURS = 168;
const SECS_PER_HOUR = 3600;
// ---------------------------------------------------------------------------
// Helper factories
// ---------------------------------------------------------------------------
interface MockStatsRow {
ringBuffer: string[];
ringBufferPointer: number;
lastHourlyUpdateTimestamp: bigint;
holderCount: number;
minStake?: bigint;
stakeTotalSupply?: bigint;
outstandingStake?: bigint;
kraikenTotalSupply?: bigint;
}
function emptyStatsRow(overrides: Partial<MockStatsRow> = {}): MockStatsRow {
return {
ringBuffer: makeEmptyRingBuffer().map(String),
ringBufferPointer: 0,
lastHourlyUpdateTimestamp: 0n,
holderCount: 0,
minStake: 0n,
stakeTotalSupply: 0n,
outstandingStake: 0n,
kraikenTotalSupply: 0n,
...overrides,
};
}
/** Build a ring buffer string[] with a known value at a specific slot. */
function ringBufferWith(
pointer: number,
slotOffset: number,
value: bigint,
hoursBack = 0,
): string[] {
const buf = makeEmptyRingBuffer();
const idx = ((pointer - hoursBack + HOURS) % HOURS) * RING_BUFFER_SEGMENTS + slotOffset;
buf[idx] = value;
return buf.map(String);
}
function createSetMock() {
return vi.fn().mockResolvedValue(undefined);
}
function createMockContext(statsRow: MockStatsRow | null = null): StatsContext {
const setFn = createSetMock();
const ctx = {
db: {
find: vi.fn().mockResolvedValue(statsRow),
update: vi.fn().mockReturnValue({ set: setFn }),
insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
},
client: {
readContract: vi.fn().mockResolvedValue(0n),
},
contracts: {
Kraiken: { abi: [], address: '0x0000000000000000000000000000000000000001' as `0x${string}` },
Stake: { abi: [], address: '0x0000000000000000000000000000000000000002' as `0x${string}` },
},
network: {
contracts: {
Kraiken: { abi: [], address: '0x0000000000000000000000000000000000000001' },
},
},
logger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
},
};
// Cast: vitest uses esbuild (no type-check), so the slim mock satisfies the runtime contract.
return ctx as unknown as StatsContext;
}
// ---------------------------------------------------------------------------
// makeEmptyRingBuffer
// ---------------------------------------------------------------------------
describe('makeEmptyRingBuffer', () => {
it('returns an array of the correct length', () => {
const buf = makeEmptyRingBuffer();
expect(buf).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
});
it('fills every element with 0n', () => {
const buf = makeEmptyRingBuffer();
expect(buf.every(v => v === 0n)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// parseRingBuffer
// ---------------------------------------------------------------------------
describe('parseRingBuffer', () => {
it('converts a string array to bigint array', () => {
const raw = ['1', '2', '3'];
expect(parseRingBuffer(raw)).toEqual([1n, 2n, 3n]);
});
it('returns empty ring buffer for null input', () => {
expect(parseRingBuffer(null)).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
expect(parseRingBuffer(null).every(v => v === 0n)).toBe(true);
});
it('returns empty ring buffer for undefined input', () => {
expect(parseRingBuffer(undefined)).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
});
it('returns empty ring buffer for empty array input', () => {
expect(parseRingBuffer([])).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
});
});
// ---------------------------------------------------------------------------
// serializeRingBuffer
// ---------------------------------------------------------------------------
describe('serializeRingBuffer', () => {
it('converts bigint array to string array', () => {
expect(serializeRingBuffer([0n, 1n, 1000n])).toEqual(['0', '1', '1000']);
});
it('round-trips through parseRingBuffer', () => {
const original = makeEmptyRingBuffer();
original[0] = 42n;
original[RING_BUFFER_SEGMENTS] = 99n;
const serialized = serializeRingBuffer(original);
expect(parseRingBuffer(serialized)).toEqual(original);
});
});
// ---------------------------------------------------------------------------
// checkBlockHistorySufficient
// ---------------------------------------------------------------------------
describe('checkBlockHistorySufficient', () => {
it('returns false when context has no network contracts', () => {
const ctx = { network: {} } as unknown as StatsContext;
const event = { block: { number: 200n } } as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
});
it('returns false when context is missing network entirely', () => {
const ctx = {} as unknown as StatsContext;
const event = { block: { number: 200n } } as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
});
it('returns false when not enough blocks have passed', () => {
const ctx = createMockContext();
const event = {
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER - 1) },
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
});
it('uses console fallback when context has no logger', () => {
// Context with network.contracts.Kraiken but no logger — exercises logger.ts fallback branch
const ctx = {
network: { contracts: { Kraiken: {} } },
} as unknown as StatsContext;
const event = {
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER - 1) },
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
// Should not throw; console.warn is used as fallback
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
});
it('returns true when sufficient blocks have passed', () => {
const ctx = createMockContext();
const event = {
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER + 1) },
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
expect(checkBlockHistorySufficient(ctx, event)).toBe(true);
});
it('returns true at exact threshold', () => {
const ctx = createMockContext();
const event = {
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER) },
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
expect(checkBlockHistorySufficient(ctx, event)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// updateHourlyData
// ---------------------------------------------------------------------------
describe('updateHourlyData', () => {
it('returns early when no stats row exists', async () => {
const ctx = createMockContext(null);
await updateHourlyData(ctx, BigInt(SECS_PER_HOUR * 10));
expect((ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } }).db.update).not.toHaveBeenCalled();
});
it('initialises lastHourlyUpdateTimestamp when it is 0n', async () => {
const row = emptyStatsRow({ lastHourlyUpdateTimestamp: 0n });
const ctx = createMockContext(row);
const timestamp = BigInt(SECS_PER_HOUR * 5);
await updateHourlyData(ctx, timestamp);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
// Should store the current hour
expect(setArg.lastHourlyUpdateTimestamp).toBe((timestamp / BigInt(SECS_PER_HOUR)) * BigInt(SECS_PER_HOUR));
});
it('advances the pointer when a new hour has started', async () => {
const baseTimestamp = BigInt(SECS_PER_HOUR * 100);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: 0,
});
const ctx = createMockContext(row);
// Move 3 hours forward
const newTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR * 3 + 60);
await updateHourlyData(ctx, newTimestamp);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.ringBufferPointer).toBe(3);
});
it('clamps hours elapsed to HOURS_IN_RING_BUFFER to prevent full-buffer clear loops', async () => {
const baseTimestamp = BigInt(SECS_PER_HOUR * 100);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: 0,
});
const ctx = createMockContext(row);
// Jump 500 hours — exceeds ring buffer
const newTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR * 500);
await updateHourlyData(ctx, newTimestamp);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
// pointer should wrap around within HOURS range
expect(setArg.ringBufferPointer).toBeGreaterThanOrEqual(0);
expect(setArg.ringBufferPointer).toBeLessThan(HOURS);
});
it('computes metrics when hour has advanced (mintedDay, burnedDay, etc.)', async () => {
const pointer = 5;
// Slot 1 (minted) at index i=0 (current hour) through i=23 — 10n in each of last 48 hours
const buf = makeEmptyRingBuffer();
for (let h = 0; h < 48; h++) {
const idx = ((pointer - h + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[idx + 1] = 10n; // minted
buf[idx + 2] = 5n; // burned
}
const baseTimestamp = BigInt(SECS_PER_HOUR * 200);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
// mintedLastWeek and mintedLastDay should be positive
expect(setArg.mintedLastWeek).toBeGreaterThan(0n);
expect(setArg.mintedLastDay).toBeGreaterThan(0n);
expect(setArg.burnedLastWeek).toBeGreaterThan(0n);
expect(setArg.burnedLastDay).toBeGreaterThan(0n);
});
it('carries forward holderCount into new ring buffer slots', async () => {
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: BigInt(SECS_PER_HOUR * 10),
ringBufferPointer: 0,
holderCount: 42,
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, BigInt(SECS_PER_HOUR * 12));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArgs = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
const updatedBuf = parseRingBuffer(setArgs.ringBuffer as string[]);
// pointer advanced to 2; slot 3 of new pointer should be 42n
const newPointer = 2;
expect(updatedBuf[newPointer * RING_BUFFER_SEGMENTS + 3]).toBe(42n);
});
it('computes projections when same hour (no advancement)', async () => {
const pointer = 3;
const baseTimestamp = BigInt(SECS_PER_HOUR * 100);
// Add some minted data in the current and previous slot
const buf = makeEmptyRingBuffer();
buf[pointer * RING_BUFFER_SEGMENTS + 1] = 100n; // current hour minted
const prevPointer = ((pointer - 1 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[prevPointer + 1] = 80n; // previous hour minted
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
// Same hour but 30 minutes in
const sameHourTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR / 2);
await updateHourlyData(ctx, sameHourTimestamp);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
// Projections should be computed
expect(setArg.mintNextHourProjected).toBeDefined();
expect(setArg.burnNextHourProjected).toBeDefined();
});
it('handles zero elapsedSeconds in projection (exact hour boundary)', async () => {
const pointer = 0;
const exactHour = BigInt(SECS_PER_HOUR * 50);
const buf = makeEmptyRingBuffer();
buf[pointer * RING_BUFFER_SEGMENTS + 1] = 50n; // some minted
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: exactHour,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
// Pass exactly the same timestamp (elapsedSeconds = 0)
await updateHourlyData(ctx, exactHour);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
});
it('computes ETH reserve change metrics when reserves are populated', async () => {
const pointer = 25;
const buf = makeEmptyRingBuffer();
// Set current hour ETH reserve (i=0)
buf[pointer * RING_BUFFER_SEGMENTS + 0] = 2000n;
// Set 24h ago ETH reserve (i=23)
const idx24h = ((pointer - 23 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[idx24h + 0] = 1000n;
const baseTimestamp = BigInt(SECS_PER_HOUR * 200);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.ethReserveLastDay).toBeGreaterThanOrEqual(0n);
});
});
// ---------------------------------------------------------------------------
// recordEthReserveSnapshot
// ---------------------------------------------------------------------------
describe('recordEthReserveSnapshot', () => {
it('returns early when no stats row exists', async () => {
const ctx = createMockContext(null);
await recordEthReserveSnapshot(ctx, 1000n, 500n);
expect((ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } }).db.update).not.toHaveBeenCalled();
});
it('writes the ETH balance into slot 0 of the current pointer', async () => {
const pointer = 2;
const row = emptyStatsRow({ ringBufferPointer: pointer });
const ctx = createMockContext(row);
const ethBalance = 12345678n;
await recordEthReserveSnapshot(ctx, 1000n, ethBalance);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
const updatedBuf = parseRingBuffer(setArg.ringBuffer as string[]);
expect(updatedBuf[pointer * RING_BUFFER_SEGMENTS + 0]).toBe(ethBalance);
});
});
// ---------------------------------------------------------------------------
// updateEthReserve
// ---------------------------------------------------------------------------
describe('updateEthReserve', () => {
it('skips update when weth balance is 0', async () => {
const ctx = createMockContext(emptyStatsRow());
// readContract returns 0n by default
await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`);
expect((ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } }).db.update).not.toHaveBeenCalled();
});
it('updates lastEthReserve when weth balance is non-zero', async () => {
const ctx = createMockContext(emptyStatsRow());
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(9999n);
await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.lastEthReserve).toBe(9999n);
});
it('logs warning and returns when readContract throws', async () => {
const ctx = createMockContext(emptyStatsRow());
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockRejectedValue(new Error('rpc error'));
await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// ensureStatsExists
// ---------------------------------------------------------------------------
describe('ensureStatsExists', () => {
it('returns existing stats without re-creating when row exists', async () => {
const row = emptyStatsRow();
const ctx = createMockContext(row);
const result = await ensureStatsExists(ctx, 1000n);
const dbMock = ctx as unknown as { db: { insert: ReturnType<typeof vi.fn> } };
expect(dbMock.db.insert).not.toHaveBeenCalled();
expect(result).toBe(row);
});
it('creates a new stats row when none exists', async () => {
// First call returns null, second call (post-insert) returns the row
const row = emptyStatsRow();
const dbFindMock = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(row);
const ctx = createMockContext(null);
(ctx as unknown as { db: { find: ReturnType<typeof vi.fn> } }).db.find = dbFindMock;
const result = await ensureStatsExists(ctx, 1000n);
const dbMock = ctx as unknown as { db: { insert: ReturnType<typeof vi.fn> } };
expect(dbMock.db.insert).toHaveBeenCalled();
expect(result).toBe(row);
});
it('uses fallback values when readContract throws during creation', async () => {
const row = emptyStatsRow();
const dbFindMock = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(row);
const ctx = createMockContext(null);
(ctx as unknown as { db: { find: ReturnType<typeof vi.fn> } }).db.find = dbFindMock;
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockRejectedValue(new Error('rpc error'));
const result = await ensureStatsExists(ctx, 1000n);
expect(result).toBe(row);
// insert should still have been called with fallback 0n values
const dbMock = ctx as unknown as { db: { insert: ReturnType<typeof vi.fn> } };
expect(dbMock.db.insert).toHaveBeenCalled();
});
it('works without timestamp argument', async () => {
const row = emptyStatsRow();
const ctx = createMockContext(row);
const result = await ensureStatsExists(ctx);
expect(result).toBe(row);
});
});
// ---------------------------------------------------------------------------
// markPositionsUpdated
// ---------------------------------------------------------------------------
describe('markPositionsUpdated', () => {
it('updates positionsUpdatedAt in the stats row', async () => {
const row = emptyStatsRow();
const ctx = createMockContext(row);
const ts = 7777n;
await markPositionsUpdated(ctx, ts);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.positionsUpdatedAt).toBe(ts);
});
});
// ---------------------------------------------------------------------------
// getStakeTotalSupply
// ---------------------------------------------------------------------------
describe('getStakeTotalSupply', () => {
beforeEach(() => {
vi.resetModules();
});
it('reads totalSupply from contract when cache is cold', async () => {
// Fresh import to reset module-level cache
const { getStakeTotalSupply: freshGet } = await import('../src/helpers/stats.js');
const row = emptyStatsRow({ stakeTotalSupply: 0n });
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(555n);
const result = await freshGet(ctx);
expect(result).toBe(555n);
});
it('returns cached value without calling readContract on second call', async () => {
// Fresh import to get a clean module with null cache
const { getStakeTotalSupply: freshGet } = await import('../src/helpers/stats.js');
const row = emptyStatsRow({ stakeTotalSupply: 0n });
const readContractMock = vi.fn().mockResolvedValue(777n);
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= readContractMock;
// First call — populates cache via readContract
const first = await freshGet(ctx);
expect(first).toBe(777n);
// Second call — should return cached value, readContract not called again
const second = await freshGet(ctx);
expect(second).toBe(777n);
expect(readContractMock).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// refreshOutstandingStake
// ---------------------------------------------------------------------------
describe('refreshOutstandingStake', () => {
it('reads outstandingStake from contract and persists it', async () => {
const row = emptyStatsRow();
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(1234n);
await refreshOutstandingStake(ctx);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.outstandingStake).toBe(1234n);
});
});
// ---------------------------------------------------------------------------
// refreshMinStake
// ---------------------------------------------------------------------------
describe('refreshMinStake', () => {
it('skips update when minStake is unchanged', async () => {
const row = emptyStatsRow({ minStake: 500n });
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(500n);
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).not.toHaveBeenCalled();
});
it('updates minStake when the on-chain value has changed', async () => {
const row = emptyStatsRow({ minStake: 100n });
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(200n);
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.minStake).toBe(200n);
});
it('fetches stats when no statsData arg is provided and row exists', async () => {
const row = emptyStatsRow({ minStake: 0n });
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(300n);
await refreshMinStake(ctx);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
});
it('creates stats row when find returns null', async () => {
const row = emptyStatsRow({ minStake: 0n });
const dbFindMock = vi.fn()
.mockResolvedValueOnce(null) // first find (refresh check)
.mockResolvedValueOnce(null) // ensureStatsExists find
.mockResolvedValueOnce(row); // ensureStatsExists post-insert
const ctx = createMockContext(null);
(ctx as unknown as { db: { find: ReturnType<typeof vi.fn> } }).db.find = dbFindMock;
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(300n);
await refreshMinStake(ctx);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
});
it('logs warning and returns when readContract throws', async () => {
const row = emptyStatsRow({ minStake: 100n });
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockRejectedValue(new Error('rpc error'));
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).not.toHaveBeenCalled();
});
it('uses ringBufferWith helper to build non-trivial buffers', () => {
const pointer = 10;
const buf = parseRingBuffer(ringBufferWith(pointer, 1, 999n));
expect(buf[pointer * RING_BUFFER_SEGMENTS + 1]).toBe(999n);
});
});
// ---------------------------------------------------------------------------
// RING_BUFFER_SEGMENTS and MINIMUM_BLOCKS_FOR_RINGBUFFER exports
// ---------------------------------------------------------------------------
describe('constants', () => {
it('RING_BUFFER_SEGMENTS is 4', () => {
expect(RING_BUFFER_SEGMENTS).toBe(4);
});
it('MINIMUM_BLOCKS_FOR_RINGBUFFER is 100', () => {
expect(MINIMUM_BLOCKS_FOR_RINGBUFFER).toBe(100);
});
});
// ---------------------------------------------------------------------------
// computeMetrics coverage: holderCount and ethReserve at 24h/7d boundaries
// ---------------------------------------------------------------------------
describe('computeMetrics boundary coverage (via updateHourlyData)', () => {
it('captures ethReserve7dAgo from the oldest non-zero slot', async () => {
const pointer = 2;
const buf = makeEmptyRingBuffer();
// Fill a slot > 24 hours back with a non-zero ETH reserve
const oldSlot = ((pointer - 50 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[oldSlot + 0] = 500n;
// Current hour also has a value
buf[pointer * RING_BUFFER_SEGMENTS + 0] = 1000n;
const baseTimestamp = BigInt(SECS_PER_HOUR * 200);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.ethReserveLastWeek).toBeGreaterThanOrEqual(0n);
});
it('records holderCount at 24h and 7d ago markers', async () => {
const pointer = 30;
const buf = makeEmptyRingBuffer();
// Set holderCount at current pointer (i=0)
buf[pointer * RING_BUFFER_SEGMENTS + 3] = 100n;
// Set holderCount at 24h ago (i=23)
const idx24h = ((pointer - 23 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[idx24h + 3] = 80n;
// Set holderCount far back (i=150, older than 7d)
const idxOld = ((pointer - 150 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[idxOld + 3] = 50n;
const baseTimestamp = BigInt(SECS_PER_HOUR * 500);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
});
it('handles projection fallback when weekly totals are also zero', async () => {
// All zeros in ring buffer — medium will be 0, fallback is weekly/7
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: BigInt(SECS_PER_HOUR * 100),
ringBufferPointer: 0,
});
const ctx = createMockContext(row);
const sameHourTimestamp = BigInt(SECS_PER_HOUR * 100) + BigInt(SECS_PER_HOUR / 4);
await updateHourlyData(ctx, sameHourTimestamp);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
// Projection should be 0 (weekly/7 = 0/7 = 0)
expect(setArg.mintNextHourProjected).toBe(0n);
expect(setArg.burnNextHourProjected).toBe(0n);
});
it('computes netSupplyChange from minted minus burned', async () => {
const pointer = 1;
const buf = makeEmptyRingBuffer();
for (let h = 0; h < 24; h++) {
const idx = ((pointer - h + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[idx + 1] = 20n; // minted
buf[idx + 2] = 8n; // burned
}
const baseTimestamp = BigInt(SECS_PER_HOUR * 300);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.netSupplyChangeDay).toBeGreaterThan(0n);
expect(setArg.netSupplyChangeWeek).toBeGreaterThan(0n);
});
});