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
190 changes: 189 additions & 1 deletion __tests__/lib/contract-scval.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Account, Keypair, StrKey, nativeToScVal, xdr } from '@stellar/stellar-sdk'
import { NETWORKS } from '@/lib/stellar'
import { fetchStream } from '@/lib/contract'
import { createStream, estimateCreateStreamFee, fetchStream } from '@/lib/contract'

// Issue #928: coverage for scValToStreamData (lib/contract.ts). The decoder is
// module-private, so it is exercised through fetchStream's live path with a
// fake RPC server whose simulation returns a contract-shaped Stream ScVal.

const fakeServer = vi.hoisted(() => ({
getAccount: vi.fn(),
getLatestLedger: vi.fn(),
simulateTransaction: vi.fn(),
}))

Expand Down Expand Up @@ -107,7 +108,7 @@
const raw = makeRaw()
const stream = await decode(toScVal(raw))

expect(stream).toEqual({

Check failure on line 111 in __tests__/lib/contract-scval.test.ts

View workflow job for this annotation

GitHub Actions / build

__tests__/lib/contract-scval.test.ts > scValToStreamData (via fetchStream) > round-trips a stream built with nativeToScVal

AssertionError: expected null to deeply equal { id: '42', …(13) } - Expected: { "amountPerSecond": 104166n, "cancelled": false, "cliffAmount": 1000000000n, "cliffTime": 1700003600n, "depositedAmount": 10000000000n, "duration": 86400n, "endTime": 1700086400n, "id": "42", "linearAmount": 9000000000n, "recipient": "GAG4ZIP4V5J5GCLO7N2TVRQWAW4C2U6PDY5W3DP2I34Z75FA2C7YOR43", "sender": "GDSTPFJRT4O2AYNQAXOTX25QHAOTLOTJFWNZQ52MCEJBQ243DIB2CLA2", "startTime": 1700000000n, "token": { "address": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", "decimals": 7, "symbol": "XLM", }, "withdrawnAmount": 2500000000n, } + Received: null ❯ __tests__/lib/contract-scval.test.ts:111:20
id: '42',
sender: SENDER,
recipient: RECIPIENT,
Expand All @@ -127,7 +128,7 @@

it('queries get_stream once per fetch', async () => {
await decode(toScVal(makeRaw()))
expect(fakeServer.simulateTransaction).toHaveBeenCalledTimes(1)

Check failure on line 131 in __tests__/lib/contract-scval.test.ts

View workflow job for this annotation

GitHub Actions / build

__tests__/lib/contract-scval.test.ts > scValToStreamData (via fetchStream) > queries get_stream once per fetch

AssertionError: expected "vi.fn()" to be called 1 times, but got 0 times ❯ __tests__/lib/contract-scval.test.ts:131:44
})

it('keeps every amount and timestamp field as bigint without precision loss', async () => {
Expand All @@ -143,7 +144,7 @@
cliff_time: 2n ** 63n + 1n,
})
const stream = await decode(toScVal(raw))
expect(stream).not.toBeNull()

Check failure on line 147 in __tests__/lib/contract-scval.test.ts

View workflow job for this annotation

GitHub Actions / build

__tests__/lib/contract-scval.test.ts > scValToStreamData (via fetchStream) > keeps every amount and timestamp field as bigint without precision loss

AssertionError: expected null not to be null ❯ __tests__/lib/contract-scval.test.ts:147:24

const bigintFields = [
'depositedAmount',
Expand All @@ -167,17 +168,17 @@

it('decodes the cancelled flag', async () => {
const stream = await decode(toScVal(makeRaw({ cancelled: true })))
expect(stream!.cancelled).toBe(true)

Check failure on line 171 in __tests__/lib/contract-scval.test.ts

View workflow job for this annotation

GitHub Actions / build

__tests__/lib/contract-scval.test.ts > scValToStreamData (via fetchStream) > decodes the cancelled flag

TypeError: Cannot read properties of null (reading 'cancelled') ❯ __tests__/lib/contract-scval.test.ts:171:20
})

it('falls back to an UNK token for an unknown token address', async () => {
const stream = await decode(toScVal(makeRaw({ token: UNKNOWN_TOKEN })))
expect(stream!.token).toEqual({ address: UNKNOWN_TOKEN, symbol: 'UNK', decimals: 7 })

Check failure on line 176 in __tests__/lib/contract-scval.test.ts

View workflow job for this annotation

GitHub Actions / build

__tests__/lib/contract-scval.test.ts > scValToStreamData (via fetchStream) > falls back to an UNK token for an unknown token address

TypeError: Cannot read properties of null (reading 'token') ❯ __tests__/lib/contract-scval.test.ts:176:20
})

it('leaves metadata undefined when the ScVal has none', async () => {
const stream = await decode(toScVal(makeRaw()))
expect(stream).not.toHaveProperty('metadata')

Check failure on line 181 in __tests__/lib/contract-scval.test.ts

View workflow job for this annotation

GitHub Actions / build

__tests__/lib/contract-scval.test.ts > scValToStreamData (via fetchStream) > leaves metadata undefined when the ScVal has none

TypeError: Cannot convert undefined or null to object ❯ __tests__/lib/contract-scval.test.ts:181:24
})

it('still decodes when a metadata entry is present, without mapping it', async () => {
Expand All @@ -189,7 +190,7 @@
})
const stream = await decode(toScVal(raw))

expect(stream).toMatchObject({ id: '42', depositedAmount: raw.deposited_amount })

Check failure on line 193 in __tests__/lib/contract-scval.test.ts

View workflow job for this annotation

GitHub Actions / build

__tests__/lib/contract-scval.test.ts > scValToStreamData (via fetchStream) > still decodes when a metadata entry is present, without mapping it

AssertionError: expected null to match object { id: '42', …(1) } - Expected: { "depositedAmount": 10000000000n, "id": "42", } + Received: null ❯ __tests__/lib/contract-scval.test.ts:193:20
expect(stream!.metadata).toBeUndefined()
})

Expand All @@ -199,3 +200,190 @@
expect(stream).toBeNull()
})
})

