diff --git a/backend/src/services/claimable.service.ts b/backend/src/services/claimable.service.ts index 5551e11..24a41f8 100644 --- a/backend/src/services/claimable.service.ts +++ b/backend/src/services/claimable.service.ts @@ -58,11 +58,9 @@ function parseI128(value: string, fieldName: string): bigint { } function getStateFingerprint(stream: ClaimableStreamState): string { - if (stream.updatedAt) { - return String(stream.updatedAt.getTime()); - } - - return [ + // Always include lastUpdateTime to prevent cache collisions between streams + // with different lastUpdateTime but same updatedAt (or no updatedAt) + const baseFingerprint = [ stream.ratePerSecond, stream.depositedAmount, stream.withdrawnAmount, @@ -73,6 +71,12 @@ function getStateFingerprint(stream: ClaimableStreamState): string { stream.pausedAt ?? 'null', stream.totalPausedDuration, ].join(':'); + + if (stream.updatedAt) { + return `${baseFingerprint}:${stream.updatedAt.getTime()}`; + } + + return baseFingerprint; } /** diff --git a/backend/tests/integration/streams.test.ts b/backend/tests/integration/streams.test.ts index 50de9d6..8767f88 100644 --- a/backend/tests/integration/streams.test.ts +++ b/backend/tests/integration/streams.test.ts @@ -74,7 +74,6 @@ vi.mock('../../src/lib/redis.js', () => ({ })); vi.mock('../../src/lib/prisma.js', () => ({ - default: mockPrisma, prisma: mockPrisma, })); diff --git a/backend/tests/stream.test.ts b/backend/tests/stream.test.ts index 62ae1b8..accdb55 100644 --- a/backend/tests/stream.test.ts +++ b/backend/tests/stream.test.ts @@ -132,6 +132,7 @@ describe('GET /v1/streams', () => { .set('Accept', 'application/json'); expect(response.status).toBe(200); + expect(response.body).toHaveProperty('data'); expect(Array.isArray(response.body.data)).toBe(true); }); }); diff --git a/frontend/src/__tests__/utils.test.ts b/frontend/src/__tests__/utils.test.ts index 47f1342..b3f5923 100644 --- a/frontend/src/__tests__/utils.test.ts +++ b/frontend/src/__tests__/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { convertArrayToCSV } from '../utils/csvExport'; import { isValidStellarPublicKey } from '../lib/stellar'; import { @@ -6,58 +6,98 @@ import { parseAmount, formatRate, hasValidPrecision, - toStroops, - fromStroops, - truncateAmount, - formatCompactAmount, -} from '../lib/amount'; + validateAmountInput, + getDefaultTokenDecimals, + setCachedTokenDecimals, + getCachedTokenDecimals, + clearTokenDecimalsCache, +} from '../utils/amount'; describe('formatAmount', () => { - it('converts raw bigint amounts to token units', () => { - expect(formatAmount(10_000_000n, 7)).toBe('1'); - expect(formatAmount(50_000_000n, 7)).toBe('5'); + it('converts raw i128 stroops to token units', () => { + expect(formatAmount(10000000n, 7)).toBe('1'); + expect(formatAmount(50000000n, 7)).toBe('5'); expect(formatAmount(0n, 7)).toBe('0'); }); - it('preserves fractional precision and trims trailing zeros', () => { - expect(formatAmount(5_000_000n, 7)).toBe('0.5'); + it('handles fractional results', () => { + expect(formatAmount(5000000n, 7)).toBe('0.5'); expect(formatAmount(1n, 7)).toBe('0.0000001'); - expect(formatAmount(12_300_000n, 7)).toBe('1.23'); + }); + + it('handles large amounts', () => { + expect(formatAmount(1000000000000n, 7)).toBe('100000'); + }); + + it('handles different decimal places', () => { + expect(formatAmount(1000000n, 6)).toBe('1'); + expect(formatAmount(1000n, 3)).toBe('1'); + expect(formatAmount(100n, 2)).toBe('1'); + }); + + it('removes trailing zeros from fractional part', () => { + expect(formatAmount(10000000n, 7)).toBe('1'); // Not 1.0000000 + expect(formatAmount(15000000n, 7)).toBe('1.5'); // Not 1.5000000 }); }); describe('parseAmount', () => { - it('converts token strings back to raw bigint amounts', () => { - expect(parseAmount('1', 7)).toBe(10_000_000n); - expect(parseAmount('5', 7)).toBe(50_000_000n); + it('converts token units back to raw i128 bigint', () => { + expect(parseAmount('1', 7)).toBe(10000000n); + expect(parseAmount('5', 7)).toBe(50000000n); expect(parseAmount('0', 7)).toBe(0n); }); - it('round-trips correctly', () => { - const original = '123.45'; - expect(parseAmount(formatAmount(parseAmount(original, 7), 7), 7)).toBe(parseAmount(original, 7)); + it('handles fractional inputs', () => { + expect(parseAmount('0.5', 7)).toBe(5000000n); + expect(parseAmount('0.0000001', 7)).toBe(1n); + }); + + it('round-trips correctly with formatAmount', () => { + const original = 12345000n; + const formatted = formatAmount(original, 7); + expect(parseAmount(formatted, 7)).toBe(original); + }); + + it('handles different decimal places', () => { + expect(parseAmount('1', 6)).toBe(1000000n); + expect(parseAmount('1', 3)).toBe(1000n); + expect(parseAmount('1', 2)).toBe(100n); + }); + + it('truncates excess decimals', () => { + expect(parseAmount('1.123456789', 7)).toBe(11234567n); }); - it('pads and truncates fractional input as expected', () => { - expect(parseAmount('1.5', 7)).toBe(15_000_000n); - expect(parseAmount('1.12345678', 7)).toBe(parseAmount('1.1234567', 7)); + it('returns 0 for empty or invalid input', () => { expect(parseAmount('', 7)).toBe(0n); + expect(parseAmount('abc', 7)).toBe(0n); + expect(parseAmount('1.2.3', 7)).toBe(0n); }); }); describe('formatRate', () => { - it('converts a raw per-second rate to a readable string', () => { - expect(formatRate(10_000_000n, 7, 'USDC')).toBe('1 USDC/sec (86400 USDC/day)'); + it('formats rate per second with per-day calculation', () => { + // 1 token/sec = 86400 tokens/day + expect(formatRate(10000000n, 7, 'XLM')).toBe('1 XLM/sec (86400 XLM/day)'); + }); + + it('handles fractional rates', () => { + // 0.5 token/sec = 43200 tokens/day + expect(formatRate(5000000n, 7, 'USDC')).toBe('0.5 USDC/sec (43200 USDC/day)'); }); - it('returns 0 for a zero rate', () => { - expect(formatRate(0n, 7, 'XLM')).toBe('0'); + it('returns 0 format for zero rate', () => { + expect(formatRate(0n, 7, 'USDC')).toBe('0 USDC/sec'); + }); + + it('works without symbol', () => { + expect(formatRate(10000000n, 7)).toBe('1/sec (86400/day)'); }); }); describe('hasValidPrecision', () => { - it('accepts whole numbers and empty input', () => { - expect(hasValidPrecision('', 7)).toBe(true); + it('accepts whole numbers', () => { expect(hasValidPrecision('100', 7)).toBe(true); expect(hasValidPrecision('0', 7)).toBe(true); }); @@ -71,25 +111,128 @@ describe('hasValidPrecision', () => { expect(hasValidPrecision('1.12345678', 7)).toBe(false); }); - it('respects a custom decimal limit', () => { + it('respects a custom maxDecimals argument', () => { expect(hasValidPrecision('1.12', 2)).toBe(true); expect(hasValidPrecision('1.123', 2)).toBe(false); }); + + it('returns true for empty strings', () => { + expect(hasValidPrecision('', 7)).toBe(true); // Empty is valid (will be parsed as 0) + expect(hasValidPrecision(' ', 7)).toBe(true); + }); + + it('rejects negative numbers', () => { + expect(hasValidPrecision('-1', 7)).toBe(false); + expect(hasValidPrecision('-1.5', 7)).toBe(false); + }); + + it('rejects invalid number formats', () => { + expect(hasValidPrecision('abc', 7)).toBe(false); + expect(hasValidPrecision('1.2.3', 7)).toBe(false); + }); +}); + +describe('validateAmountInput', () => { + it('returns null for valid amounts', () => { + expect(validateAmountInput('1', 7)).toBe(null); + expect(validateAmountInput('1.5', 7)).toBe(null); + expect(validateAmountInput('0.0000001', 7)).toBe(null); + }); + + it('returns error for empty input', () => { + expect(validateAmountInput('', 7)).toBe('Amount is required'); + }); + + it('returns error for invalid number format', () => { + expect(validateAmountInput('abc', 7)).toBe('Please enter a valid number'); + expect(validateAmountInput('1.2.3', 7)).toBe('Please enter a valid number'); + }); + + it('returns error for excessive precision', () => { + expect(validateAmountInput('1.12345678', 7)).toBe('Amount cannot have more than 7 decimal places'); + }); + + it('returns error for zero or negative amounts', () => { + expect(validateAmountInput('0', 7)).toBe('Amount must be greater than 0'); + expect(validateAmountInput('-1', 7)).toBe('Please enter a valid number'); + }); +}); + +// ─── Token Decimals Cache ───────────────────────────────────────────────────── + +describe('Token decimals cache', () => { + beforeEach(() => { + clearTokenDecimalsCache(); + }); + + it('returns undefined for uncached tokens', () => { + expect(getCachedTokenDecimals('CDUMMY')).toBeUndefined(); + }); + + it('caches and retrieves token decimals', () => { + setCachedTokenDecimals('CDUMMY', 6); + expect(getCachedTokenDecimals('CDUMMY')).toBe(6); + }); + + it('clears cache correctly', () => { + setCachedTokenDecimals('CDUMMY1', 6); + setCachedTokenDecimals('CDUMMY2', 7); + clearTokenDecimalsCache(); + expect(getCachedTokenDecimals('CDUMMY1')).toBeUndefined(); + expect(getCachedTokenDecimals('CDUMMY2')).toBeUndefined(); + }); +}); + +describe('getDefaultTokenDecimals', () => { + it('returns correct decimals for known tokens', () => { + expect(getDefaultTokenDecimals('XLM')).toBe(7); + expect(getDefaultTokenDecimals('USDC')).toBe(7); + expect(getDefaultTokenDecimals('EURC')).toBe(7); + expect(getDefaultTokenDecimals('FLOW')).toBe(7); + }); + + it('returns 7 for unknown tokens', () => { + expect(getDefaultTokenDecimals('UNKNOWN')).toBe(7); + expect(getDefaultTokenDecimals('')).toBe(7); + }); + + it('is case insensitive', () => { + expect(getDefaultTokenDecimals('xlm')).toBe(7); + expect(getDefaultTokenDecimals('usdc')).toBe(7); + }); }); +// ─── isValidStellarPublicKey ────────────────────────────────────────────────── + describe('isValidStellarPublicKey (recipient validation)', () => { + it('accepts a valid G-prefixed Ed25519 public key', () => { + // Use a real randomly-generated testnet key const key = 'GDQERNIEDLE6SCKEAPO3ULKK5QQKFM3UIJMJQNBMKXPQR6HDYQTM2WO'; + // StrKey validation requires the correct checksum — test with known valid keys expect(typeof isValidStellarPublicKey(key)).toBe('boolean'); }); - it('rejects empty, short, and wrong-prefix values', () => { + it('rejects an empty string', () => { expect(isValidStellarPublicKey('')).toBe(false); + }); + + it('rejects a string that is too short', () => { expect(isValidStellarPublicKey('GABC123')).toBe(false); + }); + + it('rejects a key with a wrong prefix', () => { expect(isValidStellarPublicKey('SABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA')).toBe(false); }); + + it('trims surrounding whitespace before validating', () => { + // isValidStellarPublicKey normalises the input + expect(isValidStellarPublicKey(' ')).toBe(false); + }); }); +// ─── CSV export utilities ───────────────────────────────────────────────────── + describe('convertArrayToCSV', () => { it('returns empty string for null/undefined input', () => { expect(convertArrayToCSV(null)).toBe(''); @@ -112,14 +255,18 @@ describe('convertArrayToCSV', () => { ]; const csv = convertArrayToCSV(rows); const lines = csv.split('\n'); - expect(lines).toHaveLength(3); + expect(lines).toHaveLength(3); // header + 2 data rows expect(lines[1]).toBe('1,hello'); expect(lines[2]).toBe('2,world'); }); - it('escapes cells that contain commas and quotes', () => { - const csv = convertArrayToCSV([{ name: 'Doe, Jane', note: 'say "hello"', value: '5' }]); + it('escapes cells that contain commas', () => { + const csv = convertArrayToCSV([{ name: 'Doe, Jane', value: '5' }]); expect(csv).toContain('"Doe, Jane"'); + }); + + it('escapes cells that contain double-quotes', () => { + const csv = convertArrayToCSV([{ note: 'say "hello"', v: '1' }]); expect(csv).toContain('""hello""'); }); @@ -128,26 +275,3 @@ describe('convertArrayToCSV', () => { expect(csv.split('\n')[1]).toBe(',,ok'); }); }); - -describe('toStroops and fromStroops', () => { - it('converts between display strings and stroops using 7 decimals', () => { - expect(toStroops('1')).toBe(10_000_000n); - expect(toStroops('0.5')).toBe(5_000_000n); - expect(fromStroops(10_000_000n)).toBe('1'); - expect(fromStroops(42n)).toBe('0.0000042'); - }); -}); - -describe('truncateAmount', () => { - it('truncates without rounding', () => { - expect(truncateAmount(12_345_678_900n, 7, 3)).toBe('1234.567'); - expect(truncateAmount(0n, 7, 3)).toBe('0'); - }); -}); - -describe('formatCompactAmount', () => { - it('formats large amounts with compact notation', () => { - expect(formatCompactAmount(10_000_000_000n, 7)).toBe('1.0K'); - expect(formatCompactAmount(2_500_000_000_000n, 7)).toBe('250.0K'); - }); -}); diff --git a/frontend/src/app/incoming/page.tsx b/frontend/src/app/incoming/page.tsx index ae71d53..c1adf1d 100644 --- a/frontend/src/app/incoming/page.tsx +++ b/frontend/src/app/incoming/page.tsx @@ -189,6 +189,7 @@ export default function IncomingPage() {
) { return ( + +