Files
salvium-rs/test/wallet-store.test.js
T
Matt Hess e751b6569a ● Add comprehensive test coverage for wallet modules
- Add tests for wallet-store.js (40 tests: MemoryStorage, WalletOutput, WalletTransaction)
  - Add tests for wallet-sync.js (25 tests: sync engine, events, progress tracking)
  - Add tests for query.js (45 tests: OutputQuery, TxQuery, TransferQuery, presets)
  - Add tests for connection-manager.js (29 tests: ConnectionInfo, failover, events)
  - Add tests for offline.js (29 tests: unsigned/signed tx, key images, output export)
  - Add tests for multisig.js (39 tests: KEX, signers, accounts, tx sets)
  - Add tests for persistent-wallet.js (27 tests: lifecycle, balance, outputs, sync)
  - Update test/all.js to include new test suites
  - Rename SYNC_UNLOCK_BLOCKS to avoid export conflict with wallet.js
2026-01-18 23:43:58 +00:00

610 lines
18 KiB
JavaScript

#!/usr/bin/env bun
/**
* Wallet Storage Tests
*
* Tests for wallet-store.js:
* - WalletOutput model
* - WalletTransaction model
* - MemoryStorage implementation
* - Storage queries and operations
*/
import {
WalletStorage,
WalletOutput,
WalletTransaction,
MemoryStorage,
createStorage
} from '../src/wallet-store.js';
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (e) {
console.log(`${name}`);
console.log(` Error: ${e.message}`);
failed++;
}
}
async function testAsync(name, fn) {
try {
await fn();
console.log(`${name}`);
passed++;
} catch (e) {
console.log(`${name}`);
console.log(` Error: ${e.message}`);
failed++;
}
}
function assert(condition, message) {
if (!condition) throw new Error(message || 'Assertion failed');
}
function assertEqual(actual, expected, message) {
if (actual !== expected) {
throw new Error(message || `Expected ${expected}, got ${actual}`);
}
}
console.log('=== Wallet Storage Tests ===\n');
// ============================================================================
// WalletOutput Tests
// ============================================================================
console.log('--- WalletOutput Model ---');
test('creates output with default values', () => {
const output = new WalletOutput();
assertEqual(output.keyImage, null);
assertEqual(output.amount, 0n);
assertEqual(output.assetType, 'SAL');
assertEqual(output.isSpent, false);
assertEqual(output.isFrozen, false);
assertEqual(output.txType, 3);
});
test('creates output with provided values', () => {
const output = new WalletOutput({
keyImage: 'abc123',
publicKey: 'def456',
txHash: 'tx789',
outputIndex: 2,
blockHeight: 1000,
amount: 5000000000n,
assetType: 'SAL'
});
assertEqual(output.keyImage, 'abc123');
assertEqual(output.publicKey, 'def456');
assertEqual(output.txHash, 'tx789');
assertEqual(output.outputIndex, 2);
assertEqual(output.blockHeight, 1000);
assertEqual(output.amount, 5000000000n);
});
test('amount accepts number and converts to BigInt', () => {
const output = new WalletOutput({ amount: 1000 });
assertEqual(output.amount, 1000n);
});
test('amount accepts string and converts to BigInt', () => {
const output = new WalletOutput({ amount: '999999999999' });
assertEqual(output.amount, 999999999999n);
});
test('isUnlocked returns true for confirmed output with enough confirmations', () => {
const output = new WalletOutput({
blockHeight: 100,
unlockTime: 0n
});
assert(output.isUnlocked(115, 10), 'Should be unlocked at 15 confirmations');
assert(output.isUnlocked(110, 10), 'Should be unlocked at exactly 10 confirmations');
});
test('isUnlocked returns false for insufficient confirmations', () => {
const output = new WalletOutput({
blockHeight: 100,
unlockTime: 0n
});
assert(!output.isUnlocked(105, 10), 'Should not be unlocked at 5 confirmations');
assert(!output.isUnlocked(109, 10), 'Should not be unlocked at 9 confirmations');
});
test('isUnlocked handles block-height unlock time', () => {
const output = new WalletOutput({
blockHeight: 100,
unlockTime: 200n // Locked until block 200
});
assert(!output.isUnlocked(150), 'Should be locked at height 150');
assert(!output.isUnlocked(199), 'Should be locked at height 199');
assert(output.isUnlocked(200), 'Should be unlocked at height 200');
assert(output.isUnlocked(250), 'Should be unlocked at height 250');
});
test('isUnlocked handles timestamp unlock time', () => {
const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
const pastTime = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago
const lockedOutput = new WalletOutput({
blockHeight: 100,
unlockTime: BigInt(futureTime)
});
assert(!lockedOutput.isUnlocked(1000), 'Should be locked with future timestamp');
const unlockedOutput = new WalletOutput({
blockHeight: 100,
unlockTime: BigInt(pastTime)
});
assert(unlockedOutput.isUnlocked(1000), 'Should be unlocked with past timestamp');
});
test('isSpendable requires key image', () => {
const outputNoKeyImage = new WalletOutput({
blockHeight: 100,
amount: 1000n,
keyImage: null
});
assert(!outputNoKeyImage.isSpendable(200), 'Should not be spendable without key image');
const outputWithKeyImage = new WalletOutput({
blockHeight: 100,
amount: 1000n,
keyImage: 'abc123'
});
assert(outputWithKeyImage.isSpendable(200), 'Should be spendable with key image');
});
test('isSpendable returns false for spent outputs', () => {
const output = new WalletOutput({
blockHeight: 100,
keyImage: 'abc123',
isSpent: true
});
assert(!output.isSpendable(200), 'Spent output should not be spendable');
});
test('isSpendable returns false for frozen outputs', () => {
const output = new WalletOutput({
blockHeight: 100,
keyImage: 'abc123',
isFrozen: true
});
assert(!output.isSpendable(200), 'Frozen output should not be spendable');
});
test('toJSON serializes correctly', () => {
const output = new WalletOutput({
keyImage: 'abc',
amount: 1234567890n,
unlockTime: 100n
});
const json = output.toJSON();
assertEqual(json.keyImage, 'abc');
assertEqual(json.amount, '1234567890');
assertEqual(json.unlockTime, '100');
assertEqual(typeof json.amount, 'string');
});
test('fromJSON deserializes correctly', () => {
const json = {
keyImage: 'xyz',
amount: '9876543210',
unlockTime: '500',
blockHeight: 1000
};
const output = WalletOutput.fromJSON(json);
assertEqual(output.keyImage, 'xyz');
assertEqual(output.amount, 9876543210n);
assertEqual(output.unlockTime, 500n);
assertEqual(output.blockHeight, 1000);
});
test('round-trip JSON serialization preserves BigInt values', () => {
const original = new WalletOutput({
amount: 123456789012345678901234n,
unlockTime: 999999999999n
});
const json = original.toJSON();
const restored = WalletOutput.fromJSON(json);
assertEqual(restored.amount, original.amount);
assertEqual(restored.unlockTime, original.unlockTime);
});
// ============================================================================
// WalletTransaction Tests
// ============================================================================
console.log('\n--- WalletTransaction Model ---');
test('creates transaction with default values', () => {
const tx = new WalletTransaction();
assertEqual(tx.txHash, null);
assertEqual(tx.incomingAmount, 0n);
assertEqual(tx.outgoingAmount, 0n);
assertEqual(tx.fee, 0n);
assertEqual(tx.isIncoming, false);
assertEqual(tx.isOutgoing, false);
assertEqual(tx.inPool, false);
});
test('creates transaction with provided values', () => {
const tx = new WalletTransaction({
txHash: 'hash123',
blockHeight: 5000,
isIncoming: true,
incomingAmount: 10000000000n,
fee: 50000000n
});
assertEqual(tx.txHash, 'hash123');
assertEqual(tx.blockHeight, 5000);
assertEqual(tx.isIncoming, true);
assertEqual(tx.incomingAmount, 10000000000n);
assertEqual(tx.fee, 50000000n);
});
test('isConfirmed is true when blockHeight is set', () => {
const confirmedTx = new WalletTransaction({ blockHeight: 1000 });
const unconfirmedTx = new WalletTransaction({ blockHeight: null });
assert(confirmedTx.isConfirmed, 'Should be confirmed with block height');
assert(!unconfirmedTx.isConfirmed, 'Should not be confirmed without block height');
});
test('getNetAmount calculates correctly', () => {
const tx = new WalletTransaction({
incomingAmount: 10000n,
outgoingAmount: 3000n,
fee: 100n
});
assertEqual(tx.getNetAmount(), 6900n); // 10000 - 3000 - 100
});
test('getNetAmount handles negative result', () => {
const tx = new WalletTransaction({
incomingAmount: 0n,
outgoingAmount: 5000n,
fee: 100n
});
assertEqual(tx.getNetAmount(), -5100n);
});
test('toJSON serializes correctly', () => {
const tx = new WalletTransaction({
txHash: 'abc',
incomingAmount: 1000000n,
fee: 10000n
});
const json = tx.toJSON();
assertEqual(json.txHash, 'abc');
assertEqual(json.incomingAmount, '1000000');
assertEqual(json.fee, '10000');
});
test('fromJSON deserializes correctly', () => {
const json = {
txHash: 'xyz',
incomingAmount: '5000000',
outgoingAmount: '1000000',
fee: '50000',
changeAmount: '100000',
unlockTime: '0'
};
const tx = WalletTransaction.fromJSON(json);
assertEqual(tx.txHash, 'xyz');
assertEqual(tx.incomingAmount, 5000000n);
assertEqual(tx.outgoingAmount, 1000000n);
assertEqual(tx.fee, 50000n);
assertEqual(tx.changeAmount, 100000n);
});
// ============================================================================
// MemoryStorage Tests
// ============================================================================
console.log('\n--- MemoryStorage ---');
await testAsync('open and close work', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.close();
});
await testAsync('putOutput and getOutput work', async () => {
const storage = new MemoryStorage();
await storage.open();
const output = new WalletOutput({
keyImage: 'ki_test_1',
amount: 1000000n,
blockHeight: 100
});
await storage.putOutput(output);
const retrieved = await storage.getOutput('ki_test_1');
assertEqual(retrieved.keyImage, 'ki_test_1');
assertEqual(retrieved.amount, 1000000n);
assertEqual(retrieved.blockHeight, 100);
await storage.close();
});
await testAsync('getOutput returns null for nonexistent key', async () => {
const storage = new MemoryStorage();
await storage.open();
const result = await storage.getOutput('nonexistent');
assertEqual(result, null);
await storage.close();
});
await testAsync('getOutputs returns all outputs', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.putOutput(new WalletOutput({ keyImage: 'ki1', amount: 100n }));
await storage.putOutput(new WalletOutput({ keyImage: 'ki2', amount: 200n }));
await storage.putOutput(new WalletOutput({ keyImage: 'ki3', amount: 300n }));
const outputs = await storage.getOutputs();
assertEqual(outputs.length, 3);
await storage.close();
});
await testAsync('getOutputs filters by isSpent', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.putOutput(new WalletOutput({ keyImage: 'ki1', isSpent: false }));
await storage.putOutput(new WalletOutput({ keyImage: 'ki2', isSpent: true }));
await storage.putOutput(new WalletOutput({ keyImage: 'ki3', isSpent: false }));
const unspent = await storage.getOutputs({ isSpent: false });
const spent = await storage.getOutputs({ isSpent: true });
assertEqual(unspent.length, 2);
assertEqual(spent.length, 1);
await storage.close();
});
await testAsync('getOutputs filters by assetType', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.putOutput(new WalletOutput({ keyImage: 'ki1', assetType: 'SAL' }));
await storage.putOutput(new WalletOutput({ keyImage: 'ki2', assetType: 'USD' }));
await storage.putOutput(new WalletOutput({ keyImage: 'ki3', assetType: 'SAL' }));
const salOutputs = await storage.getOutputs({ assetType: 'SAL' });
const usdOutputs = await storage.getOutputs({ assetType: 'USD' });
assertEqual(salOutputs.length, 2);
assertEqual(usdOutputs.length, 1);
await storage.close();
});
await testAsync('getOutputs filters by amount range', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.putOutput(new WalletOutput({ keyImage: 'ki1', amount: 100n }));
await storage.putOutput(new WalletOutput({ keyImage: 'ki2', amount: 500n }));
await storage.putOutput(new WalletOutput({ keyImage: 'ki3', amount: 1000n }));
const filtered = await storage.getOutputs({ minAmount: 200n, maxAmount: 800n });
assertEqual(filtered.length, 1);
assertEqual(filtered[0].amount, 500n);
await storage.close();
});
await testAsync('getOutputs filters by account index', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.putOutput(new WalletOutput({ keyImage: 'ki1', subaddressIndex: { major: 0, minor: 0 } }));
await storage.putOutput(new WalletOutput({ keyImage: 'ki2', subaddressIndex: { major: 1, minor: 0 } }));
await storage.putOutput(new WalletOutput({ keyImage: 'ki3', subaddressIndex: { major: 0, minor: 1 } }));
const account0 = await storage.getOutputs({ accountIndex: 0 });
const account1 = await storage.getOutputs({ accountIndex: 1 });
assertEqual(account0.length, 2);
assertEqual(account1.length, 1);
await storage.close();
});
await testAsync('markOutputSpent updates output', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.putOutput(new WalletOutput({ keyImage: 'ki1', amount: 100n }));
const before = await storage.getOutput('ki1');
assertEqual(before.isSpent, false);
await storage.markOutputSpent('ki1', 'spending_tx_hash', 500);
const after = await storage.getOutput('ki1');
assertEqual(after.isSpent, true);
assertEqual(after.spentTxHash, 'spending_tx_hash');
assertEqual(after.spentHeight, 500);
await storage.close();
});
await testAsync('putTransaction and getTransaction work', async () => {
const storage = new MemoryStorage();
await storage.open();
const tx = new WalletTransaction({
txHash: 'tx_test_1',
blockHeight: 1000,
incomingAmount: 5000000n
});
await storage.putTransaction(tx);
const retrieved = await storage.getTransaction('tx_test_1');
assertEqual(retrieved.txHash, 'tx_test_1');
assertEqual(retrieved.blockHeight, 1000);
assertEqual(retrieved.incomingAmount, 5000000n);
await storage.close();
});
await testAsync('getTransactions filters by direction', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.putTransaction(new WalletTransaction({ txHash: 'tx1', isIncoming: true, blockHeight: 100 }));
await storage.putTransaction(new WalletTransaction({ txHash: 'tx2', isOutgoing: true, blockHeight: 101 }));
await storage.putTransaction(new WalletTransaction({ txHash: 'tx3', isIncoming: true, blockHeight: 102 }));
const incoming = await storage.getTransactions({ isIncoming: true });
const outgoing = await storage.getTransactions({ isOutgoing: true });
assertEqual(incoming.length, 2);
assertEqual(outgoing.length, 1);
await storage.close();
});
await testAsync('getTransactions sorts by height descending', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.putTransaction(new WalletTransaction({ txHash: 'tx1', blockHeight: 100 }));
await storage.putTransaction(new WalletTransaction({ txHash: 'tx3', blockHeight: 300 }));
await storage.putTransaction(new WalletTransaction({ txHash: 'tx2', blockHeight: 200 }));
const txs = await storage.getTransactions();
assertEqual(txs[0].blockHeight, 300);
assertEqual(txs[1].blockHeight, 200);
assertEqual(txs[2].blockHeight, 100);
await storage.close();
});
await testAsync('sync height operations work', async () => {
const storage = new MemoryStorage();
await storage.open();
const initial = await storage.getSyncHeight();
assertEqual(initial, 0);
await storage.setSyncHeight(5000);
const updated = await storage.getSyncHeight();
assertEqual(updated, 5000);
await storage.close();
});
await testAsync('state operations work', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.setState('testKey', { foo: 'bar', num: 123 });
const value = await storage.getState('testKey');
assertEqual(value.foo, 'bar');
assertEqual(value.num, 123);
await storage.close();
});
await testAsync('clear removes all data', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.putOutput(new WalletOutput({ keyImage: 'ki1' }));
await storage.putTransaction(new WalletTransaction({ txHash: 'tx1' }));
await storage.setSyncHeight(1000);
await storage.clear();
const outputs = await storage.getOutputs();
const txs = await storage.getTransactions();
const height = await storage.getSyncHeight();
assertEqual(outputs.length, 0);
assertEqual(txs.length, 0);
assertEqual(height, 0);
await storage.close();
});
await testAsync('key image tracking works', async () => {
const storage = new MemoryStorage();
await storage.open();
await storage.putKeyImage('ki1', { txHash: 'tx1', outputIndex: 0 });
const isSpentBefore = await storage.isKeyImageSpent('ki1');
assertEqual(isSpentBefore, false);
// Mark output spent (which also marks key image spent)
await storage.putOutput(new WalletOutput({ keyImage: 'ki1' }));
await storage.markOutputSpent('ki1', 'spending_tx');
const isSpentAfter = await storage.isKeyImageSpent('ki1');
assertEqual(isSpentAfter, true);
const spentKeyImages = await storage.getSpentKeyImages();
assert(spentKeyImages.includes('ki1'));
await storage.close();
});
// ============================================================================
// createStorage Factory Tests
// ============================================================================
console.log('\n--- createStorage Factory ---');
test('createStorage with type=memory returns MemoryStorage', () => {
const storage = createStorage({ type: 'memory' });
assert(storage instanceof MemoryStorage);
});
test('createStorage with auto falls back to MemoryStorage (no IndexedDB)', () => {
// In Node/Bun environment, IndexedDB is not available
const storage = createStorage({ type: 'auto' });
assert(storage instanceof MemoryStorage);
});
test('createStorage with no options returns MemoryStorage', () => {
const storage = createStorage();
assert(storage instanceof MemoryStorage);
});
// ============================================================================
// Summary
// ============================================================================
console.log('\n--- Summary ---');
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
if (failed > 0) {
console.log('\n✗ Some tests failed!');
process.exit(1);
} else {
console.log('\n✓ All wallet storage tests passed!');
process.exit(0);
}