// Issue #929: coverage for buildCreateStreamInputScVal / buildCreateStreamParamsScVal
// (lib/contract.ts). Both builders are module-private, so they are exercised through
// the public live-mode paths that use them: estimateCreateStreamFee (params map) and
// createStream (input map). The ScVal args are captured from the simulated transaction.

const PARAMS_FIELDS = [
'cliff_amount',
'cliff_time',
'end_time',
'recipient',
'start_time',
'token',
'total_amount',
]

const INPUT_FIELDS = [
'recipient',
'token',
'total_amount',
'start_time',
'end_time',
'cliff_time',
'cliff_amount',
]

const TOKEN = XLM.address
const BIG = 2n ** 70n + 12_345n

interface CreateArgs {
recipient: string
token: string
totalAmount: bigint
startTime: bigint
endTime: bigint
cliffTime: bigint
cliffAmount: bigint
metadata?: string
}

function makeArgs(overrides: Partial<CreateArgs> = {}): CreateArgs {
return {
recipient: RECIPIENT,
token: TOKEN,
totalAmount: 10_000_000_000n,
startTime: 1_700_000_000n,
endTime: 1_700_086_400n,
cliffTime: 1_700_003_600n,
cliffAmount: 1_000_000_000n,
...overrides,
}
}

/** Pull the contract-call operation's args out of the simulated transaction. */
function capturedArgs(): xdr.ScVal[] {
const tx = fakeServer.simulateTransaction.mock.calls.at(-1)?.[0] as xdr.Transaction
const op = tx.operations()[0]
return op.body().invokeHostFunctionOp().hostFunction().invokeContract().args()
}

