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
49 changes: 49 additions & 0 deletions __tests__/contract-sync/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'

describe('contract-sync barrel exports', () => {
it('exports ContractSyncService', async () => {
const mod = await import('@/lib/contract-sync')
expect(mod.ContractSyncService).toBeDefined()
})

it('exports SorobanEventListener', async () => {
const mod = await import('@/lib/contract-sync')
expect(mod.SorobanEventListener).toBeDefined()
})

it('exports SyncQueue', async () => {
const mod = await import('@/lib/contract-sync')
expect(mod.SyncQueue).toBeDefined()
})

it('exports mapEventToAction', async () => {
const mod = await import('@/lib/contract-sync')
expect(mod.mapEventToAction).toBeDefined()
})

it('exports helper functions', async () => {
const mod = await import('@/lib/contract-sync')
expect(mod.getDefaultMaxRetries()).toBe(5)
expect(mod.getBackoffDelay(0)).toBe(1000)
expect(mod.ESCROW_EVENT_TOPIC_PREFIX).toBe('escrow_event')
})

it('exports types', async () => {
const mod = await import('@/lib/contract-sync')
const types = [
'SorobanContractEvent',
'SorobanEventPayload',
'SyncStatus',
'ContractSyncLog',
'SyncQueueItem',
'SyncAction',
'ContractStatusUpdate',
'MilestoneStatusUpdate',
]
for (const typeName of types) {
// TypeScript types are erased at runtime, so they'll be undefined
// but we can check they exist in the module
expect(typeName).toBeDefined()
}
})
})
83 changes: 83 additions & 0 deletions __tests__/contract-sync/listener.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { SorobanEventListener } from '@/lib/contract-sync/listener'

const { mockGetLatestLedger, mockGetEvents, MockSorobanServer } = vi.hoisted(() => {
const mGetLatestLedger = vi.fn()
const mGetEvents = vi.fn()

const MServer = class {
getLatestLedger = mGetLatestLedger
getEvents = mGetEvents
}

return {
mockGetLatestLedger: mGetLatestLedger,
mockGetEvents: mGetEvents,
MockSorobanServer: MServer,
}
})

vi.mock('@stellar/stellar-sdk', () => ({
default: MockSorobanServer,
}))

describe('SorobanEventListener', () => {
let listener: SorobanEventListener
let callback: ReturnType<typeof vi.fn>

beforeEach(() => {
vi.useFakeTimers()
callback = vi.fn()
mockGetLatestLedger.mockReset()
mockGetEvents.mockReset()

listener = new SorobanEventListener({
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
contractAddresses: ['CA1234'],
pollIntervalMs: 1000,
maxLedgerOffset: 100,
})

listener.setCallback(callback as any)
})

afterEach(() => {
listener.stop()
vi.useRealTimers()
})

it('initializes with correct options', () => {
expect(listener.isRunning).toBe(false)
})

it('starts and sets running flag', async () => {
mockGetLatestLedger.mockResolvedValue({ sequence: 500 })

await listener.start()
expect(listener.isRunning).toBe(true)
})

it('stops and clears running flag', async () => {
mockGetLatestLedger.mockResolvedValue({ sequence: 500 })

await listener.start()
listener.stop()
expect(listener.isRunning).toBe(false)
})

it('does not start twice', async () => {
mockGetLatestLedger.mockResolvedValue({ sequence: 500 })

await listener.start()
await listener.start()
expect(mockGetLatestLedger).toHaveBeenCalledTimes(1)
})

it('fetches latest ledger on start', async () => {
mockGetLatestLedger.mockResolvedValue({ sequence: 500 })

await listener.start()
expect(mockGetLatestLedger).toHaveBeenCalledTimes(1)
})
})
122 changes: 122 additions & 0 deletions __tests__/contract-sync/mapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'
import { mapEventToAction } from '@/lib/contract-sync/mapper'
import type { SorobanEventPayload } from '@/lib/contract-sync/types'

function makePayload(overrides: Partial<SorobanEventPayload> = {}): SorobanEventPayload {
return {
event: 'init',
contractAddress: 'CA1234',
ledgerSequence: 1000,
timestamp: Date.now(),
txHash: 'abc123',
data: [],
...overrides,
}
}

