filecoin-pin
Version:
Bridge IPFS content to Filecoin Onchain Cloud using familiar tools
532 lines (453 loc) • 16.5 kB
text/typescript
/**
* Unit tests for CAR import functionality
*
* Tests the import command's ability to:
* - Validate CAR files
* - Handle various root CID scenarios
* - Initialize Synapse with progress callbacks
* - Upload to Filecoin
* - Clean up resources properly
*/
import { createWriteStream } from 'node:fs'
import { mkdir, rm, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { CarWriter } from '@ipld/car'
import { CID } from 'multiformats/cid'
import * as raw from 'multiformats/codecs/raw'
import { sha256 } from 'multiformats/hashes/sha2'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { runCarImport } from '../../import/import.js'
import type { ImportOptions } from '../../import/types.js'
// Test constants
const ZERO_CID = 'bafkqaaa' // Zero CID used when CAR has no roots
// Mock modules
vi.mock('@filoz/synapse-sdk', async () => await import('../mocks/synapse-sdk.js'))
vi.mock('../../common/upload-flow.js', () => ({
validatePaymentSetup: vi.fn(),
performUpload: vi.fn().mockResolvedValue({
pieceCid: 'bafkzcibtest1234567890',
pieceId: 789,
dataSetId: '123',
network: 'calibration',
transactionHash: '0xabc123',
providerInfo: {
id: 1,
name: 'Test Provider',
serviceURL: 'http://test.provider',
},
}),
displayUploadResults: vi.fn(),
performAutoFunding: vi.fn(),
}))
vi.mock('../../core/payments/index.js', async () => {
const actual = await vi.importActual<typeof import('../../core/payments/index.js')>('../../core/payments/index.js')
return {
...actual,
checkFILBalance: vi.fn().mockResolvedValue({
balance: 1000000000000000000n,
isCalibnet: true,
hasSufficientGas: true,
}),
checkUSDFCBalance: vi.fn().mockResolvedValue(1000000000000000000000n),
checkAllowances: vi.fn().mockResolvedValue({
needsUpdate: false,
currentAllowances: {
rateAllowance: BigInt('0xffffffffffffffff'),
lockupAllowance: BigInt('0xffffffffffffffff'),
rateUsed: 0n,
lockupUsed: 0n,
},
}),
setMaxAllowances: vi.fn().mockResolvedValue({
transactionHash: '0x123...',
currentAllowances: {
rateAllowance: BigInt('0xffffffffffffffff'),
lockupAllowance: BigInt('0xffffffffffffffff'),
rateUsed: 0n,
lockupUsed: 0n,
},
}),
checkAndSetAllowances: vi.fn().mockResolvedValue({
updated: false,
currentAllowances: {
rateAllowance: BigInt('0xffffffffffffffff'),
lockupAllowance: BigInt('0xffffffffffffffff'),
rateUsed: 0n,
lockupUsed: 0n,
},
}),
validatePaymentCapacity: vi.fn().mockResolvedValue({
canUpload: true,
storageTiB: 0.001,
required: {
rateAllowance: 100000000000000n,
lockupAllowance: 1000000000000000000n,
storageCapacityTiB: 0.001,
},
issues: {},
suggestions: [],
}),
}
})
vi.mock('../../core/utils/validate-ipni-advertisement.js', () => ({
waitForIpniProviderResults: vi.fn().mockResolvedValue(true),
}))
vi.mock('../../payments/setup.js', () => ({
formatUSDFC: vi.fn((amount) => `${amount} USDFC`),
validatePaymentRequirements: vi.fn().mockReturnValue({ isValid: true }),
}))
vi.mock('../../core/synapse/index.js', async () => {
const { MockSynapse } = await import('../mocks/synapse-mocks.js')
return {
isSessionKeyMode: vi.fn(() => false),
initializeSynapse: vi.fn(async (config: any, _logger: any) => {
// Validate auth config (mirrors validateAuthConfig in actual code)
const hasStandardAuth = config.privateKey != null
const hasSessionKeyAuth = config.walletAddress != null && config.sessionKey != null
if (!hasStandardAuth && !hasSessionKeyAuth) {
throw new Error('Authentication required: provide either a privateKey or walletAddress + sessionKey')
}
const mockSynapse = new MockSynapse()
return mockSynapse
}),
createStorageContext: vi.fn(async (_synapse: any, _logger: any, options?: any) => {
const mockSynapse = new MockSynapse()
// Simulate progress callbacks
if (options?.callbacks) {
// Simulate provider selection
setTimeout(() => {
options.callbacks.onProviderSelected?.({
id: 1,
name: 'Mock Provider',
serviceProvider: '0x1234567890123456789012345678901234567890',
})
}, 10)
// Simulate dataset resolution
setTimeout(() => {
options.callbacks.onDataSetResolved?.({
dataSetId: 123,
isExisting: false,
})
}, 20)
}
const mockStorage = await mockSynapse.storage.createContext()
return {
synapse: mockSynapse as any,
storage: mockStorage,
providerInfo: {
id: 1,
name: 'Mock Provider',
serviceProvider: '0x1234567890123456789012345678901234567890',
products: {
PDP: {
data: {
serviceURL: 'http://localhost:8888/pdp',
},
},
},
},
}
}),
cleanupSynapseService: vi.fn(async () => {
// Mock cleanup
}),
}
})
// Mock console methods to capture output
const consoleMocks = {
log: vi.spyOn(console, 'log').mockImplementation(() => {
// Intentionally empty - suppressing console output in tests
}),
error: vi.spyOn(console, 'error').mockImplementation(() => {
// Intentionally empty - suppressing console output in tests
}),
}
// Mock process.exit to prevent test runner from exiting
const processExitMock = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called')
})
/**
* Create a test CAR file with the given content
*
* @param filePath - Path where the CAR file should be written
* @param roots - Root CIDs for the CAR header
* @param blocks - Array of { content, cid? } to add to the CAR
*/
async function createTestCarFile(
filePath: string,
roots: CID[],
blocks: Array<{ content: string | Uint8Array; cid?: CID }> = []
): Promise<{ cids: CID[] }> {
const { writer, out } = await CarWriter.create(roots)
const writeStream = createWriteStream(filePath)
// Start piping the CAR output to file
const pipePromise = pipeline(out as any, writeStream)
// Track CIDs for test assertions
const cids: CID[] = []
// Write blocks
for (const block of blocks) {
const bytes = typeof block.content === 'string' ? new TextEncoder().encode(block.content) : block.content
let cid = block.cid
if (!cid) {
const hash = await sha256.digest(bytes)
cid = CID.create(1, raw.code, hash)
}
cids.push(cid)
await writer.put({ cid, bytes })
}
await writer.close()
await pipePromise
return { cids }
}
describe('CAR Import', () => {
const testDir = './test-import-cars'
const testPrivateKey = '0x0000000000000000000000000000000000000000000000000000000000000001'
beforeEach(async () => {
// Create test directory
await mkdir(testDir, { recursive: true })
// Clear all mocks
vi.clearAllMocks()
consoleMocks.log.mockClear()
consoleMocks.error.mockClear()
processExitMock.mockClear()
})
afterEach(async () => {
// Clean up test files
try {
await stat(testDir)
await rm(testDir, { recursive: true, force: true })
} catch {
// Directory doesn't exist, nothing to clean up
}
})
describe('CAR File Validation', () => {
it('should validate a proper CAR file with single root', async () => {
const carPath = join(testDir, 'valid.car')
const { cids } = await createTestCarFile(
carPath,
[], // Will use first block's CID as root
[{ content: 'test content' }]
)
const cid = cids[0]
if (!cid) throw new Error('No CID generated')
// Update CAR with proper root
await createTestCarFile(carPath, [cid], [{ content: 'test content', cid }])
const options: ImportOptions = {
filePath: carPath,
privateKey: testPrivateKey,
}
const result = await runCarImport(options)
expect(result.rootCid).toBe(cid.toString())
expect(result.filePath).toBe(carPath)
expect(result.pieceCid).toBeDefined()
expect(result.dataSetId).toBeDefined()
})
it('should handle CAR file with no roots (use zero CID)', async () => {
const carPath = join(testDir, 'no-roots.car')
await createTestCarFile(
carPath,
[], // Empty roots array
[{ content: 'test content' }]
)
const options: ImportOptions = {
filePath: carPath,
privateKey: testPrivateKey,
}
const result = await runCarImport(options)
expect(result.rootCid).toBe(ZERO_CID) // Zero CID
expect(result.filePath).toBe(carPath)
expect(result.pieceCid).toBeDefined()
})
it('should handle CAR file with multiple roots (use first)', async () => {
const carPath = join(testDir, 'multi-roots.car')
const { cids } = await createTestCarFile(
carPath,
[], // Will set roots after creating CIDs
[{ content: 'content 1' }, { content: 'content 2' }]
)
const cid1 = cids[0]
const cid2 = cids[1]
if (!cid1 || !cid2) throw new Error('CIDs not generated')
// Recreate with multiple roots
await createTestCarFile(
carPath,
[cid1, cid2], // Multiple roots
[
{ content: 'content 1', cid: cid1 },
{ content: 'content 2', cid: cid2 },
]
)
const options: ImportOptions = {
filePath: carPath,
privateKey: testPrivateKey,
}
const result = await runCarImport(options)
expect(result.rootCid).toBe(cid1.toString()) // Should use first CID
expect(consoleMocks.log).toHaveBeenCalledWith(expect.stringContaining('Multiple root CIDs found'))
})
it('should reject invalid CAR file', async () => {
const invalidCarPath = join(testDir, 'invalid.car')
await writeFile(invalidCarPath, 'not a car file')
const options: ImportOptions = {
filePath: invalidCarPath,
privateKey: testPrivateKey,
}
await expect(runCarImport(options)).rejects.toThrow('process.exit called')
expect(consoleMocks.error).toHaveBeenCalledWith('Import cancelled')
})
it('should reject non-existent file', async () => {
const options: ImportOptions = {
filePath: join(testDir, 'nonexistent.car'),
privateKey: testPrivateKey,
}
await expect(runCarImport(options)).rejects.toThrow('process.exit called')
expect(consoleMocks.error).toHaveBeenCalledWith('Import cancelled')
})
})
describe('Synapse Integration', () => {
it('should show progress during initialization', async () => {
const carPath = join(testDir, 'progress.car')
await createTestCarFile(
carPath,
[], // Will use first block's CID
[{ content: 'test content' }]
)
const { createStorageContext } = await import('../../core/synapse/index.js')
const createContextSpy = vi.mocked(createStorageContext)
const options: ImportOptions = {
filePath: carPath,
privateKey: testPrivateKey,
}
await runCarImport(options)
// Verify progress callbacks were provided to createStorageContext
expect(createContextSpy).toHaveBeenCalledWith(
expect.any(Object), // synapse
expect.objectContaining({
logger: expect.anything(),
callbacks: expect.objectContaining({
onProviderSelected: expect.any(Function),
onDataSetResolved: expect.any(Function),
}),
})
)
})
it('should require private key', async () => {
const carPath = join(testDir, 'test.car')
await createTestCarFile(carPath, [], [{ content: 'test content' }])
const options: ImportOptions = {
filePath: carPath,
// No private key provided
}
await expect(runCarImport(options)).rejects.toThrow('process.exit called')
// The error is caught and logged generically as "Import failed"
expect(consoleMocks.error).toHaveBeenCalled()
})
it('should use custom RPC URL if provided', async () => {
const carPath = join(testDir, 'rpc.car')
await createTestCarFile(carPath, [], [{ content: 'test content' }])
const customRpcUrl = 'wss://custom.rpc.url/ws'
const options: ImportOptions = {
filePath: carPath,
privateKey: testPrivateKey,
rpcUrl: customRpcUrl,
}
const { initializeSynapse } = await import('../../core/synapse/index.js')
const initSpy = vi.mocked(initializeSynapse)
await runCarImport(options)
expect(initSpy).toHaveBeenCalledWith(
expect.objectContaining({
rpcUrl: customRpcUrl,
}),
expect.any(Object)
)
})
it('passes metadata options through to upload and storage context', async () => {
const carPath = join(testDir, 'metadata.car')
await createTestCarFile(carPath, [], [{ content: 'test content' }])
const options: ImportOptions = {
filePath: carPath,
privateKey: testPrivateKey,
pieceMetadata: { ics: '8004' },
dataSetMetadata: { erc8004Files: '' },
}
await runCarImport(options)
const { createStorageContext, initializeSynapse } = await import('../../core/synapse/index.js')
expect(vi.mocked(initializeSynapse)).toHaveBeenCalledWith(
expect.objectContaining({
dataSetMetadata: { erc8004Files: '' },
}),
expect.any(Object)
)
expect(vi.mocked(createStorageContext)).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
dataset: {
metadata: { erc8004Files: '' },
},
logger: expect.anything(),
})
)
const { performUpload } = await import('../../common/upload-flow.js')
expect(vi.mocked(performUpload)).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Uint8Array),
expect.any(Object),
expect.objectContaining({
pieceMetadata: { ics: '8004' },
})
)
})
})
describe('Cleanup', () => {
it('should call cleanup on success', async () => {
const carPath = join(testDir, 'cleanup.car')
await createTestCarFile(carPath, [], [{ content: 'test content' }])
const { cleanupSynapseService } = await import('../../core/synapse/index.js')
const cleanupSpy = vi.mocked(cleanupSynapseService)
const options: ImportOptions = {
filePath: carPath,
privateKey: testPrivateKey,
}
await runCarImport(options)
expect(cleanupSpy).toHaveBeenCalled()
})
it('should call cleanup on error', async () => {
const { cleanupSynapseService } = await import('../../core/synapse/index.js')
const cleanupSpy = vi.mocked(cleanupSynapseService)
const options: ImportOptions = {
filePath: 'nonexistent.car',
privateKey: testPrivateKey,
}
await expect(runCarImport(options)).rejects.toThrow('process.exit called')
expect(cleanupSpy).toHaveBeenCalled()
})
})
describe('Upload Result', () => {
it('should return complete import result', async () => {
const carPath = join(testDir, 'result.car')
const { cids } = await createTestCarFile(carPath, [], [{ content: 'test content' }])
const cid = cids[0]
if (!cid) throw new Error('No CID generated')
// Recreate with proper root
await createTestCarFile(carPath, [cid], [{ content: 'test content', cid }])
const options: ImportOptions = {
filePath: carPath,
privateKey: testPrivateKey,
}
const result = await runCarImport(options)
expect(result).toMatchObject({
filePath: carPath,
fileSize: expect.any(Number),
rootCid: cid.toString(),
pieceCid: expect.stringMatching(/^bafkzcib/), // CommP prefix
pieceId: expect.any(Number),
dataSetId: '123', // Mock returns string
})
// Provider info is always present
expect(result.providerInfo).toBeDefined()
expect(result.providerInfo.id).toBe(1)
expect(result.providerInfo.name).toBe('Mock Provider')
})
})
})