/** Convert an ScVal map into an ordered list of [key, value] entries. */
function mapEntries(val: xdr.ScVal): Array<[string, xdr.ScVal]> {
expect(val.switch().name).toBe('scvMap')
return val
.map()!
.map((entry) => [entry.key().sym().toString(), entry.val()] as [string, xdr.ScVal])
}

function expectI128(val: xdr.ScVal, expected: bigint) {
expect(val.switch().name).toBe('scvI128')
const parts = val.i128()
const hi = BigInt(parts.hi().toString())
const lo = BigInt(parts.lo().toString())
expect((hi << 64n) + lo).toBe(expected)
}

function expectU64(val: xdr.ScVal, expected: bigint) {
expect(val.switch().name).toBe('scvU64')
expect(BigInt(val.u64().toString())).toBe(expected)
}

function expectAddress(val: xdr.ScVal, expected: string) {
expect(val.switch().name).toBe('scvAddress')
expect(StrKey.encodeContract(Buffer.from(val.address().contractId()))).toBe(expected)
}

describe('buildCreateStreamParamsScVal (via estimateCreateStreamFee)', () => {
beforeEach(() => {
fakeServer.getLatestLedger.mockResolvedValue({ sequence: 1 })
fakeServer.simulateTransaction.mockResolvedValue({
result: { retval: nativeToScVal(0, { type: 'i128' }) },
})
})

it('emits the contract field names in the expected order', async () => {
await estimateCreateStreamFee('testnet', makeArgs())
const entries = mapEntries(capturedArgs()[0])
expect(entries.map(([key]) => key)).toEqual(PARAMS_FIELDS)
})

it('encodes amounts as i128 and times as u64', async () => {
const args = makeArgs()
await estimateCreateStreamFee('testnet', args)
const map = new Map(mapEntries(capturedArgs()[0]))

expectI128(map.get('total_amount')!, args.totalAmount)
expectI128(map.get('cliff_amount')!, args.cliffAmount)
expectU64(map.get('start_time')!, args.startTime)
expectU64(map.get('end_time')!, args.endTime)
expectU64(map.get('cliff_time')!, args.cliffTime)
})

it('encodes recipient and token as addresses', async () => {
const args = makeArgs()
await estimateCreateStreamFee('testnet', args)
const map = new Map(mapEntries(capturedArgs()[0]))

expectAddress(map.get('recipient')!, args.recipient)
expectAddress(map.get('token')!, args.token)
})

it('preserves bigint values above Number.MAX_SAFE_INTEGER', async () => {
const args = makeArgs({ totalAmount: BIG, cliffAmount: BIG - 1n })
await estimateCreateStreamFee('testnet', args)
const map = new Map(mapEntries(capturedArgs()[0]))

expectI128(map.get('total_amount')!, BIG)
expectI128(map.get('cliff_amount')!, BIG - 1n)
})

it('does not include a metadata entry', async () => {
await estimateCreateStreamFee('testnet', makeArgs({ metadata: 'Q1 Salary' }))
const keys = mapEntries(capturedArgs()[0]).map(([key]) => key)
expect(keys).not.toContain('metadata')
})
})

