filecoin-pin
Version:
Bridge IPFS content to Filecoin Onchain Cloud using familiar tools
364 lines • 15.4 kB
JavaScript
/**
* 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';
// 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('../../synapse/payments.js', () => ({
checkFILBalance: vi.fn().mockResolvedValue({
balance: 1000000000000000000n, // 1 FIL
isCalibnet: true,
hasSufficientGas: true,
}),
checkUSDFCBalance: vi.fn().mockResolvedValue(1000000000000000000000n), // 1000 USDFC
checkAllowances: vi.fn().mockResolvedValue({
needsUpdate: false,
currentAllowances: {
rateAllowance: BigInt('0xffffffffffffffff'), // 2^64 - 1 (max)
lockupAllowance: BigInt('0xffffffffffffffff'), // 2^64 - 1 (max)
rateUsed: 0n,
lockupUsed: 0n,
},
}),
setMaxAllowances: vi.fn().mockResolvedValue({
transactionHash: '0x123...',
currentAllowances: {
rateAllowance: BigInt('0xffffffffffffffff'), // 2^64 - 1 (max)
lockupAllowance: BigInt('0xffffffffffffffff'), // 2^64 - 1 (max)
rateUsed: 0n,
lockupUsed: 0n,
},
}),
checkAndSetAllowances: vi.fn().mockResolvedValue({
updated: false,
currentAllowances: {
rateAllowance: BigInt('0xffffffffffffffff'), // 2^64 - 1 (max)
lockupAllowance: BigInt('0xffffffffffffffff'), // 2^64 - 1 (max)
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('../../payments/setup.js', () => ({
formatUSDFC: vi.fn((amount) => `${amount} USDFC`),
validatePaymentRequirements: vi.fn().mockReturnValue({ isValid: true }),
}));
vi.mock('../../synapse/service.js', async () => {
const { MockSynapse } = await import('../mocks/synapse-mocks.js');
return {
initializeSynapse: vi.fn(async (_config, _logger) => {
const mockSynapse = new MockSynapse();
return mockSynapse;
}),
createStorageContext: vi.fn(async (_synapse, _logger, progressCallbacks) => {
const mockSynapse = new MockSynapse();
// Simulate progress callbacks
if (progressCallbacks) {
// Simulate provider selection
setTimeout(() => {
progressCallbacks.onProviderSelected?.({
id: 1,
name: 'Mock Provider',
serviceProvider: '0x1234567890123456789012345678901234567890',
});
}, 10);
// Simulate dataset resolution
setTimeout(() => {
progressCallbacks.onDataSetResolved?.({
dataSetId: 123,
isExisting: false,
});
}, 20);
}
const mockStorage = await mockSynapse.storage.createContext();
return {
synapse: mockSynapse,
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, roots, blocks = []) {
const { writer, out } = await CarWriter.create(roots);
const writeStream = createWriteStream(filePath);
// Start piping the CAR output to file
const pipePromise = pipeline(out, writeStream);
// Track CIDs for test assertions
const cids = [];
// 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 = {
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 = {
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 = {
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 = {
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 = {
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('../../synapse/service.js');
const createContextSpy = vi.mocked(createStorageContext);
const options = {
filePath: carPath,
privateKey: testPrivateKey,
};
await runCarImport(options);
// Verify progress callbacks were provided to createStorageContext
expect(createContextSpy).toHaveBeenCalledWith(expect.any(Object), // synapse
expect.any(Object), // logger
expect.objectContaining({
onProviderSelected: expect.any(Function),
onDataSetCreationStarted: 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 = {
filePath: carPath,
// No private key provided
};
await expect(runCarImport(options)).rejects.toThrow('process.exit called');
expect(consoleMocks.error).toHaveBeenCalledWith('Import cancelled');
});
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 = {
filePath: carPath,
privateKey: testPrivateKey,
rpcUrl: customRpcUrl,
};
const { initializeSynapse } = await import('../../synapse/service.js');
const initSpy = vi.mocked(initializeSynapse);
await runCarImport(options);
expect(initSpy).toHaveBeenCalledWith(expect.objectContaining({
rpcUrl: customRpcUrl,
}), expect.any(Object));
});
});
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('../../synapse/service.js');
const cleanupSpy = vi.mocked(cleanupSynapseService);
const options = {
filePath: carPath,
privateKey: testPrivateKey,
};
await runCarImport(options);
expect(cleanupSpy).toHaveBeenCalled();
});
it('should call cleanup on error', async () => {
const { cleanupSynapseService } = await import('../../synapse/service.js');
const cleanupSpy = vi.mocked(cleanupSynapseService);
const options = {
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 = {
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');
});
});
});
//# sourceMappingURL=import.test.js.map