Files
memecoin-botV2/tests/realMode.test.ts

295 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Connection } from '@solana/web3.js';
import { RealWalletWatcher } from '../src/solana/watcher.js';
import { fetchTransactionWithRetry } from '../src/solana/fetcher.js';
import {
InMemoryRawWalletTransactionRepository,
InMemoryEventJournalRepository,
} from './mocks/InMemoryRepositories.js';
import { EventJournalService } from '../src/persistence/eventJournal.js';
import { WalletTransactionObservation } from '../src/domain/interfaces.js';
import { RawWalletTransaction } from '../src/domain/models.js';
import { randomUUID } from 'crypto';
// Mock @solana/web3.js references starting with "mock" to be accessible in hoisted vi.mock
const mockOnLogs = vi.fn().mockReturnValue(12345);
const mockRemoveOnLogsListener = vi.fn().mockResolvedValue(true);
const mockGetTransaction = vi.fn();
vi.mock('@solana/web3.js', () => {
class MockConnection {
onLogs = mockOnLogs;
removeOnLogsListener = mockRemoveOnLogsListener;
getTransaction = mockGetTransaction;
}
class MockPublicKey {
constructor(public address: string) {}
toBase58() {
return this.address;
}
}
return {
Connection: MockConnection,
PublicKey: MockPublicKey,
};
});
describe('Real Solana Observation Mode Unit Tests', () => {
let connection: any;
beforeEach(() => {
vi.clearAllMocks();
connection = new Connection('http://dummy', 'confirmed');
});
// 1. Transaction fetch retry with bounded exponential backoff
describe('Transaction Fetch Retry Mechanism', () => {
it('should successfully return transaction if it is found on the first attempt', async () => {
const mockTx = { slot: 12345, blockTime: 1710000000 };
connection.getTransaction.mockResolvedValueOnce(mockTx);
const result = await fetchTransactionWithRetry(connection as any, 'dummy-sig', 3, 10, 20);
expect(result).toEqual(mockTx);
expect(connection.getTransaction).toHaveBeenCalledTimes(1);
});
it('should retry fetching with backoff and succeed if transaction becomes available eventually', async () => {
const mockTx = { slot: 12345, blockTime: 1710000000 };
connection.getTransaction
.mockResolvedValueOnce(null) // first attempt: temporarily unavailable
.mockRejectedValueOnce(new Error('RPC limit reached')) // second attempt: error
.mockResolvedValueOnce(mockTx); // third attempt: success
const result = await fetchTransactionWithRetry(connection as any, 'dummy-sig', 4, 10, 50);
expect(result).toEqual(mockTx);
expect(connection.getTransaction).toHaveBeenCalledTimes(3);
});
it('should stop retrying and throw error if maxRetries limit is reached', async () => {
connection.getTransaction.mockResolvedValue(null); // always returns null
await expect(
fetchTransactionWithRetry(connection as any, 'dummy-sig', 3, 5, 10)
).rejects.toThrow('Transaction dummy-sig could not be fetched after 3 attempts.');
expect(connection.getTransaction).toHaveBeenCalledTimes(3);
});
});
// 2. RealWalletWatcher Subscription and Shutdown Behavior
describe('RealWalletWatcher Lifecycle & Reconnect Behavior', () => {
it('should subscribe to logs for all watched wallets on startup', async () => {
const wallets = ['3yF9asA9B7G3Y1G7as78gHJKa7A', '4yF9asA9B7G3Y1G7as78gHJKa7B'];
const watcher = new RealWalletWatcher(wallets, 'http://dummy-rpc', 'wss://dummy-wss');
await watcher.startWatching();
expect(connection.onLogs).toHaveBeenCalledTimes(2);
expect(connection.onLogs).toHaveBeenNthCalledWith(1, expect.any(Object), expect.any(Function), 'confirmed');
expect(connection.onLogs).toHaveBeenNthCalledWith(2, expect.any(Object), expect.any(Function), 'confirmed');
});
it('should unsubscribe from all logs listeners on shutdown', async () => {
const wallets = ['3yF9asA9B7G3Y1G7as78gHJKa7A'];
const watcher = new RealWalletWatcher(wallets, 'http://dummy-rpc', 'wss://dummy-wss');
await watcher.startWatching();
await watcher.stopWatching();
expect(connection.removeOnLogsListener).toHaveBeenCalledTimes(1);
expect(connection.removeOnLogsListener).toHaveBeenCalledWith(12345);
});
});
// 3. Real Mode Pipeline orchestration, Raw Persistence, Duplicates, and Stopping
describe('Real Mode Pipeline Logic', () => {
let rawTxRepo: InMemoryRawWalletTransactionRepository;
let journalRepo: InMemoryEventJournalRepository;
let journalService: EventJournalService;
beforeEach(() => {
rawTxRepo = new InMemoryRawWalletTransactionRepository();
journalRepo = new InMemoryEventJournalRepository();
journalService = new EventJournalService(journalRepo, '1.0.0', 'commit', 'hash');
});
// Simulated callback registration helper replicating Application real-mode callback
const runPipelineCallback = async (
observation: WalletTransactionObservation,
watcherInstance: any,
) => {
const correlationId = observation.correlationId || randomUUID();
const detectedAt = observation.timestamp || new Date();
// Check duplicate first
const existing = await rawTxRepo.getTransactionBySignature(observation.signature);
if (existing) {
await journalService.log(correlationId, 'duplicate_transaction_ignored', {
signature: observation.signature,
walletAddress: observation.walletAddress,
});
return { stopped: true, reason: 'duplicate' };
}
await journalService.log(correlationId, 'wallet_activity_detected', {
walletAddress: observation.walletAddress,
signature: observation.signature,
slot: observation.slot,
detectedAt,
});
// Fetch transaction
await journalService.log(correlationId, 'raw_transaction_fetch_started', {
signature: observation.signature,
});
let txPayload: any;
try {
txPayload = await fetchTransactionWithRetry(watcherInstance.connection, observation.signature, 3, 5, 10);
} catch (error) {
await journalService.log(correlationId, 'raw_transaction_fetch_failed', {
signature: observation.signature,
error: error instanceof Error ? error.message : String(error),
});
return { stopped: true, reason: 'fetch_failed' };
}
await journalService.log(correlationId, 'raw_transaction_fetched', {
signature: observation.signature,
slot: txPayload.slot,
blockTime: txPayload.blockTime,
});
// Persist raw transaction
try {
const blockTime = txPayload.blockTime ? new Date(txPayload.blockTime * 1000) : null;
const rawTx: RawWalletTransaction = {
transactionSignature: observation.signature,
sourceWallet: observation.walletAddress,
slot: observation.slot,
blockTime,
detectedAt,
rawPayload: txPayload,
parserVersion: 'ingestion_v1',
};
await rawTxRepo.saveTransaction(rawTx);
await journalService.log(correlationId, 'raw_transaction_persisted', {
signature: observation.signature,
slot: observation.slot,
});
} catch {
// failed to persist
}
// STOP: The pipeline must return here and NOT trigger strategy or simulation
return { stopped: true, reason: 'pipeline_stopped_normally' };
};
it('should fetch, persist raw transaction, log events to journal, and STOP', async () => {
const observation: WalletTransactionObservation = {
signature: 'tx-sig-123',
slot: 100,
walletAddress: '3yF9asA9B7G3Y1G7as78gHJKa7A',
timestamp: new Date(),
correlationId: 'corr-real-mode',
};
const mockTxPayload = {
slot: 100,
blockTime: Math.floor(Date.now() / 1000),
meta: { logMessages: ['program instruction...'] },
};
connection.getTransaction.mockResolvedValueOnce(mockTxPayload);
const watcher = new RealWalletWatcher(['3yF9asA9B7G3Y1G7as78gHJKa7A'], 'http://dummy', 'wss://dummy');
(watcher as any).connection = connection; // inject connection mock
const res = await runPipelineCallback(observation, watcher);
// Verify the pipeline stopped normally
expect(res.stopped).toBe(true);
expect(res.reason).toBe('pipeline_stopped_normally');
// Verify the raw transaction was saved
const savedTx = await rawTxRepo.getTransactionBySignature('tx-sig-123');
expect(savedTx).not.toBeNull();
expect(savedTx?.transactionSignature).toBe('tx-sig-123');
expect(savedTx?.sourceWallet).toBe('3yF9asA9B7G3Y1G7as78gHJKa7A');
expect(savedTx?.rawPayload).toEqual(mockTxPayload);
// Verify Event Journal logs
const events = await journalRepo.getEventsByCorrelationId('corr-real-mode');
const eventTypes = events.map((e) => e.eventType);
expect(eventTypes).toContain('wallet_activity_detected');
expect(eventTypes).toContain('raw_transaction_fetch_started');
expect(eventTypes).toContain('raw_transaction_fetched');
expect(eventTypes).toContain('raw_transaction_persisted');
});
it('should ignore and log duplicate websocket notifications without calling RPC fetch again', async () => {
const observation: WalletTransactionObservation = {
signature: 'tx-sig-dup',
slot: 100,
walletAddress: '3yF9asA9B7G3Y1G7as78gHJKa7A',
timestamp: new Date(),
correlationId: 'corr-real-mode-dup',
};
// Pre-populate raw persistence to simulate duplicate
await rawTxRepo.saveTransaction({
transactionSignature: 'tx-sig-dup',
sourceWallet: '3yF9asA9B7G3Y1G7as78gHJKa7A',
slot: 100,
blockTime: null,
detectedAt: new Date(),
rawPayload: {},
parserVersion: 'ingestion_v1',
});
const watcher = new RealWalletWatcher(['3yF9asA9B7G3Y1G7as78gHJKa7A'], 'http://dummy', 'wss://dummy');
(watcher as any).connection = connection;
const res = await runPipelineCallback(observation, watcher);
// Verify pipeline stopped
expect(res.stopped).toBe(true);
expect(res.reason).toBe('duplicate');
// Verify connection.getTransaction was NEVER called (saving RPC rate limit!)
expect(connection.getTransaction).not.toHaveBeenCalled();
// Verify duplicate event is written to journal
const events = await journalRepo.getEventsByCorrelationId('corr-real-mode-dup');
expect(events.map((e) => e.eventType)).toContain('duplicate_transaction_ignored');
});
it('should log raw_transaction_fetch_failed if RPC continues to fail and return gracefully', async () => {
const observation: WalletTransactionObservation = {
signature: 'tx-sig-fail',
slot: 100,
walletAddress: '3yF9asA9B7G3Y1G7as78gHJKa7A',
timestamp: new Date(),
correlationId: 'corr-real-mode-fail',
};
connection.getTransaction.mockResolvedValue(null); // simulates always failing to fetch
const watcher = new RealWalletWatcher(['3yF9asA9B7G3Y1G7as78gHJKa7A'], 'http://dummy', 'wss://dummy');
(watcher as any).connection = connection;
const res = await runPipelineCallback(observation, watcher);
// Verify pipeline stopped on failure
expect(res.stopped).toBe(true);
expect(res.reason).toBe('fetch_failed');
// Verify fetch failed event is in journal
const events = await journalRepo.getEventsByCorrelationId('corr-real-mode-fail');
expect(events.map((e) => e.eventType)).toContain('raw_transaction_fetch_failed');
});
});
});