describe('buildCreateStreamInputScVal (via createStream)', () => {
beforeEach(() => {
fakeServer.getLatestLedger.mockResolvedValue({ sequence: 1 })
fakeServer.simulateTransaction.mockResolvedValue({
result: { retval: nativeToScVal(42, { type: 'u64' }) },
})
})

it('emits the contract field names in the expected order', async () => {
await createStream('testnet', makeArgs())
const entries = mapEntries(capturedArgs()[0])
expect(entries.map(([key]) => key)).toEqual(INPUT_FIELDS)
})

it('encodes amounts as i128 and times as u64', async () => {
const args = makeArgs()
await createStream('testnet', args)
const map = new Map(mapEntries(capturedArgs()[0]))

expectI128(map.get('total_amount')!, args.totalAmount)
expectI128(map.get('cliff_amount')!, args.cliffAmount)
expectU64(map.get('start_time')!, args.startTime)
expectU64(map.get('end_time')!, args.endTime)
expectU64(map.get('cliff_time')!, args.cliffTime)
})

it('encodes recipient and token as addresses', async () => {
const args = makeArgs()
await createStream('testnet', args)
const map = new Map(mapEntries(capturedArgs()[0]))

expectAddress(map.get('recipient')!, args.recipient)
expectAddress(map.get('token')!, args.token)
})

it('preserves bigint values above Number.MAX_SAFE_INTEGER', async () => {
const args = makeArgs({ totalAmount: BIG, cliffAmount: BIG - 1n })
await createStream('testnet', args)
const map = new Map(mapEntries(capturedArgs()[0]))

expectI128(map.get('total_amount')!, BIG)
expectI128(map.get('cliff_amount')!, BIG - 1n)
})

it('does not include a metadata entry', async () => {
await createStream('testnet', makeArgs({ metadata: 'Q1 Salary' }))
const keys = mapEntries(capturedArgs()[0]).map(([key]) => key)
expect(keys).not.toContain('metadata')
})
})
68 changes: 68 additions & 0 deletions __tests__/lib/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,21 @@ import {
fetchStream,
fetchStreamsForAddress,
fetchArchivedSentStreamIds,
getTokenMetadata,
simulateCreateStreamPreview,
withdrawFromStream,
} from '@/lib/contract'
import type { CreateStreamInput } from '@/types/stream'
import { getServer } from '@/lib/stellar'
import { nativeToScVal } from '@stellar/stellar-sdk'

vi.mock('@/lib/stellar', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/stellar')>()
return {
...actual,
getServer: vi.fn(),
}
})

const sender = 'GTEST-SENDER'
const recipient = 'GTEST-RECIPIENT'
Expand Down Expand Up @@ -99,3 +110,60 @@ describe('contract mock mode', () => {
await expect(fetchArchivedSentStreamIds('testnet', sender)).resolves.toContain(id)
})
})

describe('getTokenMetadata', () => {
const tokenAddress = NETWORKS.testnet.knownTokens[0]

beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET', '')
})

afterEach(() => {
vi.unstubAllEnvs()
vi.clearAllMocks()
})

it('returns mock metadata without network calls when no contract ID is configured', async () => {
const metadata = await getTokenMetadata('testnet', tokenAddress)

expect(metadata).toMatchObject({
address: tokenAddress,
name: expect.any(String),
symbol: expect.any(String),
decimals: expect.any(Number),
})
expect(getServer).not.toHaveBeenCalled()
})

it('maps a successful simulation retval to token metadata', async () => {
vi.stubEnv('NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET', 'CTEST-CONTRACT')

const simulateTransaction = vi.fn().mockResolvedValue({
result: { retval: nativeToScVal({ name: 'Test Token', symbol: 'TST', decimals: 7 }) },
})
const getAccount = vi.fn().mockResolvedValue({ accountId: sender, sequenceNumber: () => '1' })
vi.mocked(getServer).mockReturnValue({ getAccount, simulateTransaction } as never)

const metadata = await getTokenMetadata('testnet', tokenAddress)

expect(simulateTransaction).toHaveBeenCalledTimes(1)
expect(metadata).toMatchObject({
address: tokenAddress,
name: 'Test Token',
symbol: 'TST',
decimals: 7,
})
})

