Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions __tests__/lib/contract.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
53 changes: 53 additions & 0 deletions __tests__/lib/federation.test.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
})
74 changes: 74 additions & 0 deletions __tests__/lib/hidden-streams.test.ts
Original file line number Diff line number Diff line change
@@ -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())
})
})
65 changes: 65 additions & 0 deletions __tests__/lib/streams-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
48 changes: 46 additions & 2 deletions hooks/use-webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,50 @@ export function useWebhooks(onSaveError?: () => void) {
async (delivery: WebhookDelivery): Promise<boolean> => {
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<boolean> => {
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,
}
}
6 changes: 3 additions & 3 deletions lib/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down