diff --git a/__tests__/lib/contract.test.ts b/__tests__/lib/contract.test.ts new file mode 100644 index 0000000..80b5ea6 --- /dev/null +++ b/__tests__/lib/contract.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NETWORKS } from '@/lib/stellar' +import { + cancelStream, + createStream, + createStreamsBatch, + estimateCreateStreamFee, + fetchStream, + fetchStreamsForAddress, + fetchArchivedSentStreamIds, + simulateCreateStreamPreview, + withdrawFromStream, +} from '@/lib/contract' +import type { CreateStreamInput } from '@/types/stream' + +const sender = 'GTEST-SENDER' +const recipient = 'GTEST-RECIPIENT' +const input: CreateStreamInput = { + recipient, + token: NETWORKS.testnet.knownTokens[0], + totalAmount: 1_000n, + startTime: 1_000n, + endTime: 2_000n, + cliffTime: 1_000n, + cliffAmount: 0n, +} + +async function createMockStream() { + const promise = createStream(input, sender, 'testnet') + await vi.advanceTimersByTimeAsync(700) + return promise +} + +describe('contract mock mode', () => { + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET', '') + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + }) + + it('returns deterministic simulation and fee previews without network calls', async () => { + expect((await simulateCreateStreamPreview('testnet', input, sender)).success).toBe(true) + await expect(simulateCreateStreamPreview('testnet', input, sender)).resolves.toMatchObject({ + estimatedFeeXlm: '0.0115', + estimatedFeeUsd: '~$0.001', + cpuInstructions: 42_000, + memoryBytes: 128, + }) + await expect(estimateCreateStreamFee('testnet', input, sender)).resolves.toEqual({ + minFee: 100_000, + estimatedFee: 115_000, + estimatedFeeXlm: '0.0115', + }) + }) + + it('creates streams in the mock store and queries them by address', async () => { + const id = await createMockStream() + await expect(fetchStream('testnet', id)).resolves.toMatchObject({ + id, + sender, + recipient, + depositedAmount: input.totalAmount, + withdrawnAmount: 0n, + cancelled: false, + }) + + const streams = await fetchStreamsForAddress('testnet', recipient) + expect(streams.some((stream) => stream.id === id)).toBe(true) + + const batchIds = await createStreamsBatch([input, input], sender, 'testnet') + expect(batchIds).toHaveLength(2) + expect(new Set(batchIds).size).toBe(2) + }) + + it('updates and cancels streams through the delayed mock write paths', async () => { + const id = await createMockStream() + + const withdrawPromise = withdrawFromStream(id, 125n, 'testnet') + await vi.advanceTimersByTimeAsync(700) + await expect(withdrawPromise).resolves.toBeNull() + await expect(fetchStream('testnet', id)).resolves.toMatchObject({ withdrawnAmount: 125n }) + + const cancelPromise = cancelStream(id, 'testnet') + await vi.advanceTimersByTimeAsync(700) + await expect(cancelPromise).resolves.toBeNull() + await expect(fetchStream('testnet', id)).resolves.toMatchObject({ cancelled: true }) + }) + + it('returns archived mock stream IDs for a matching sender', async () => { + const id = await createMockStream() + const cancelPromise = cancelStream(id, 'testnet') + await vi.advanceTimersByTimeAsync(700) + await cancelPromise + + await expect(fetchArchivedSentStreamIds('testnet', sender)).resolves.toContain(id) + }) +}) diff --git a/__tests__/lib/federation.test.ts b/__tests__/lib/federation.test.ts new file mode 100644 index 0000000..e68ae8b --- /dev/null +++ b/__tests__/lib/federation.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Federation } from '@stellar/stellar-sdk' +import { FederationLookupError, resolveFederationAddress } from '@/lib/federation' + +const accountId = 'GB5XVAABEQMY63WTHDQ5RXADGYF345VWMNPTN2GFUDZT57D57ZQTJ7PS' + +describe('resolveFederationAddress', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns the account and memo data for a successful lookup', async () => { + vi.spyOn(Federation.Server, 'resolve').mockResolvedValue({ + account_id: accountId, + memo_type: 'text', + memo: 'invoice-42', + }) + + await expect(resolveFederationAddress('alice*example.com')).resolves.toEqual({ + federationAddress: 'alice*example.com', + accountId, + memoType: 'text', + memo: 'invoice-42', + }) + }) + + it('rejects malformed federation addresses before contacting the resolver', async () => { + const resolve = vi.spyOn(Federation.Server, 'resolve') + await expect(resolveFederationAddress('not-a-federation-address')).rejects.toMatchObject({ + name: 'FederationLookupError', + message: expect.stringContaining('expected the form'), + }) + expect(resolve).not.toHaveBeenCalled() + }) + + it('maps a missing federation record to FederationLookupError', async () => { + vi.spyOn(Federation.Server, 'resolve').mockRejectedValue(new Error('404 Not Found')) + + const result = resolveFederationAddress('missing*example.com') + await expect(result).rejects.toBeInstanceOf(FederationLookupError) + await expect(result).rejects.toThrow('No Federation record found') + }) + + it('rejects a federation response containing an invalid account', async () => { + vi.spyOn(Federation.Server, 'resolve').mockResolvedValue({ + account_id: 'not-a-stellar-account', + }) + + await expect(resolveFederationAddress('alice*example.com')).rejects.toThrow( + 'returned an invalid account', + ) + }) +}) diff --git a/__tests__/lib/hidden-streams.test.ts b/__tests__/lib/hidden-streams.test.ts new file mode 100644 index 0000000..50fbdb8 --- /dev/null +++ b/__tests__/lib/hidden-streams.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + blockSender, + getBlockedSenders, + getHiddenStreamIds, + hideStream, + subscribeHiddenStreams, + unblockSender, + unhideStream, +} from '@/lib/hidden-streams' + +describe('hidden streams', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('hides and unhides stream IDs idempotently', () => { + hideStream('stream-1') + hideStream('stream-1') + expect(getHiddenStreamIds()).toEqual(new Set(['stream-1'])) + + unhideStream('stream-1') + unhideStream('stream-1') + expect(getHiddenStreamIds()).toEqual(new Set()) + }) + + it('blocks and unblocks sender addresses idempotently', () => { + blockSender('G SENDER 1') + blockSender('G SENDER 1') + expect(getBlockedSenders()).toEqual(new Set(['G SENDER 1'])) + + unblockSender('G SENDER 1') + unblockSender('G SENDER 1') + expect(getBlockedSenders()).toEqual(new Set()) + }) + + it('notifies subscribers only when a persisted set changes', () => { + const listener = vi.fn() + const unsubscribe = subscribeHiddenStreams(listener) + + hideStream('stream-1') + hideStream('stream-1') + unhideStream('stream-1') + unhideStream('stream-1') + blockSender('G SENDER') + unblockSender('G SENDER') + + expect(listener).toHaveBeenCalledTimes(4) + unsubscribe() + hideStream('stream-2') + expect(listener).toHaveBeenCalledTimes(4) + }) + + it('returns empty sets and does not throw when window is unavailable', () => { + vi.stubGlobal('window', undefined) + + expect(getHiddenStreamIds()).toEqual(new Set()) + expect(getBlockedSenders()).toEqual(new Set()) + expect(() => hideStream('stream-1')).not.toThrow() + expect(() => blockSender('G SENDER')).not.toThrow() + }) + + it('ignores malformed stored JSON', () => { + localStorage.setItem('flowstar:hidden-streams', '{bad json') + localStorage.setItem('flowstar:blocked-senders', JSON.stringify({ sender: true })) + + expect(getHiddenStreamIds()).toEqual(new Set()) + expect(getBlockedSenders()).toEqual(new Set()) + }) +}) diff --git a/__tests__/lib/streams-cache.test.ts b/__tests__/lib/streams-cache.test.ts new file mode 100644 index 0000000..9abc778 --- /dev/null +++ b/__tests__/lib/streams-cache.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readCachedStreams, writeCachedStreams } from '@/lib/streams-cache' +import type { StreamData } from '@/types/stream' + +const stream: StreamData = { + id: '42', + sender: 'G SENDER', + recipient: 'G RECIPIENT', + token: { address: 'C TOKEN', symbol: 'TKN', decimals: 7 }, + depositedAmount: 1000n, + withdrawnAmount: 25n, + startTime: 100n, + endTime: 200n, + cliffTime: 100n, + cliffAmount: 0n, + amountPerSecond: 10n, + linearAmount: 1000n, + duration: 100n, + cancelled: false, +} + +describe('streams cache', () => { + beforeEach(() => { + localStorage.clear() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('writes and reads streams with bigint fields restored', () => { + writeCachedStreams('testnet', 'G ADDRESS', [stream]) + + expect(readCachedStreams('testnet', 'G ADDRESS')).toEqual({ + streams: [stream], + fetchedAt: new Date('2026-01-01T00:00:00.000Z').getTime(), + }) + expect(readCachedStreams('mainnet', 'G ADDRESS')).toBeNull() + }) + + it('preserves the original fetchedAt so callers can detect stale data', () => { + writeCachedStreams('testnet', 'G ADDRESS', [stream]) + vi.setSystemTime(new Date('2026-01-02T00:00:00.000Z')) + + const cached = readCachedStreams('testnet', 'G ADDRESS') + expect(cached?.fetchedAt).toBe(new Date('2026-01-01T00:00:00.000Z').getTime()) + expect(Date.now() - (cached?.fetchedAt ?? 0)).toBeGreaterThan(24 * 60 * 60 * 1000 - 1) + }) + + it('returns null and does not throw when window is unavailable', () => { + vi.stubGlobal('window', undefined) + + expect(readCachedStreams('testnet', 'G ADDRESS')).toBeNull() + expect(() => writeCachedStreams('testnet', 'G ADDRESS', [stream])).not.toThrow() + }) + + it('returns null for missing or corrupt cache entries', () => { + expect(readCachedStreams('testnet', 'G ADDRESS')).toBeNull() + localStorage.setItem('flowstar:streams-cache:testnet:G ADDRESS', 'not-json') + expect(readCachedStreams('testnet', 'G ADDRESS')).toBeNull() + }) +}) diff --git a/hooks/use-webhooks.ts b/hooks/use-webhooks.ts index 5bfdbdd..aeea361 100644 --- a/hooks/use-webhooks.ts +++ b/hooks/use-webhooks.ts @@ -308,6 +308,50 @@ export function useWebhooks(onSaveError?: () => void) { async (delivery: WebhookDelivery): Promise => { const hook = webhooks.find((h) => h.id === delivery.webhookId) if (!hook || !delivery.payload) return false - const result = await deliverWithRetry(hook.url, delivery.payload) + const result = await deliverWithRetry(hook.url, hook.secret, delivery.payload) -/* … truncated 1031 chars — edit only what you need near the top … */ + const newDelivery: WebhookDelivery = { + webhookId: delivery.webhookId, + eventType: delivery.eventType, + statusCode: result.statusCode, + deliveredAt: Date.now(), + success: result.success, + payload: delivery.payload, + } + setHistory((prev) => { + const next = [newDelivery, ...prev] + reportSaveResult(saveHistory(next)) + return next + }) + return result.success + }, + [webhooks, reportSaveResult], + ) + + const testWebhook = useCallback( + async (id: string): Promise => { + const hook = webhooks.find((h) => h.id === id) + if (!hook) return false + const payload = { + schema_version: WEBHOOK_SCHEMA_VERSION, + event: 'stream.created', + timestamp: new Date().toISOString(), + data: { stream_id: 0, note: 'FlowStar webhook test' }, + } + const result = await deliverWithRetry(hook.url, hook.secret, payload, 1) + return result.success + }, + [webhooks], + ) + + return { + webhooks, + history, + addWebhook, + removeWebhook, + toggleWebhook, + fireEvent, + testWebhook, + resendDelivery, + } +} diff --git a/lib/contract.ts b/lib/contract.ts index eecdc36..47cfbcb 100644 --- a/lib/contract.ts +++ b/lib/contract.ts @@ -360,14 +360,14 @@ export async function simulateCreateStreamPreview( const estimatedFee = Math.ceil(minFee * FEE_BUFFER) const feeXlm = (estimatedFee / 1e7).toFixed(4) const feeUsd = `~$${((estimatedFee / 1e7) * 0.08).toFixed(4)}` - const resources = successSim.transactionData.build().resources() + const resources = successSim.transactionData.build().resources return { success: true, estimatedFeeXlm: feeXlm, estimatedFeeUsd: feeUsd, - cpuInstructions: resources.instructions(), - memoryBytes: resources.readBytes() + resources.writeBytes(), + cpuInstructions: resources.instructions, + memoryBytes: resources.diskReadBytes + resources.writeBytes, } } catch (err) { return {