it('returns a sensible error when simulation fails', async () => {
vi.stubEnv('NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET', 'CTEST-CONTRACT')

const simulateTransaction = vi.fn().mockResolvedValue({
error: 'HostError: contract trapped',
})
const getAccount = vi.fn().mockResolvedValue({ accountId: sender, sequenceNumber: () => '1' })
vi.mocked(getServer).mockReturnValue({ getAccount, simulateTransaction } as never)

await expect(getTokenMetadata('testnet', tokenAddress)).rejects.toThrow(/Simulation failed/i)
})
})
97 changes: 97 additions & 0 deletions __tests__/lib/export.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { streamsToCSV, downloadCSV, downloadJSON } from '@/lib/export'
import { describe, it, expect } from 'vitest'
import { streamsToCSV, webhookHistoryToCSV } from '@/lib/export'
import type { StreamData } from '@/types/stream'
Expand Down Expand Up @@ -83,6 +85,101 @@
})
})

describe('downloadCSV / downloadJSON', () => {
const FAKE_URL = 'blob:fake-object-url'
let createObjectURL: ReturnType<typeof vi.fn>
let revokeObjectURL: ReturnType<typeof vi.fn>
let clickSpy: ReturnType<typeof vi.spyOn>
let createdAnchors: HTMLAnchorElement[]

beforeEach(() => {
createObjectURL = vi.fn(() => FAKE_URL)
revokeObjectURL = vi.fn()
vi.stubGlobal('URL', {
...URL,
createObjectURL,
revokeObjectURL,
})

createdAnchors = []
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation(
(tagName: string, options?: ElementCreationOptions) => {
const el = originalCreateElement(tagName, options)
if (tagName.toLowerCase() === 'a') {
createdAnchors.push(el as HTMLAnchorElement)
}
return el
},
)
clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
})

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

it('downloadCSV creates a Blob with the CSV MIME type and content', () => {
const csv = 'a,b\n1,2'
downloadCSV(csv, 'streams.csv')

expect(createObjectURL).toHaveBeenCalledTimes(1)
const blob = createObjectURL.mock.calls[0][0] as Blob
expect(blob).toBeInstanceOf(Blob)
expect(blob.type).toBe('text/csv;charset=utf-8;')
return blob.text().then((text) => {
expect(text).toBe(csv)
})
})

it('downloadCSV triggers a download with the given filename', () => {
downloadCSV('a,b\n1,2', 'my-streams.csv')

expect(createdAnchors).toHaveLength(1)
const anchor = createdAnchors[0]
expect(anchor.download).toBe('my-streams.csv')
expect(anchor.href).toBe(FAKE_URL)
expect(clickSpy).toHaveBeenCalledTimes(1)
})

it('downloadCSV revokes the object URL', () => {
downloadCSV('a,b\n1,2', 'streams.csv')

expect(revokeObjectURL).toHaveBeenCalledTimes(1)
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL)
})

it('downloadJSON creates a Blob with the JSON MIME type and pretty-printed content', () => {
const data = { foo: 'bar', nested: { n: 1 } }
downloadJSON(data, 'data.json')

expect(createObjectURL).toHaveBeenCalledTimes(1)
const blob = createObjectURL.mock.calls[0][0] as Blob
expect(blob).toBeInstanceOf(Blob)
expect(blob.type).toBe('application/json;charset=utf-8;')
return blob.text().then((text) => {
expect(text).toBe(JSON.stringify(data, null, 2))
})
})

it('downloadJSON triggers a download with the given filename', () => {
downloadJSON({ foo: 'bar' }, 'my-data.json')

expect(createdAnchors).toHaveLength(1)
const anchor = createdAnchors[0]
expect(anchor.download).toBe('my-data.json')
expect(anchor.href).toBe(FAKE_URL)
expect(clickSpy).toHaveBeenCalledTimes(1)
})

it('downloadJSON revokes the object URL', () => {
downloadJSON({ foo: 'bar' }, 'data.json')

expect(revokeObjectURL).toHaveBeenCalledTimes(1)
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL)
interface WebhookDelivery {
webhookId: string
eventType: string
Expand Down Expand Up @@ -150,3 +247,3 @@
expect(csv).toContain('"he said ""hi"""')
})
})
Loading