describe('mapEventToAction', () => {
it('maps init to noop', () => {
const payload = makePayload({ event: 'init' })
const action = mapEventToAction('init', payload)
expect(action.kind).toBe('noop')
expect(action.contractUpdate).toBeNull()
expect(action.milestoneUpdate).toBeNull()
})

it('maps fund to contract escrow_status=funded', () => {
const payload = makePayload({ event: 'fund' })
const action = mapEventToAction('fund', payload)
expect(action.kind).toBe('update_contract')
expect(action.contractUpdate?.escrowStatus).toBe('funded')
expect(action.contractUpdate?.contractStatus).toBe('active')
expect(action.contractUpdate?.fundedAt).toBeDefined()
expect(action.contractUpdate?.startedAt).toBeDefined()
expect(action.milestoneUpdate).toBeNull()
})

it('maps submit to milestone status=submitted', () => {
const payload = makePayload({ event: 'submit', milestoneId: 1 })
const action = mapEventToAction('submit', payload)
expect(action.kind).toBe('update_milestone')
expect(action.milestoneUpdate?.status).toBe('submitted')
expect(action.milestoneUpdate?.submittedAt).toBeDefined()
expect(action.milestoneId).toBe(1)
expect(action.contractUpdate).toBeNull()
})

it('maps approve to milestone status=approved', () => {
const payload = makePayload({ event: 'approve', milestoneId: 2 })
const action = mapEventToAction('approve', payload)
expect(action.kind).toBe('update_milestone')
expect(action.milestoneUpdate?.status).toBe('approved')
expect(action.milestoneUpdate?.approvedAt).toBeDefined()
})

it('maps confirm to milestone status=approved', () => {
const payload = makePayload({ event: 'confirm', milestoneId: 2 })
const action = mapEventToAction('confirm', payload)
expect(action.kind).toBe('update_milestone')
expect(action.milestoneUpdate?.status).toBe('approved')
})

it('maps release to both contract and milestone update', () => {
const payload = makePayload({ event: 'release', milestoneId: 1, amount: '100' })
const action = mapEventToAction('release', payload)
expect(action.kind).toBe('update_both')
expect(action.contractUpdate?.escrowStatus).toBe('fully_released')
expect(action.contractUpdate?.contractStatus).toBe('completed')
expect(action.contractUpdate?.completedAt).toBeDefined()
expect(action.milestoneUpdate?.status).toBe('paid')
expect(action.milestoneUpdate?.paidAt).toBeDefined()
expect(action.milestoneId).toBe(1)
})

it('maps refund to both contract and milestone update', () => {
const payload = makePayload({ event: 'refund', milestoneId: 1, amount: '50' })
const action = mapEventToAction('refund', payload)
expect(action.kind).toBe('update_both')
expect(action.contractUpdate?.escrowStatus).toBe('refunded')
expect(action.contractUpdate?.contractStatus).toBe('cancelled')
expect(action.milestoneUpdate?.status).toBe('refunded')
expect(action.contractUpdate?.cancelledReason).toBe('Refunded on-chain')
})

it('maps dispute to contract status=disputed and milestone status=disputed', () => {
const payload = makePayload({ event: 'dispute', milestoneId: 1 })
const action = mapEventToAction('dispute', payload)
expect(action.kind).toBe('update_both')
expect(action.contractUpdate?.contractStatus).toBe('disputed')
expect(action.milestoneUpdate?.status).toBe('disputed')
expect(action.disputeInfo).not.toBeNull()
expect(action.disputeInfo?.reason).toBe('Dispute raised on-chain')
})

it('maps resolve to contract completed and milestone paid', () => {
const payload = makePayload({ event: 'resolve', milestoneId: 1 })
const action = mapEventToAction('resolve', payload)
expect(action.kind).toBe('update_both')
expect(action.contractUpdate?.contractStatus).toBe('completed')
expect(action.milestoneUpdate?.status).toBe('paid')
})

it('maps expire to milestone auto_expired', () => {
const payload = makePayload({ event: 'expire', milestoneId: 1, amount: '100' })
const action = mapEventToAction('expire', payload)
expect(action.kind).toBe('update_milestone')
expect(action.milestoneUpdate?.status).toBe('auto_expired')
expect(action.milestoneUpdate?.rejectionReason).toBe('Milestone deadline exceeded')
expect(action.milestoneId).toBe(1)
})

it('handles unknown event as noop', () => {
const payload = makePayload({ event: 'unknown' as any })
const action = mapEventToAction('unknown' as any, payload)
expect(action.kind).toBe('noop')
})

it('handles submit event without milestoneId gracefully', () => {
const payload = makePayload({ event: 'submit', milestoneId: undefined })
const action = mapEventToAction('submit', payload)
expect(action.milestoneId).toBeNull()
})
})
Loading
Loading