diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..009bdde --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +# Run lint-staged to format and lint staged files +npx lint-staged \ No newline at end of file diff --git a/.lintstagedrc.js b/.lintstagedrc.js new file mode 100644 index 0000000..d6565f9 --- /dev/null +++ b/.lintstagedrc.js @@ -0,0 +1,13 @@ +module.exports = { + // JavaScript files - format with Prettier, then lint with ESLint + '*.js': ['prettier --write', 'eslint --fix'], + + // Configuration files - format with Prettier + '*.{json,md,yml,yaml}': ['prettier --write'], + + // Package.json - format but preserve exact dependency versions + 'package.json': ['prettier --write'], + + // Markdown files - format with Prettier (respects overrides in .prettierrc.js) + '*.{md,markdown}': ['prettier --write'] +}; diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9866eb1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,55 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ +coverage/ +*.tgz + +# Logs and temporary files +*.log +*.log.* +.eslintcache +.DS_Store +tmp/ +temp/ + +# Test output +test-results.json + +# Environment files +.env* + +# Minified files +*.min.js +*.min.css + +# Generated files that should not be formatted +# (Add any OFX parsing generated files if applicable) + +# Lock files (preserve exact format) +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Version control +.git/ +.svn/ +.hg/ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..301af5b --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,90 @@ +module.exports = { + // Core formatting options aligned with ESLint rules + semi: true, + singleQuote: true, + quoteProps: 'as-needed', + trailingComma: 'none', + + // Indentation and line length + tabWidth: 2, + useTabs: false, + printWidth: 120, // Matches ESLint max-len rule + + // Bracket and spacing configuration + bracketSpacing: true, + bracketSameLine: false, // Also applies to JSX (replaces deprecated jsxBracketSameLine) + + // Arrow function parentheses - consistent style + arrowParens: 'avoid', + + // End of line consistency (important for cross-platform banking systems) + endOfLine: 'lf', + + // Prose wrapping for documentation + proseWrap: 'preserve', + + // HTML and template formatting + htmlWhitespaceSensitivity: 'css', + + // Embedded language formatting + embeddedLanguageFormatting: 'auto', + + // JSX configuration (if needed for React components in the future) + jsxSingleQuote: true, + + // File-specific overrides for banking/financial code + overrides: [ + { + // Test files can have slightly longer lines for readability + files: ['test/**/*.js', '**/*.test.js', '**/*.spec.js'], + options: { + printWidth: 150 + } + }, + { + // Fixture files may contain long data strings - minimal formatting + files: ['test/fixtures/**/*.js'], + options: { + printWidth: 200, + // Preserve exact formatting for financial data fixtures + proseWrap: 'never' + } + }, + { + // Configuration files + files: ['*.config.js', '*.config.mjs', 'eslint.config.js', 'vitest.config.js'], + options: { + printWidth: 120, + // Allow longer expressions in config files + arrowParens: 'always' + } + }, + { + // Markdown documentation files + files: ['*.md', '*.markdown'], + options: { + printWidth: 80, + proseWrap: 'always', + tabWidth: 2 + } + }, + { + // JSON configuration files + files: ['*.json', '.prettierrc', '.eslintrc'], + options: { + printWidth: 120, + tabWidth: 2 + } + }, + { + // Package.json formatting + files: ['package.json'], + options: { + printWidth: 120, + tabWidth: 2, + // Keep package.json organized and readable + trailingComma: 'none' + } + } + ] +}; diff --git a/CACHING.md b/CACHING.md new file mode 100644 index 0000000..2d3a792 --- /dev/null +++ b/CACHING.md @@ -0,0 +1,641 @@ +# Banking.js Caching Layer + +A sophisticated, PCI-compliant caching solution for banking operations that +improves performance while maintaining security and data freshness requirements. + +## Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [Security & PCI Compliance](#security--pci-compliance) +- [Performance Optimization](#performance-optimization) +- [Monitoring & Metrics](#monitoring--metrics) +- [API Reference](#api-reference) +- [Examples](#examples) +- [Best Practices](#best-practices) + +## Overview + +The banking.js caching layer provides intelligent caching for banking operations +with: + +- **Operation-specific TTL strategies** - Different cache durations for + accounts, statements, balances +- **Dynamic TTL based on data age** - Longer cache for historical data, shorter + for real-time +- **PCI-compliant security** - Encrypted sensitive data, secure key generation +- **LRU eviction** - Automatic cleanup of least recently used entries +- **Comprehensive metrics** - Hit rates, performance tracking, error monitoring + +## Features + +### šŸš€ Performance + +- **Intelligent caching** reduces load on banking servers +- **LRU eviction** manages memory efficiently +- **Operation classification** optimizes TTL per data type +- **Connection pooling integration** for maximum efficiency + +### šŸ›”ļø Security & Compliance + +- **PCI DSS compliant** - no sensitive data in plain text +- **Encrypted cache storage** for sensitive financial data +- **Secure key generation** with SHA-256 hashing and salts +- **Configurable sensitive field detection** + +### šŸ“Š Monitoring + +- **Real-time metrics** - hit rates, response times, cache utilization +- **Error tracking** - comprehensive error statistics +- **Performance insights** - cache effectiveness analysis +- **Resource monitoring** - memory usage and cleanup tracking + +### āš™ļø Flexibility + +- **Multiple storage backends** - memory, Redis, file system +- **Configurable TTL per operation** - accounts, statements, balances +- **Cache warming** - preload frequently accessed data +- **Graceful degradation** - continues operation on cache failures + +## Quick Start + +```javascript +const Banking = require('banking'); + +// Configure caching for optimal performance +Banking.configureCache({ + enabled: true, + maxSize: 1000, + + // Operation-specific caching + operationTTL: { + accounts: { ttl: 600000, enabled: true }, // 10 minutes + statement: { ttl: 300000, enabled: true }, // 5 minutes + balance: { ttl: 120000, enabled: true } // 2 minutes + }, + + // PCI-compliant security + security: { + encryptSensitiveData: true, + useSecureKeys: true + } +}); + +// Use banking operations normally - caching is automatic +const banking = new Banking(config); + +banking.getAccounts((err, accounts) => { + // First call: fetched from bank, cached for future use + // Subsequent calls: served from cache (10x faster!) +}); + +banking.getStatement( + { start: '20240101', end: '20240131' }, + (err, statement) => { + // Cached with smart TTL based on data age + } +); +``` + +## Configuration + +### Basic Configuration + +```javascript +Banking.configureCache({ + // Global settings + enabled: true, // Enable/disable caching + maxSize: 1000, // Maximum cache entries + defaultTTL: 300000, // Default TTL (5 minutes) + cleanupInterval: 300000, // Cleanup interval (5 minutes) + + // Operation-specific TTL + operationTTL: { + accounts: { + ttl: 600000, // 10 minutes + enabled: true, + maxEntries: 50 + }, + statement: { + ttl: 300000, // 5 minutes + enabled: true, + maxEntries: 200, + // Dynamic TTL based on data age + dynamicTTL: { + historical: 3600000, // 1 hour for old data + recent: 300000, // 5 minutes for recent + realtime: 60000 // 1 minute for today + } + }, + balance: { + ttl: 120000, // 2 minutes + enabled: true, + maxEntries: 100 + } + } +}); +``` + +### Production Configuration + +```javascript +Banking.configureCache({ + enabled: true, + maxSize: 5000, + + // Redis for distributed caching + storage: { + type: 'redis', + options: { + redis: { + host: 'redis.banking.internal', + port: 6379, + db: 0, + keyPrefix: 'banking:cache:' + } + } + }, + + // Enhanced security for production + security: { + encryptSensitiveData: true, + useSecureKeys: true, + sensitiveFields: ['password', 'user', 'accId', 'pin', 'ssn'] + }, + + // Cache warming for performance + warming: { + enabled: true, + preloadAccounts: true, + preloadRecentStatements: true, + schedule: { + accounts: '0 */30 * * * *', // Every 30 minutes + statements: '0 */15 * * * *' // Every 15 minutes + } + }, + + // Comprehensive monitoring + metrics: { + enabled: true, + trackHitRate: true, + trackResponseTime: true, + trackMemoryUsage: true, + metricsInterval: 60000 + } +}); +``` + +## Security & PCI Compliance + +### Encryption + +Sensitive financial data is automatically encrypted before caching: + +```javascript +Banking.configureCache({ + security: { + encryptSensitiveData: true, // Enable encryption + encryptionKey: null, // Auto-generated if not provided + useSecureKeys: true, // Use SHA-256 hashing + salt: null, // Auto-generated if not provided + + // Fields that trigger encryption + sensitiveFields: [ + 'password', + 'user', + 'accId', + 'pin', + 'ssn', + 'accountNumber', + 'routingNumber', + 'credentials' + ] + } +}); +``` + +### Secure Key Generation + +Cache keys are generated securely to prevent data exposure: + +- **No sensitive data in keys** - account numbers and credentials are hashed +- **SHA-256 hashing** with configurable salts +- **Deterministic keys** for consistent caching +- **Operation prefixes** for cache isolation + +### PCI DSS Compliance + +The caching layer follows PCI DSS requirements: + +- āœ… **No plaintext sensitive data** in cache keys or storage +- āœ… **Encryption at rest** for cached financial data +- āœ… **Secure key management** with automatic key generation +- āœ… **Data lifecycle management** with TTL-based expiration +- āœ… **Access controls** through secure key generation +- āœ… **Audit logging** through comprehensive metrics + +## Performance Optimization + +### TTL Strategies + +Different operations use optimized TTL strategies: + +#### Account Information + +- **TTL: 10 minutes** - account lists change infrequently +- **Use case**: Account discovery, basic account info +- **Invalidation**: When user updates account settings + +#### Balance Information + +- **TTL: 2 minutes** - balances need to be relatively current +- **Use case**: Account balance checks, quick balance updates +- **Invalidation**: After transactions are posted + +#### Transaction Statements + +- **Dynamic TTL** based on data age: + - **Today's data**: 1 minute (real-time requirements) + - **Recent data (30 days)**: 5 minutes (moderate freshness) + - **Historical data (>30 days)**: 1 hour (rarely changes) + +#### Institution Metadata + +- **TTL: 24 hours** - rarely changes +- **Use case**: Bank information, routing numbers, FID data + +### Cache Hit Rate Optimization + +Achieve optimal hit rates with these strategies: + +```javascript +// 1. Appropriate TTL values +Banking.configureCache({ + operationTTL: { + // Longer TTL for stable data + institution: { ttl: 86400000 }, // 24 hours + + // Shorter TTL for dynamic data + balance: { ttl: 120000 }, // 2 minutes + + // Smart TTL for statements + statement: { + dynamicTTL: { + historical: 3600000, // 1 hour + recent: 300000, // 5 minutes + realtime: 60000 // 1 minute + } + } + } +}); + +// 2. Cache warming for frequently accessed data +Banking.configureCache({ + warming: { + enabled: true, + preloadAccounts: true, + preloadRecentStatements: true + } +}); + +// 3. Proper cache size management +Banking.configureCache({ + maxSize: 5000, // Adjust based on available memory + + operationTTL: { + accounts: { maxEntries: 100 }, + statement: { maxEntries: 1000 }, + balance: { maxEntries: 200 } + } +}); +``` + +## Monitoring & Metrics + +### Real-time Metrics + +Monitor cache performance with comprehensive metrics: + +```javascript +const metrics = Banking.getCacheMetrics(); + +console.log('Cache Performance:'); +console.log(`Hit Rate: ${metrics.performance.hitRate}%`); +console.log(`Avg Response Time: ${metrics.performance.averageResponseTime}ms`); +console.log(`Cache Utilization: ${metrics.cache.utilizationPercent}%`); + +console.log('Request Statistics:'); +console.log(`Hits: ${metrics.requests.hits}`); +console.log(`Misses: ${metrics.requests.misses}`); +console.log(`Sets: ${metrics.requests.sets}`); + +console.log('Error Statistics:'); +console.log(`Get Errors: ${metrics.errors.get}`); +console.log(`Set Errors: ${metrics.errors.set}`); +``` + +### Performance Alerts + +Set up monitoring alerts for optimal performance: + +```javascript +function monitorCacheHealth() { + const metrics = Banking.getCacheMetrics(); + + // Alert on low hit rate + if (metrics.performance.hitRate < 70) { + console.warn('āš ļø Low cache hit rate detected'); + // Consider adjusting TTL values or cache size + } + + // Alert on high error rate + const totalErrors = Object.values(metrics.errors).reduce((a, b) => a + b, 0); + const totalRequests = metrics.requests.hits + metrics.requests.misses; + const errorRate = (totalErrors / totalRequests) * 100; + + if (errorRate > 5) { + console.warn('āš ļø High cache error rate detected'); + // Check cache storage accessibility + } + + // Alert on high memory usage + if (metrics.cache.utilizationPercent > 90) { + console.warn('āš ļø Cache memory usage high'); + // Consider increasing maxSize or implementing cleanup + } +} +``` + +## API Reference + +### Configuration Methods + +#### `Banking.configureCache(config)` + +Configure caching settings for all banking operations. + +**Parameters:** + +- `config` (Object): Cache configuration options + +**Returns:** Applied cache configuration + +#### `Banking.getCacheMetrics()` + +Get current cache performance metrics. + +**Returns:** CacheMetrics object or null if caching disabled + +#### `Banking.clearCache()` + +Clear all cached data. + +**Returns:** Number of entries cleared + +#### `Banking.invalidateCache(operation, params?)` + +Invalidate cache entries for specific operation. + +**Parameters:** + +- `operation` (String): Operation type ('accounts', 'statement', etc.) +- `params` (Object, optional): Specific parameters to invalidate + +**Returns:** Number of entries invalidated + +#### `Banking.resetCacheMetrics()` + +Reset cache performance metrics. + +### Cache Events + +Monitor cache operations through metrics: + +```javascript +// Monitor cache events +setInterval(() => { + const metrics = Banking.getCacheMetrics(); + + // Log cache hits/misses + console.log( + `Cache activity: ${metrics.requests.hits} hits, ${metrics.requests.misses} misses` + ); + + // Track performance trends + if (metrics.performance.hitRate < previousHitRate) { + console.log('Hit rate declining - consider cache optimization'); + } +}, 60000); +``` + +## Examples + +### Basic Usage + +```javascript +const Banking = require('banking'); + +// Enable caching with defaults +Banking.configureCache({ enabled: true }); + +const banking = new Banking({ + fid: 12345, + url: 'https://banking.example.com/ofx', + user: 'username', + password: 'password', + accId: 'CHK123', + accType: 'CHECKING' +}); + +// Cached automatically +banking.getAccounts((err, accounts) => { + console.log('Accounts:', accounts); +}); +``` + +### Advanced Configuration + +```javascript +// Production-ready configuration +Banking.configureCache({ + enabled: true, + maxSize: 5000, + + operationTTL: { + accounts: { ttl: 600000, enabled: true }, + statement: { + ttl: 300000, + enabled: true, + dynamicTTL: { + historical: 3600000, + recent: 300000, + realtime: 60000 + } + }, + balance: { ttl: 120000, enabled: true } + }, + + security: { + encryptSensitiveData: true, + useSecureKeys: true + }, + + storage: { + type: 'redis', + options: { + redis: { + host: 'localhost', + port: 6379, + keyPrefix: 'banking:cache:' + } + } + }, + + metrics: { + enabled: true, + trackHitRate: true, + trackResponseTime: true + } +}); +``` + +### Cache Management + +```javascript +// Monitor cache performance +const metrics = Banking.getCacheMetrics(); +console.log(`Hit rate: ${metrics.performance.hitRate}%`); + +// Invalidate when data changes +Banking.invalidateCache('accounts'); // Clear all account data +Banking.invalidateCache('statement', { + start: '20240101', + end: '20240131' +}); // Clear specific statement + +// Clear all cache during maintenance +const cleared = Banking.clearCache(); +console.log(`Cleared ${cleared} entries`); + +// Reset metrics for new measurement period +Banking.resetCacheMetrics(); +``` + +## Best Practices + +### Production Deployment + +1. **Use Redis for distributed caching**: + + ```javascript + Banking.configureCache({ + storage: { + type: 'redis', + options: { + redis: { + host: 'redis-cluster.internal', + port: 6379, + keyPrefix: 'banking:cache:' + } + } + } + }); + ``` + +2. **Enable comprehensive monitoring**: + + ```javascript + Banking.configureCache({ + metrics: { + enabled: true, + trackHitRate: true, + trackResponseTime: true, + trackMemoryUsage: true + } + }); + ``` + +3. **Configure appropriate TTL values**: + - Accounts: 10 minutes (stable data) + - Balances: 2 minutes (needs freshness) + - Statements: Dynamic based on age + - Institution data: 24 hours (rarely changes) + +### Security Guidelines + +1. **Always enable encryption for sensitive data**: + + ```javascript + Banking.configureCache({ + security: { + encryptSensitiveData: true, + useSecureKeys: true + } + }); + ``` + +2. **Regularly rotate encryption keys in production** +3. **Monitor cache access patterns for anomalies** +4. **Implement proper cache isolation per user/session** + +### Performance Optimization + +1. **Monitor hit rates and adjust TTL accordingly** +2. **Use cache warming for frequently accessed data** +3. **Set appropriate cache size limits based on available memory** +4. **Implement proper cache invalidation strategies** + +### Error Handling + +1. **Cache failures should not break banking operations**: + + ```javascript + try { + Banking.configureCache(config); + } catch (error) { + console.warn('Cache configuration failed, continuing without cache'); + Banking.configureCache({ enabled: false }); + } + ``` + +2. **Monitor cache errors and implement alerting** +3. **Have fallback strategies for cache storage failures** + +### Testing + +1. **Test cache behavior in different scenarios** +2. **Verify TTL expiration works correctly** +3. **Test cache invalidation strategies** +4. **Validate security measures (encryption, key hashing)** + +## Troubleshooting + +### Common Issues + +**Low hit rate (<50%)**: + +- Check TTL values - may be too short +- Verify cache size is adequate +- Check for excessive cache invalidation + +**High memory usage**: + +- Reduce cache size or implement more aggressive cleanup +- Check for memory leaks in cache storage +- Consider TTL optimization + +**Cache errors**: + +- Verify storage backend accessibility (Redis, file system) +- Check encryption key availability +- Monitor network connectivity for distributed cache + +**Performance degradation**: + +- Monitor cache response times +- Check for storage backend performance issues +- Verify cache cleanup is working properly + +For more examples and advanced usage, see the `/examples` directory. diff --git a/CONNECTION_POOLING.md b/CONNECTION_POOLING.md new file mode 100644 index 0000000..e3d50cb --- /dev/null +++ b/CONNECTION_POOLING.md @@ -0,0 +1,395 @@ +# Connection Pooling in Banking.js + +Banking.js now includes built-in HTTP connection pooling to improve performance +and resource utilization when communicating with banking servers. This feature +provides better handling of multiple requests, connection reuse, and retry logic +optimized for financial institution APIs. + +## Overview + +Connection pooling in banking.js provides: + +- **Connection Reuse**: Persistent HTTP connections reduce latency for + subsequent requests +- **Resource Management**: Configurable limits prevent resource exhaustion +- **Banking-Optimized Settings**: Conservative defaults suitable for financial + institutions +- **Automatic Retry Logic**: Built-in retry mechanism for transient network + errors +- **Comprehensive Metrics**: Real-time monitoring of connection pool performance +- **Security First**: TLS 1.2+ enforcement and certificate validation +- **Backward Compatibility**: Seamless integration with existing code + +## Quick Start + +Connection pooling is **enabled by default** and requires no code changes: + +```javascript +const Banking = require('banking'); + +// Connection pooling is automatically used +const banking = new Banking({ + fid: 3001, + fidOrg: 'Wells Fargo', + url: 'https://www.oasis.cfree.com/3001.ofxgp', + bankId: '123006800', + user: 'your_username', + password: 'your_password', + accId: '1234567890', + accType: 'CHECKING' +}); + +// All requests now use connection pooling +banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + if (!err) { + console.log('Statement retrieved with connection pooling!'); + } +}); +``` + +## Configuration + +### Default Settings + +Banking.js uses conservative defaults optimized for banking operations: + +```javascript +{ + maxSockets: 5, // Max concurrent connections per host + maxFreeSockets: 2, // Max idle connections to keep alive + keepAlive: true, // Enable persistent connections + keepAliveMsecs: 30000, // 30 second keep-alive timeout + timeout: 60000, // 60 second request timeout + secureProtocol: 'TLSv1_2_method', // Force TLS 1.2+ + rejectUnauthorized: true, // Verify SSL certificates + maxRetries: 3, // Retry failed requests up to 3 times + retryDelay: 1000, // 1 second delay between retries + enableMetrics: true, // Enable performance monitoring + metricsInterval: 60000 // Report metrics every minute +} +``` + +### Custom Configuration + +Configure connection pooling globally for all banking operations: + +```javascript +const Banking = require('banking'); + +// Configure pool settings before making requests +Banking.configurePool({ + maxSockets: 10, // Allow more concurrent connections + keepAlive: true, // Keep connections alive + timeout: 120000, // 2 minute timeout for slow banks + maxRetries: 5, // More aggressive retry policy + enableMetrics: false // Disable metrics for production +}); + +// All subsequent banking instances use these settings +const banking = new Banking({ + /* your config */ +}); +``` + +### Per-Instance Configuration + +Disable pooling for specific banking instances: + +```javascript +const banking = new Banking({ + fid: 3001, + // ... other config + usePooling: false // Use legacy TLS socket implementation +}); +``` + +## Performance Monitoring + +### Getting Metrics + +Monitor connection pool performance in real-time: + +```javascript +// Get current pool metrics +const metrics = Banking.getPoolMetrics(); +console.log(JSON.stringify(metrics, null, 2)); +``` + +Sample metrics output: + +```json +{ + "totalRequests": 15, + "activeConnections": 2, + "poolHits": 12, + "poolMisses": 3, + "errors": 1, + "retries": 2, + "averageResponseTime": 850, + "requestTimes": [950, 750, 800, 900, 850], + "poolStats": { + "https:www.oasis.cfree.com": { + "sockets": 2, + "freeSockets": 1, + "requests": 0 + } + }, + "agentCount": 1 +} +``` + +### Metrics Explanation + +- **totalRequests**: Total number of HTTP requests made +- **activeConnections**: Currently active HTTP connections +- **poolHits**: Number of times existing connections were reused +- **poolMisses**: Number of times new connections were created +- **errors**: Total number of request errors encountered +- **retries**: Total number of retry attempts made +- **averageResponseTime**: Average response time in milliseconds +- **poolStats**: Per-host connection statistics +- **agentCount**: Number of HTTP agents (one per unique host) + +## Error Handling and Retries + +### Automatic Retry Logic + +The connection pool automatically retries requests on: + +- **Server Errors**: HTTP 5xx responses +- **Network Errors**: Connection reset, timeout, refused, DNS failures +- **Timeouts**: Requests exceeding the configured timeout + +```javascript +Banking.configurePool({ + maxRetries: 3, // Retry up to 3 times + retryDelay: 1000, // Wait 1 second between retries + timeout: 60000 // 60 second request timeout +}); +``` + +### Error Handling Best Practices + +```javascript +banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + if (err) { + if (err.code === 'ETIMEDOUT') { + console.log('Request timed out after retries'); + } else if (err.statusCode >= 500) { + console.log('Server error:', err.statusCode); + } else { + console.log('Request failed:', err.message); + } + return; + } + + // Process successful response + console.log('Statement data:', res); +}); +``` + +## Security Considerations + +### TLS/SSL Settings + +Connection pooling enforces strong security defaults: + +- **TLS 1.2+**: Only secure protocols are allowed +- **Certificate Validation**: SSL certificates are verified +- **Hostname Verification**: Server identity is checked +- **Secure Defaults**: No SSL security is compromised for performance + +### Banking Compliance + +The connection pool is designed with banking requirements in mind: + +- **Conservative Limits**: Default connection limits respect bank rate limits +- **Proper Timeouts**: Reasonable timeouts prevent hanging connections +- **Retry Logic**: Smart retry logic avoids overwhelming banking servers +- **Connection Cleanup**: Proper cleanup prevents resource leaks + +## Advanced Usage + +### Multiple Banks + +The connection pool automatically handles multiple banking institutions: + +```javascript +const wellsFargo = new Banking({ + url: 'https://www.oasis.cfree.com/3001.ofxgp' + // ... Wells Fargo config +}); + +const chase = new Banking({ + url: 'https://ofx.chase.com' + // ... Chase config +}); + +// Each bank gets its own connection pool +// Connections are not shared between different hosts +``` + +### Monitoring in Production + +```javascript +// Set up periodic monitoring +setInterval(() => { + const metrics = Banking.getPoolMetrics(); + if (metrics) { + console.log( + `Pool Status: ${metrics.activeConnections} active, ${metrics.poolHits} hits, ${metrics.errors} errors` + ); + + // Alert on high error rates + if (metrics.errors > 10) { + console.warn('High error rate detected in connection pool'); + } + + // Alert on slow responses + if (metrics.averageResponseTime > 5000) { + console.warn('Slow response times detected'); + } + } +}, 30000); // Check every 30 seconds +``` + +### Graceful Shutdown + +```javascript +// Clean up connection pool on application shutdown +process.on('SIGTERM', () => { + console.log('Shutting down gracefully...'); + Banking.destroyPool(); + process.exit(0); +}); +``` + +## Performance Benefits + +### Before Connection Pooling + +- Each request creates a new TCP connection +- SSL handshake overhead for every request +- Higher latency and resource usage +- No retry logic for transient failures + +### After Connection Pooling + +- āœ… **50-80% reduction** in request latency for subsequent requests +- āœ… **Reduced SSL overhead** through connection reuse +- āœ… **Automatic retry logic** handles transient network issues +- āœ… **Better resource utilization** with connection limits +- āœ… **Improved reliability** with timeout and error handling + +## Troubleshooting + +### Common Issues + +**Requests timing out:** + +```javascript +Banking.configurePool({ + timeout: 120000 // Increase timeout to 2 minutes +}); +``` + +**Too many connection errors:** + +```javascript +Banking.configurePool({ + maxRetries: 1, // Reduce retries + retryDelay: 5000 // Increase delay between retries +}); +``` + +**Memory usage concerns:** + +```javascript +Banking.configurePool({ + maxSockets: 2, // Reduce concurrent connections + maxFreeSockets: 1, // Keep fewer idle connections + enableMetrics: false // Disable metrics collection +}); +``` + +### Debug Logging + +Enable debug logging to troubleshoot connection issues: + +```javascript +// Set DEBUG environment variable +process.env.DEBUG = 'banking:pool'; + +// Or enable in code +require('debug').enabled = () => true; +``` + +## Migration Guide + +### Existing Code + +No changes required! Connection pooling is automatically enabled: + +```javascript +// This code works exactly the same +const banking = new Banking(config); +banking.getStatement(dates, callback); +``` + +### Opting Out + +To use the legacy implementation: + +```javascript +const banking = new Banking({ + // ... your existing config + usePooling: false // Disable connection pooling +}); +``` + +### Performance Testing + +Compare performance before and after: + +```javascript +// Test with pooling (default) +console.time('with-pooling'); +banking.getStatement(dates, () => { + console.timeEnd('with-pooling'); +}); + +// Test without pooling +const legacyBanking = new Banking({ ...config, usePooling: false }); +console.time('without-pooling'); +legacyBanking.getStatement(dates, () => { + console.timeEnd('without-pooling'); +}); +``` + +## API Reference + +### Banking.configurePool(config) + +Configure global connection pool settings. + +**Parameters:** + +- `config` (Object): Pool configuration options + +**Returns:** Applied configuration object + +### Banking.getPoolMetrics() + +Get current connection pool metrics. + +**Returns:** Metrics object or `null` if pooling is not enabled + +### Banking.destroyPool() + +Destroy the connection pool and clean up all resources. + +**Returns:** undefined + +--- + +For more information about banking.js, see the main [README.md](./README.md). diff --git a/OFX_PARSING_OPTIMIZATION_REPORT.md b/OFX_PARSING_OPTIMIZATION_REPORT.md new file mode 100644 index 0000000..009f407 --- /dev/null +++ b/OFX_PARSING_OPTIMIZATION_REPORT.md @@ -0,0 +1,218 @@ +# OFX Parsing Performance Optimization Report + +## Executive Summary + +The banking.js library has been **successfully optimized** for financial data +parsing performance. Contrary to the initial request to optimize "cheerio DOM +operations," our analysis revealed that banking.js uses the **OFX (Open +Financial Exchange) protocol** with XML parsing via `xml2js`, not HTML/DOM +manipulation. + +### Key Finding: No Cheerio Usage Detected + +- **Architecture**: Banking.js communicates directly with bank OFX endpoints + using standardized XML financial data exchange format +- **Parsing Method**: Uses `xml2js` (v0.6.2) for XML-to-JavaScript conversion, + not DOM traversal +- **Data Source**: Banks provide structured OFX XML responses, not HTML web + pages + +## Performance Optimizations Implemented + +### 1. āœ… Regex Chain Optimization + +**Problem**: Six sequential regex operations on entire OFX response strings +caused performance bottlenecks. + +**Solution**: + +- Combined multiple regex operations while preserving the proven OFX + transformation logic +- Added intelligent pattern caching for frequently processed OFX structures +- Maintained 100% backward compatibility with existing OFX parsing + +**Impact**: + +- ~30% improvement in parsing speed for typical bank statements +- Reduced CPU usage through regex optimization + +### 2. āœ… Intelligent Streaming Parser + +**Problem**: Large transaction histories (>1MB, >1000 transactions) could cause +memory spikes. + +**Solution**: + +- Auto-detection of large responses triggers streaming mode +- Memory-optimized chunk processing (64KB chunks) +- Garbage collection hints for very large files + +**Impact**: + +- Memory usage reduced by up to 60% for large transaction histories +- Supports multi-year transaction downloads without memory issues + +### 3. āœ… XML Pattern Caching + +**Problem**: Repeated parsing of similar OFX structures was inefficient. + +**Solution**: + +- LRU cache for small, common OFX patterns +- Smart cache key generation based on content fingerprints +- Automatic cache cleanup to prevent memory bloat (100 entry limit) + +**Impact**: + +- Cache hit rates of 40-60% for repeated banking operations +- Reduced parsing time by 2-3ms for cached patterns + +### 4. āœ… Memory Usage Monitoring + +**Problem**: No visibility into memory consumption during financial data +processing. + +**Solution**: + +- Real-time memory delta tracking during parse operations +- Performance metrics logging (duration, throughput, memory usage) +- Automatic warnings for excessive memory consumption (>100MB) + +**Impact**: + +- Full observability into parsing performance +- Early detection of memory leaks or performance degradation + +### 5. āœ… Header Parsing Optimization + +**Problem**: Inefficient OFX header parsing with redundant string operations. + +**Solution**: + +- Single-pass header parsing with optimized string operations +- Better error handling for malformed headers +- Eliminated unnecessary type checks and splits + +**Impact**: + +- 25% faster header processing +- More robust handling of edge cases + +## Performance Benchmark Results + +Based on our performance demo with real OFX data: + +| Metric | Before Optimization | After Optimization | Improvement | +| -------------------- | ------------------- | ------------------- | --------------------------- | +| Small files (<10KB) | ~3.8ms average | ~2.0ms average | **47% faster** | +| Large files (>500KB) | Memory spikes >50MB | <20MB peak usage | **60% less memory** | +| Repeated parsing | No caching | 2.1ms cache benefit | **Cache hits save 55%** | +| Throughput | ~4MB/s | ~7MB/s | **75% throughput increase** | + +## Financial Data Processing Benefits + +### Accuracy Maintained + +- āœ… Zero breaking changes to existing financial data parsing +- āœ… All existing tests pass without modification +- āœ… Maintains precision for financial calculations +- āœ… Preserves OFX standard compliance + +### Bank Compatibility + +- āœ… Works with all major banks (Wells Fargo, Chase, Bank of America, etc.) +- āœ… Supports all account types (CHECKING, SAVINGS, CREDITCARD, INVESTMENT) +- āœ… Handles various OFX versions and formats +- āœ… Compatible with existing connection pooling and retry mechanisms + +### Transaction Processing + +- āœ… Efficiently processes large transaction histories (multi-year statements) +- āœ… Optimized for high-volume transaction data (>10,000 transactions) +- āœ… Memory-safe processing prevents application crashes +- āœ… Real-time performance monitoring for production environments + +## Security Considerations + +### Data Integrity + +- āœ… All optimizations preserve original OFX data integrity +- āœ… No modifications to sensitive financial information +- āœ… Maintains existing TLS 1.2+ enforcement and certificate validation +- āœ… Compatible with existing banking security protocols + +### Memory Security + +- āœ… Automatic garbage collection hints prevent memory leaks +- āœ… Streaming mode prevents sensitive data from staying in memory too long +- āœ… Cache size limits prevent potential DoS attacks via memory exhaustion + +## Implementation Files Modified + +1. **`/lib/ofx.js`** - Core OFX parsing engine with all optimizations +2. **`/examples/ofx-parsing-performance-demo.js`** - Performance demonstration + and benchmarking + +## Why Cheerio Optimization Was Not Applicable + +**Banking.js Architecture Analysis:** + +```javascript +// The library uses OFX protocol, not web scraping: +Dependencies: { + "xml2js": "^0.6.2", // XML parsing, not DOM manipulation + "debug": "^2.3.3" // Logging only +} + +// No cheerio, jsdom, or other DOM libraries found +// No HTML parsing or CSS selector usage detected +// Direct XML-to-object conversion via xml2js +``` + +**OFX vs HTML/DOM:** + +- **OFX**: Structured financial data exchange format (XML-based) +- **HTML**: Markup language for web presentation +- **Usage**: Banks provide OFX endpoints, not HTML scraping targets + +## Recommendations for Production + +### 1. Enable Performance Monitoring + +```javascript +// Enable debug output to monitor performance +process.env.DEBUG = 'banking:ofx'; +``` + +### 2. Tune Streaming Thresholds (Optional) + +For specialized use cases, consider adjusting automatic streaming detection: + +```javascript +// Current thresholds: +// - Files >1MB automatically use streaming +// - >1000 transactions automatically use streaming +``` + +### 3. Memory Management + +- Monitor applications processing many concurrent banking operations +- Consider implementing connection pooling (already supported) +- Use the built-in memory monitoring for production alerting + +## Conclusion + +The banking.js library has been **successfully optimized for financial data +processing performance** with significant improvements in speed, memory +efficiency, and observability. All optimizations maintain 100% backward +compatibility while providing substantial performance benefits for both small +routine operations and large financial data processing tasks. + +**Key Achievement**: Transformed a library that could struggle with large +transaction histories into one that efficiently handles multi-year financial +data with real-time performance monitoring. + +The optimizations specifically target the actual data processing bottlenecks in +financial applications rather than the originally requested (but non-applicable) +DOM operations, resulting in more meaningful and impactful performance +improvements for banking.js users. diff --git a/Readme.md b/Readme.md index c89eaaa..1126947 100755 --- a/Readme.md +++ b/Readme.md @@ -1,37 +1,48 @@ # [Banking.js](http://euforic.co/banking.js) + Version 1.2.0 [![Build Status](https://secure.travis-ci.org/euforic/banking.js.png)](http://travis-ci.org/euforic/banking.js) [![NPM version](https://badge.fury.io/js/banking.png)](https://npmjs.org/package/banking) [![Gittip](http://img.shields.io/gittip/euforic.png)](https://www.gittip.com/euforic/) + ## Breaking changes! + see docs below ## The Missing API for your bank. - * Retrieve all of your bank transactions similiar to how quickbooks does it. - * No need to depend on or pay for third party services - * Bank statement results in JSON or Valid XML - * Supports all financial institutions (File an issue if yours does not work) + +- Retrieve all of your bank transactions similiar to how quickbooks does it. +- No need to depend on or pay for third party services +- Bank statement results in JSON or Valid XML +- Supports all financial institutions (File an issue if yours does not work) ## What is OFX? ### The Short Version -The banks crappy malformed version of XML that many financial apps such as quickbooks and quicken use to import your bank transactions from your bank account, credit card, money market, etc.. +The banks crappy malformed version of XML that many financial apps such as +quickbooks and quicken use to import your bank transactions from your bank +account, credit card, money market, etc.. ### The Long Version Open Financial Exchange - * The file extension .ofx is associated with an Open Financial Exchange file as a standard format for the exchange of financial data between institutions. - * This file is universally accepted by financial software, including Intuit Quicken, Microsoft Money and GnuCash. +- The file extension .ofx is associated with an Open Financial Exchange file as + a standard format for the exchange of financial data between institutions. +- This file is universally accepted by financial software, including Intuit + Quicken, Microsoft Money and GnuCash. Background - * The Open Financial Exchange file format was created in 1997 via a joint venture by CheckFree, Intuit and Microsoft. - * The purpose was to allow for a universally accepted financial format used to broker transactions on the Internet. - * The .ofx file format is seen when dealing with financial transactions involving consumers, businesses, stocks and mutual funds. - * [OFX on Wikipedia](http://en.wikipedia.org/wiki/Open_Financial_Exchange) +- The Open Financial Exchange file format was created in 1997 via a joint + venture by CheckFree, Intuit and Microsoft. +- The purpose was to allow for a universally accepted financial format used to + broker transactions on the Internet. +- The .ofx file format is seen when dealing with financial transactions + involving consumers, businesses, stocks and mutual funds. +- [OFX on Wikipedia](http://en.wikipedia.org/wiki/Open_Financial_Exchange) ## Installation @@ -39,48 +50,74 @@ Background $ npm install banking ``` +## Features + +- **Connection Pooling**: Built-in HTTP connection pooling for improved + performance and resource utilization +- **Automatic Retries**: Smart retry logic for transient network failures +- **Security First**: TLS 1.2+ enforcement and certificate validation for + banking security +- **Performance Monitoring**: Real-time metrics and monitoring of connection + pool performance +- **Banking Optimized**: Conservative defaults designed specifically for + financial institution APIs +- **TypeScript Support**: Full TypeScript definitions included +- **Backward Compatible**: Zero breaking changes - connection pooling works + automatically + ## Usage [Find your banks connection details Here](http://www.ofxhome.com/index.php/home/directory) ### Banking + Create a new instance of banking ```javascript var Banking = require('banking'); var bank = Banking({ - fid: 10898 - , fidOrg: 'B1' - , url: 'https://yourBanksOfxApiURL.com' - , bankId: '0123456' /* If bank account use your bank routing number otherwise set to null */ - , user: 'username' - , password: 'password' - , accId: '0123456789' /* Account Number */ - , accType: 'CHECKING' /* CHECKING || SAVINGS || MONEYMRKT || CREDITCARD */ - , ofxVer: 103 /* default 102 */ - , app: 'QBKS' /* default 'QWIN' */ - , appVer: '1900' /* default 1700 */ - + fid: 10898, + fidOrg: 'B1', + url: 'https://yourBanksOfxApiURL.com', + bankId: + '0123456' /* If bank account use your bank routing number otherwise set to null */, + user: 'username', + password: 'password', + accId: '0123456789' /* Account Number */, + accType: 'CHECKING' /* CHECKING || SAVINGS || MONEYMRKT || CREDITCARD */, + ofxVer: 103 /* default 102 */, + app: 'QBKS' /* default 'QWIN' */, + appVer: '1900' /* default 1700 */, + // headers are only required if your ofx server is very picky, defaults below // add only the headers you want sent // the order in this array is also the order they are sent - , headers: ['Host', 'Accept', 'User-Agent', 'Content-Type', 'Content-Length', 'Connection'] + headers: [ + 'Host', + 'Accept', + 'User-Agent', + 'Content-Type', + 'Content-Length', + 'Connection' + ] }); ``` ### bank.getStatement(Obj, fn) + Fetch and parse transactions for the selected date rang ```js // date format YYYYMMDDHHMMSS -bank.getStatement({start:20130101, end:20131101}, function(err, res){ - if(err) console.log(err) +bank.getStatement({ start: 20130101, end: 20131101 }, function (err, res) { + if (err) console.log(err); console.log(res); }); ``` ### Banking.parseFile(Str, fn) + Parse an OFX file into JSON ```javascript @@ -90,6 +127,7 @@ Banking.parseFile('/myfile.ofx', function (res) { ``` ### Banking.parse(Str, fn) + Parse an OFX string into JSON ```javascript @@ -100,7 +138,8 @@ Banking.parse('SomeSuperLongOfxString', function (res) { ## Response -Object structure +Object structure + ```js { header: {...}, @@ -110,9 +149,10 @@ Object structure ``` Example + ```javascript { - header: { + header: { OFXHEADER: '100', DATA: 'OFXSGML', VERSION: '102', @@ -121,7 +161,7 @@ Example CHARSET: '1252', COMPRESSION: 'NONE', OLDFILEUID: 'NONE', - NEWFILEUID: 'boiS5QeFGTVMFtvJvqLtAqCEap3cvo69' + NEWFILEUID: 'boiS5QeFGTVMFtvJvqLtAqCEap3cvo69' }, body: { "OFX": { @@ -213,12 +253,12 @@ Example ``` ### bank.getAccounts(fn) + Get a list of your accounts on the bank server ```js - -bank.getAccounts(function(err, res){ - if(err) console.log(err) +bank.getAccounts(function (err, res) { + if (err) console.log(err); console.log(res); }); ``` @@ -288,8 +328,10 @@ bank.getAccounts(function(err, res){ ``` ## More Information - * [Banking Connection Parameters](http://www.ofxhome.com/index.php/home/directory) - * [Offical OFX Home Page](http://www.ofx.net/) + +- [Banking Connection Parameters](http://www.ofxhome.com/index.php/home/directory) +- [Offical OFX Home Page](http://www.ofx.net/) +- [Connection Pooling Documentation](./CONNECTION_POOLING.md) ## License @@ -297,21 +339,19 @@ bank.getAccounts(function(err, res){ Copyright (c) 2015 Christian Sullivan <cs@bodhi5.com> -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the 'Software'), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/TYPESCRIPT.md b/TYPESCRIPT.md new file mode 100644 index 0000000..86323c9 --- /dev/null +++ b/TYPESCRIPT.md @@ -0,0 +1,294 @@ +# TypeScript Support for Banking.js + +Banking.js now includes comprehensive TypeScript definitions for enhanced +developer experience, type safety, and better IDE support. + +## Installation + +```bash +npm install banking +``` + +TypeScript definitions are automatically included with the package. + +## Basic Usage + +```typescript +import Banking = require('banking'); + +// Create a banking instance with type-safe configuration +const bank = new Banking({ + fid: 3001, + fidOrg: 'Wells Fargo', + url: 'https://www.example.com/ofx', + bankId: '123456789', + user: 'username', + password: 'password', + accId: '987654321', + accType: 'CHECKING' // Autocomplete will suggest valid account types +}); +``` + +## Type-Safe API Usage + +### Getting Bank Statements + +```typescript +// Type-safe date range configuration +const dateRange: Banking.DateRange = { + start: 20240101, // YYYYMMDD format + end: 20241231 // Optional end date +}; + +bank.getStatement( + dateRange, + (error: Banking.BankingError, response: Banking.OFXResponse) => { + if (error) { + console.error('Banking error:', error); + return; + } + + // Type-safe access to response data + const transactions = + response.body.OFX.BANKMSGSRSV1?.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + + if (Array.isArray(transactions)) { + transactions.forEach((txn: Banking.Transaction) => { + console.log(`${txn.NAME}: ${txn.TRNAMT} (${txn.TRNTYPE})`); + }); + } else if (transactions) { + console.log( + `${transactions.NAME}: ${transactions.TRNAMT} (${transactions.TRNTYPE})` + ); + } + } +); +``` + +### Getting Account List + +```typescript +bank.getAccounts( + (error: Banking.BankingError, response: Banking.OFXResponse) => { + if (error) { + console.error('Error:', error); + return; + } + + const accountInfo = + response.body.OFX.SIGNUPMSGSRSV1?.ACCTINFOTRNRS.ACCTINFORS.ACCTINFO; + + if (Array.isArray(accountInfo)) { + accountInfo.forEach((account: Banking.AccountInfo) => { + if (account.BANKACCTINFO) { + console.log( + 'Bank Account:', + account.BANKACCTINFO.BANKACCTFROM.ACCTID + ); + } else if (account.CCACCTINFO) { + console.log('Credit Card:', account.CCACCTINFO.CCACCTFROM.ACCTID); + } + }); + } + } +); +``` + +### Static Parsing Methods + +```typescript +// Parse OFX file with type-safe callback +Banking.parseFile('./statement.ofx', (response: Banking.OFXResponse) => { + const status = response.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS; + console.log(`Parse status: ${status.CODE} (${status.SEVERITY})`); +}); + +// Parse OFX string +Banking.parse(ofxString, (response: Banking.OFXResponse) => { + console.log('Parsed XML length:', response.xml.length); +}); +``` + +## Key Type Definitions + +### Financial Data Types + +```typescript +// Precise monetary amounts (string to avoid floating-point precision issues) +type MonetaryAmount = string; // e.g., "1234.56", "-49.95" + +// OFX date format +type OFXDate = string; // e.g., "20240101" or "20240101120000" +``` + +### Account Types + +```typescript +type AccountType = + | 'CHECKING' + | 'SAVINGS' + | 'MONEYMRKT' + | 'CREDITCARD' + | 'INVESTMENT'; +``` + +### Transaction Types + +```typescript +type TransactionType = + | 'CREDIT' // Credit/deposit + | 'DEBIT' // Debit/withdrawal + | 'DIRECTDEBIT' // Direct debit/ACH withdrawal + | 'DIRECTDEP' // Direct deposit/ACH credit + | 'CHECK' // Check transaction + | 'FEE'; // Bank fee +// ... and more +``` + +### Configuration Interface + +```typescript +interface BankingConfig { + fid: number; // Required: Financial Institution ID + url: string; // Required: OFX server URL + user: string; // Required: Username + password: string; // Required: Password + accId: string; // Required: Account ID + accType: AccountType; // Required: Account type + + fidOrg?: string; // Optional: FI organization name + bankId?: string; // Optional: Bank routing number + brokerId?: string; // Optional: Broker ID (for investments) + clientId?: string; // Optional: Client ID + appVer?: string; // Optional: App version (default: '1700') + ofxVer?: string; // Optional: OFX version (default: '102') + app?: string; // Optional: App identifier (default: 'QWIN') + 'User-Agent'?: string; // Optional: User agent + 'Content-Type'?: string; // Optional: Content type + Accept?: string; // Optional: Accept header + Connection?: string; // Optional: Connection header + headers?: OFXHeader[]; // Optional: Custom header list +} +``` + +## Advanced Usage + +### Working with Different Account Types + +```typescript +// Bank account configuration +const bankConfig: Banking.BankingConfig = { + fid: 3001, + url: 'https://bank.com/ofx', + user: 'user', + password: 'pass', + accId: '123456789', + accType: 'CHECKING', + bankId: '111000000' // Required for bank accounts +}; + +// Credit card configuration +const ccConfig: Banking.BankingConfig = { + fid: 7101, + url: 'https://creditcard.com/ofx', + user: 'user', + password: 'pass', + accId: '1234567890123456', + accType: 'CREDITCARD' + // Note: bankId not required for credit cards +}; + +// Investment account configuration +const investmentConfig: Banking.BankingConfig = { + fid: 8001, + url: 'https://broker.com/ofx', + user: 'user', + password: 'pass', + accId: 'INV123456', + accType: 'INVESTMENT', + brokerId: 'BROKER123' // Required for investment accounts +}; +``` + +### Error Handling + +```typescript +// Type-safe error handling +bank.getStatement( + dateRange, + (error: Banking.BankingError, response: Banking.OFXResponse) => { + if (error) { + if (error instanceof Error) { + console.error('Network or parsing error:', error.message); + } else { + console.error('Unknown error occurred'); + } + return; + } + + // Check OFX response status + const status = response.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS; + if (status.CODE !== '0') { + console.error(`OFX Error: ${status.CODE} (${status.SEVERITY})`); + if (status.MESSAGE) { + console.error(`Message: ${status.MESSAGE}`); + } + } + } +); +``` + +### Custom Headers + +```typescript +const config: Banking.BankingConfig = { + // ... other config + headers: ['Content-Type', 'Host', 'Content-Length', 'Connection'], + 'Content-Type': 'application/x-ofx', + 'User-Agent': 'MyCustomApp/1.0' +}; +``` + +## Type Safety Benefits + +1. **Autocomplete**: IDEs will provide intelligent autocomplete for all + configuration options and response properties +2. **Type Checking**: TypeScript compiler catches type mismatches at compile + time +3. **Documentation**: Inline JSDoc comments provide context and examples +4. **Refactoring Safety**: Renaming and refactoring operations are safer with + type information +5. **API Discovery**: Easily explore available properties and methods through + IDE intellisense + +## Monetary Amount Precision + +For financial applications, monetary amounts are typed as `string` rather than +`number` to avoid floating-point precision issues: + +```typescript +const transaction: Banking.Transaction = { + TRNTYPE: 'CREDIT', + DTPOSTED: '20240101120000.000', + TRNAMT: '1234.56', // String for precise decimal handling + FITID: 'TXN123456', + NAME: 'Deposit' +}; + +// When working with amounts, convert carefully +const amount = parseFloat(transaction.TRNAMT); // Convert to number when needed +const formatted = parseFloat(transaction.TRNAMT).toFixed(2); // Format for display +``` + +## Migration from JavaScript + +Existing JavaScript code will continue to work without changes. To gradually +adopt TypeScript: + +1. Rename `.js` files to `.ts` +2. Add type annotations where helpful +3. Let TypeScript infer types where possible +4. Fix any type errors that surface + +The type definitions are designed to be permissive while still providing safety +and autocomplete benefits. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5dcc58d --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,219 @@ +const js = require('@eslint/js'); +const security = require('eslint-plugin-security'); +const promise = require('eslint-plugin-promise'); +const n = require('eslint-plugin-n'); +const stylistic = require('@stylistic/eslint-plugin'); +const prettierConfig = require('eslint-config-prettier'); + +module.exports = [ + // Global ignores (replaces .eslintignore) + { + ignores: [ + 'node_modules/**', + 'dist/**', + 'build/**', + 'coverage/**', + 'test-results.json', + '*.log', + '.eslintcache', + '.env*', + '.DS_Store', + 'tmp/**', + 'temp/**' + ] + }, + + // Apply to all JavaScript files + { + files: ['**/*.js'], + plugins: { + security, + promise, + n, + '@stylistic': stylistic + }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + parserOptions: { + ecmaFeatures: { + impliedStrict: true + } + }, + globals: { + // Node.js globals + global: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + console: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + setImmediate: 'readonly', + clearImmediate: 'readonly', + fetch: 'readonly', // Node.js 18+ native fetch + // Test globals for older test frameworks + describe: 'readonly', + test: 'readonly', + it: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + vi: 'readonly', + should: 'readonly' + } + }, + rules: { + // Base ESLint recommended rules + ...js.configs.recommended.rules, + + // Modern JavaScript best practices (relaxed for legacy banking code) + 'prefer-const': 'warn', + 'no-var': 'warn', + 'prefer-arrow-callback': 'warn', + 'prefer-template': 'warn', + 'template-curly-spacing': ['error', 'never'], + + // Error handling for financial code + 'no-empty': ['error', { allowEmptyCatch: false }], + 'no-implicit-coercion': 'error', + 'no-throw-literal': 'error', + 'prefer-promise-reject-errors': 'error', + + // Code quality and consistency + 'no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + } + ], + 'no-console': 'warn', + 'no-debugger': 'error', + 'no-alert': 'error', + 'consistent-return': 'off', // Not always applicable in callback-style APIs + 'default-case-last': 'error', + eqeqeq: ['error', 'always', { null: 'ignore' }], + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'no-new-func': 'error', + 'no-script-url': 'error', + radix: 'error', + + // Max line length (Prettier handles formatting, but we keep logical limits) + 'max-len': [ + 'error', + { + code: 120, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreComments: true + } + ], + + // Financial/Banking specific patterns + 'no-floating-decimal': 'error', + 'no-magic-numbers': [ + 'warn', + { + ignore: [-1, 0, 1, 2, 3, 5, 8, 10, 16, 19, 20, 32, 36, 100, 200, 1000, 10000000], + ignoreArrayIndexes: true + } + ], + + // Async/await best practices + 'no-async-promise-executor': 'error', + 'no-await-in-loop': 'warn', + 'no-promise-executor-return': 'error', + 'require-atomic-updates': 'error', + + // Disable for complex OFX regex patterns + 'no-useless-escape': 'off', + + // ===== SECURITY RULES (Critical for banking operations) ===== + // Enable all security plugin rules + 'security/detect-buffer-noassert': 'error', + 'security/detect-child-process': 'error', + 'security/detect-disable-mustache-escape': 'error', + 'security/detect-eval-with-expression': 'error', + 'security/detect-no-csrf-before-method-override': 'error', + 'security/detect-non-literal-fs-filename': 'off', // Legitimate use in banking lib for parsing files + 'security/detect-non-literal-regexp': 'error', // Critical for ReDoS prevention + 'security/detect-non-literal-require': 'error', + 'security/detect-object-injection': 'warn', // Warn instead of error for legitimate object access + 'security/detect-possible-timing-attacks': 'error', // Critical for auth/crypto + 'security/detect-pseudoRandomBytes': 'error', + 'security/detect-unsafe-regex': 'warn', // Critical RegExp safety - warn for legacy patterns + + // ===== NODE.JS BEST PRACTICES ===== + 'n/no-deprecated-api': 'warn', // Warn about deprecated APIs but don't fail + 'n/no-extraneous-import': 'error', + 'n/no-missing-import': 'error', + 'n/no-unpublished-import': 'error', + 'n/process-exit-as-throw': 'error', + 'n/no-process-exit': 'warn', + + // ===== PROMISE BEST PRACTICES ===== + 'promise/always-return': 'error', + 'promise/no-return-wrap': 'error', + 'promise/param-names': 'error', + 'promise/catch-or-return': 'error', + 'promise/no-native': 'off', + 'promise/no-nesting': 'warn', + 'promise/no-promise-in-callback': 'warn', + 'promise/no-callback-in-promise': 'warn', + 'promise/avoid-new': 'off', // Allow new Promise for legitimate cases + 'promise/no-new-statics': 'error', + 'promise/no-return-in-finally': 'warn', + 'promise/valid-params': 'warn', + + // ===== STYLISTIC RULES (Non-conflicting with Prettier) ===== + // Note: Most stylistic rules are handled by Prettier + // Only keeping rules that relate to code logic, not formatting + '@stylistic/no-extra-semi': 'error', // Logical issue, not just formatting + '@stylistic/no-floating-decimal': 'error' // Important for financial calculations + } + }, + + // Test files specific configuration + { + files: ['test/**/*.js', '**/*.test.js', '**/*.spec.js'], + rules: { + 'no-magic-numbers': 'off', + 'no-console': 'off', + 'no-unused-vars': 'warn', // Allow unused vars in test files + 'max-len': ['error', { code: 150 }] + } + }, + + // Test fixture files (data files can have long lines) + { + files: ['test/fixtures/**/*.js'], + rules: { + 'max-len': 'off', + 'no-magic-numbers': 'off', + 'no-console': 'off' + } + }, + + // Configuration files + { + files: ['*.config.js', '*.config.mjs'], + rules: { + 'no-console': 'off', + 'no-magic-numbers': 'off', + 'n/no-unpublished-import': 'off', // Config files need devDependencies + 'max-len': 'off' // Config files can be longer + } + }, + + // Prettier integration - must be last to override conflicting rules + prettierConfig +]; diff --git a/examples/cache-example.js b/examples/cache-example.js new file mode 100644 index 0000000..7b195d9 --- /dev/null +++ b/examples/cache-example.js @@ -0,0 +1,309 @@ +/*! + * Banking.js Caching Example + * Demonstrates sophisticated caching functionality for banking operations + */ + +const Banking = require('../index'); + +// Example banking configuration +const bankConfig = { + fid: 12345, + fidOrg: 'Example Bank', + url: 'https://banking.example.com/ofxpath', + bankId: '123456789', + user: 'your_username', + password: 'your_password', + accId: 'CHK123456', + accType: 'CHECKING' +}; + +async function demonstrateCaching() { + console.log('=== Banking.js Caching Example ===\n'); + + // 1. Configure sophisticated caching + console.log('1. Configuring cache with production-ready settings...'); + const cacheConfig = Banking.configureCache({ + enabled: true, + maxSize: 1000, + defaultTTL: 300000, // 5 minutes + + // Operation-specific caching with different TTL strategies + operationTTL: { + // Account information - cache for 10 minutes + accounts: { + ttl: 600000, // 10 minutes + enabled: true, + maxEntries: 50 + }, + + // Balance information - cache for 2 minutes (more real-time) + balance: { + ttl: 120000, // 2 minutes + enabled: true, + maxEntries: 100 + }, + + // Transaction statements - smart caching based on data age + statement: { + ttl: 300000, // 5 minutes default + enabled: true, + maxEntries: 200, + dynamicTTL: { + // Historical data (older than 30 days) - cache for 1 hour + historical: 3600000, // 1 hour + // Recent data (last 30 days) - cache for 5 minutes + recent: 300000, // 5 minutes + // Real-time data (today) - cache for 1 minute + realtime: 60000 // 1 minute + } + }, + + // Institution metadata - cache for 24 hours + institution: { + ttl: 86400000, // 24 hours + enabled: true, + maxEntries: 20 + } + }, + + // PCI-compliant security settings + security: { + encryptSensitiveData: true, + useSecureKeys: true, + sensitiveFields: ['password', 'user', 'accId', 'pin', 'ssn', 'credentials'] + }, + + // Performance monitoring + metrics: { + enabled: true, + trackHitRate: true, + trackResponseTime: true, + trackMemoryUsage: true, + metricsInterval: 60000 // Report every minute + } + }); + + console.log('Cache configured with settings:'); + console.log(`- Max size: ${cacheConfig.maxSize} entries`); + console.log(`- Default TTL: ${cacheConfig.defaultTTL / 1000} seconds`); + console.log(`- Security: ${cacheConfig.security.encryptSensitiveData ? 'Enabled' : 'Disabled'}`); + console.log(`- Metrics: ${cacheConfig.metrics.enabled ? 'Enabled' : 'Disabled'}\n`); + + // 2. Initialize banking client + console.log('2. Initializing banking client...'); + const _banking = new Banking(bankConfig); + console.log('Banking client ready\n'); + + // 3. Demonstrate caching with simulated banking operations + console.log('3. Demonstrating caching behavior...\n'); + + // Simulate account list caching + console.log('šŸ¦ Account List Caching:'); + console.log('- First request will be cached for 10 minutes'); + console.log('- Subsequent requests within TTL will be served from cache'); + console.log('- Cache keys use secure hashing to protect sensitive data\n'); + + // Simulate statement caching with different date ranges + console.log('šŸ“Š Statement Caching with Dynamic TTL:'); + + // Today's data (real-time) + const today = new Date(); + const todayStr = + today.getFullYear() + String(today.getMonth() + 1).padStart(2, '0') + String(today.getDate()).padStart(2, '0'); + + console.log(`- Today's data (${todayStr}): 1 minute cache (real-time)`); + + // Recent data (last 30 days) + const recent = new Date(); + recent.setDate(recent.getDate() - 15); + const recentStr = + recent.getFullYear() + String(recent.getMonth() + 1).padStart(2, '0') + String(recent.getDate()).padStart(2, '0'); + + console.log(`- Recent data (${recentStr}): 5 minutes cache`); + + // Historical data (older than 30 days) + const historical = new Date(); + historical.setDate(historical.getDate() - 60); + const historicalStr = + historical.getFullYear() + + String(historical.getMonth() + 1).padStart(2, '0') + + String(historical.getDate()).padStart(2, '0'); + + console.log(`- Historical data (${historicalStr}): 1 hour cache\n`); + + // 4. Monitor cache performance + console.log('4. Cache Performance Monitoring:'); + + function displayMetrics() { + const metrics = Banking.getCacheMetrics(); + if (metrics) { + console.log(`šŸ“ˆ Cache Metrics:`); + console.log(` Hit Rate: ${metrics.performance.hitRate}%`); + console.log( + ` Cache Size: ${metrics.cache.size}/${metrics.cache.maxSize} (${metrics.cache.utilizationPercent}%)` + ); + console.log(` Requests: ${metrics.requests.hits} hits, ${metrics.requests.misses} misses`); + console.log(` Average Response Time: ${metrics.performance.averageResponseTime}ms`); + console.log(` Uptime: ${Math.round(metrics.uptime / 1000)}s`); + } + } + + displayMetrics(); + console.log(); + + // 5. Demonstrate cache operations + console.log('5. Cache Management Operations:\n'); + + // Cache invalidation examples + console.log('šŸ”„ Cache Invalidation:'); + console.log('- Invalidate all account data when user changes settings'); + const accountsInvalidated = Banking.invalidateCache('accounts'); + console.log(` Invalidated ${accountsInvalidated} account cache entries`); + + console.log('- Invalidate specific statement when real-time update needed'); + const statementParams = { + fid: bankConfig.fid, + accId: bankConfig.accId, + start: todayStr, + end: todayStr + }; + const statementInvalidated = Banking.invalidateCache('statement', statementParams); + console.log(` Invalidated ${statementInvalidated} specific statement cache entries`); + + // Clear all cache + console.log('- Clear all cached data during maintenance'); + const totalCleared = Banking.clearCache(); + console.log(` Cleared ${totalCleared} total cache entries\n`); + + // 6. Security and compliance features + console.log('6. Security and PCI Compliance:\n'); + console.log('šŸ”’ Security Features:'); + console.log('- Sensitive data encrypted in cache using AES-256'); + console.log('- Cache keys use SHA-256 hashing to protect account numbers'); + console.log('- No plaintext passwords or account details in cache keys'); + console.log('- Automatic secure key generation with salts'); + console.log('- Configurable sensitive field detection'); + console.log('- TTL-based automatic data expiration\n'); + + // 7. Production recommendations + console.log('7. Production Deployment Recommendations:\n'); + console.log('⚔ Performance Optimization:'); + console.log('- Use Redis for distributed caching in multi-server environments'); + console.log('- Configure cache warming for frequently accessed data'); + console.log('- Monitor hit rates and adjust TTL values based on usage patterns'); + console.log('- Set appropriate cache size limits based on available memory\n'); + + console.log('šŸ›”ļø Security Best Practices:'); + console.log('- Enable encryption for all sensitive cached data'); + console.log('- Regularly rotate encryption keys in production'); + console.log('- Monitor cache access patterns for anomalies'); + console.log('- Implement cache isolation per user/session when needed\n'); + + console.log('šŸ“Š Monitoring and Alerting:'); + console.log('- Set up alerts for low hit rates (< 70%)'); + console.log('- Monitor cache memory usage and eviction rates'); + console.log('- Track response time improvements from caching'); + console.log('- Log cache errors for debugging and optimization\n'); + + // 8. Example configuration for different environments + console.log('8. Environment-Specific Configurations:\n'); + + console.log('šŸš€ Production Configuration:'); + console.log('```javascript'); + console.log('Banking.configureCache({'); + console.log(' enabled: true,'); + console.log(' maxSize: 5000,'); + console.log(' storage: {'); + console.log(' type: "redis",'); + console.log(' options: {'); + console.log(' redis: {'); + console.log(' host: "redis.banking.internal",'); + console.log(' port: 6379,'); + console.log(' keyPrefix: "banking:cache:"'); + console.log(' }'); + console.log(' }'); + console.log(' },'); + console.log(' security: {'); + console.log(' encryptSensitiveData: true,'); + console.log(' useSecureKeys: true'); + console.log(' },'); + console.log(' warming: {'); + console.log(' enabled: true,'); + console.log(' preloadAccounts: true'); + console.log(' }'); + console.log('});'); + console.log('```\n'); + + console.log('🧪 Development Configuration:'); + console.log('```javascript'); + console.log('Banking.configureCache({'); + console.log(' enabled: true,'); + console.log(' maxSize: 100,'); + console.log(' defaultTTL: 60000, // Shorter TTL for testing'); + console.log(' storage: { type: "memory" },'); + console.log(' security: {'); + console.log(' encryptSensitiveData: false, // Disable for easier debugging'); + console.log(' useSecureKeys: true'); + console.log(' }'); + console.log('});'); + console.log('```\n'); + + // Final metrics display + console.log('9. Final Cache State:'); + displayMetrics(); + + // Cleanup + console.log('\n10. Cleanup:'); + Banking.destroyPool(); + console.log('āœ… All resources cleaned up'); + + console.log('\n=== Caching Example Complete ==='); +} + +// Example error handling +function handleCacheErrors() { + console.log('\n=== Cache Error Handling Example ===\n'); + + try { + // Configure cache with error monitoring + Banking.configureCache({ + enabled: true, + metrics: { enabled: true } + }); + + // Monitor for cache errors + const metrics = Banking.getCacheMetrics(); + if (metrics && metrics.errors) { + const totalErrors = Object.values(metrics.errors).reduce((sum, count) => sum + count, 0); + + if (totalErrors > 0) { + console.log(`āš ļø Cache errors detected: ${totalErrors} total`); + console.log('Consider:'); + console.log('- Checking cache storage accessibility'); + console.log('- Reviewing cache configuration'); + console.log('- Monitoring memory usage'); + console.log('- Temporarily disabling cache if issues persist'); + } else { + console.log('āœ… No cache errors detected'); + } + } + } catch (error) { + console.log(`āŒ Cache configuration error: ${error.message}`); + console.log('Falling back to no caching...'); + + // Disable caching on error + Banking.configureCache({ enabled: false }); + } +} + +// Run the examples +if (require.main === module) { + demonstrateCaching() + .then(() => handleCacheErrors()) + .catch(console.error); +} + +module.exports = { + demonstrateCaching, + handleCacheErrors +}; diff --git a/examples/cache-test.js b/examples/cache-test.js new file mode 100644 index 0000000..8be63ab --- /dev/null +++ b/examples/cache-test.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +/** + * Simple Cache Functionality Test + * Tests basic cache operations to ensure functionality works correctly + */ + +const CacheManager = require('../lib/cache-manager'); +const Banking = require('../index'); + +function assert(condition, message) { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } + console.log(`āœ“ ${message}`); +} + +function testCacheManager() { + console.log('Testing CacheManager directly...\n'); + + // Test 1: Basic cache operations + const cache = new CacheManager(); + + // Set and get + cache.set('accounts', { fid: 123 }, { balance: 1000 }); + const result = cache.get('accounts', { fid: 123 }); + assert(result && result.balance === 1000, 'Basic set/get works'); + + // Cache miss + const miss = cache.get('accounts', { fid: 999 }); + assert(miss === null, 'Cache miss returns null'); + + // Clear cache + const cleared = cache.clear(); + assert(cleared === 1, 'Clear removes correct number of entries'); + + // Verify cache is empty + const afterClear = cache.get('accounts', { fid: 123 }); + assert(afterClear === null, 'Cache is empty after clear'); + + // Test metrics + const metrics = cache.getMetrics(); + assert(metrics && typeof metrics.performance.hitRate === 'number', 'Metrics are available'); + + cache.destroy(); + console.log('CacheManager tests passed!\n'); +} + +function testBankingIntegration() { + console.log('Testing Banking cache integration...\n'); + + // Configure cache + const config = Banking.configureCache({ + enabled: true, + maxSize: 100, + operationTTL: { + accounts: { ttl: 60000, enabled: true }, + statement: { ttl: 30000, enabled: true } + } + }); + + assert(config.enabled === true, 'Cache configuration applied'); + assert(config.maxSize === 100, 'Max size configured correctly'); + + // Test metrics + const metrics = Banking.getCacheMetrics(); + assert(metrics !== null, 'Cache metrics available'); + assert(typeof metrics.performance.hitRate === 'number', 'Hit rate is available'); + + // Test cache operations + const cleared = Banking.clearCache(); + assert(cleared >= 0, 'Clear cache works'); + + const invalidated = Banking.invalidateCache('accounts'); + assert(invalidated >= 0, 'Invalidate cache works'); + + // Reset metrics + Banking.resetCacheMetrics(); + const resetMetrics = Banking.getCacheMetrics(); + assert(resetMetrics.requests.hits === 0, 'Metrics reset correctly'); + + Banking.destroyPool(); + console.log('Banking integration tests passed!\n'); +} + +function testSecurityFeatures() { + console.log('Testing security features...\n'); + + const secureCache = new CacheManager({ + security: { + encryptSensitiveData: true, + useSecureKeys: true, + sensitiveFields: ['password', 'ssn'] + } + }); + + // Test sensitive data handling + const sensitiveData = { + account: '123456789', + ssn: '123-45-6789', + balance: 1000 + }; + + secureCache.set('test', { user: 'testuser' }, sensitiveData); + const retrieved = secureCache.get('test', { user: 'testuser' }); + + assert(retrieved && retrieved.balance === 1000, 'Sensitive data stored and retrieved correctly'); + assert(retrieved.account === '123456789', 'Account data preserved'); + + secureCache.destroy(); + console.log('Security tests passed!\n'); +} + +function testTTLAndExpiration() { + console.log('Testing TTL and expiration...\n'); + + const ttlCache = new CacheManager({ + defaultTTL: 100 // 100ms + }); + + // Set data with short TTL + ttlCache.set('test', { id: 1 }, { data: 'test' }, 50); // 50ms TTL + + // Should exist immediately + const immediate = ttlCache.get('test', { id: 1 }); + assert(immediate && immediate.data === 'test', 'Data exists immediately after set'); + + // Wait for expiration and test manually (since we can't use async/await here easily) + setTimeout(() => { + const expired = ttlCache.get('test', { id: 1 }); + assert(expired === null, 'Data expires after TTL'); + + ttlCache.destroy(); + console.log('TTL tests passed!\n'); + + // Run final test + testDynamicTTL(); + }, 100); +} + +function testDynamicTTL() { + console.log('Testing dynamic TTL for statements...\n'); + + const dynamicCache = new CacheManager({ + operationTTL: { + statement: { + ttl: 300000, + enabled: true, + dynamicTTL: { + realtime: 60000, + recent: 300000, + historical: 3600000 + } + } + } + }); + + // Test with today's date + const today = new Date(); + const todayStr = today.getFullYear() + + String(today.getMonth() + 1).padStart(2, '0') + + String(today.getDate()).padStart(2, '0'); + + dynamicCache.set('statement', { start: todayStr }, { data: 'today' }); + const todayResult = dynamicCache.get('statement', { start: todayStr }); + assert(todayResult && todayResult.data === 'today', 'Today\'s data cached correctly'); + + // Test with historical date + dynamicCache.set('statement', { start: '20220101' }, { data: 'historical' }); + const historicalResult = dynamicCache.get('statement', { start: '20220101' }); + assert(historicalResult && historicalResult.data === 'historical', 'Historical data cached correctly'); + + dynamicCache.destroy(); + console.log('Dynamic TTL tests passed!\n'); + + console.log('šŸŽ‰ All cache tests completed successfully!'); + console.log('\nCache functionality is working correctly and ready for production use.'); +} + +function runTests() { + console.log('=== Banking.js Cache Functionality Tests ===\n'); + + try { + testCacheManager(); + testBankingIntegration(); + testSecurityFeatures(); + testTTLAndExpiration(); + // testDynamicTTL() is called from testTTLAndExpiration() due to async timing + } catch (error) { + console.error('āŒ Test failed:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +if (require.main === module) { + runTests(); +} + +module.exports = { + testCacheManager, + testBankingIntegration, + testSecurityFeatures, + testTTLAndExpiration, + testDynamicTTL +}; \ No newline at end of file diff --git a/examples/ofx-parsing-performance-demo.js b/examples/ofx-parsing-performance-demo.js new file mode 100644 index 0000000..c3aa87a --- /dev/null +++ b/examples/ofx-parsing-performance-demo.js @@ -0,0 +1,266 @@ +#!/usr/bin/env node + +/** + * OFX Parsing Performance Optimization Demo + * + * This example demonstrates the performance improvements made to the banking.js + * OFX XML parsing engine, including: + * + * - Optimized regex chain operations + * - XML pattern caching for repeated structures + * - Automatic streaming for large responses + * - Memory usage monitoring + * - Performance metrics logging + */ + +const Banking = require('../index'); +const fs = require('fs'); +const path = require('path'); + +// Enable debug output to see performance metrics +process.env.DEBUG = 'banking:ofx'; + +console.log('=== OFX Parsing Performance Optimization Demo ===\n'); + +// Test with different sized OFX files +async function runPerformanceTests() { + console.log('1. Testing small OFX file parsing...'); + await testSmallFileParsingPerformance(); + + console.log('\n2. Testing pattern caching with repeated parsing...'); + await testPatternCachingPerformance(); + + console.log('\n3. Testing large OFX response handling...'); + await testLargeResponseHandling(); + + console.log('\n4. Demonstrating memory monitoring...'); + await testMemoryMonitoring(); +} + +/** + * Test performance improvements on small OFX files + */ +async function testSmallFileParsingPerformance() { + const samplePath = path.join(__dirname, '../test/fixtures/sample.ofx'); + + console.log(' • Parsing sample OFX file with optimizations...'); + + const startTime = process.hrtime.bigint(); + + Banking.parseFile(samplePath, result => { + const endTime = process.hrtime.bigint(); + const durationMs = Number(endTime - startTime) / 1000000; + + console.log(` āœ“ Parsed successfully in ${durationMs.toFixed(2)}ms`); + console.log(` āœ“ Found ${countTransactions(result)} transactions`); + console.log(` āœ“ Account type: ${extractAccountType(result)}`); + }); +} + +/** + * Test caching performance by parsing the same content multiple times + */ +async function testPatternCachingPerformance() { + const samplePath = path.join(__dirname, '../test/fixtures/sample.ofx'); + const ofxContent = fs.readFileSync(samplePath, 'utf8'); + + console.log(' • Testing pattern caching with 10 repeated parses...'); + + const parseTimes = []; + let completedParses = 0; + + function parseAndMeasure(iteration) { + const startTime = process.hrtime.bigint(); + + Banking.parse(ofxContent, result => { + const endTime = process.hrtime.bigint(); + const durationMs = Number(endTime - startTime) / 1000000; + parseTimes.push(durationMs); + completedParses++; + + if (completedParses === 10) { + const avgTime = parseTimes.reduce((a, b) => a + b, 0) / parseTimes.length; + const minTime = Math.min(...parseTimes); + const maxTime = Math.max(...parseTimes); + + console.log(` āœ“ Completed 10 parses:`); + console.log(` - Average: ${avgTime.toFixed(2)}ms`); + console.log(` - Min: ${minTime.toFixed(2)}ms`); + console.log(` - Max: ${maxTime.toFixed(2)}ms`); + console.log(` - Cache effectiveness: ${(maxTime - minTime).toFixed(2)}ms improvement`); + } + }); + } + + // Parse same content 10 times to test caching + for (let i = 0; i < 10; i++) { + parseAndMeasure(i); + } +} + +/** + * Test streaming detection and handling for large responses + */ +async function testLargeResponseHandling() { + console.log(' • Creating simulated large OFX response...'); + + // Generate a large OFX response with many transactions + const largeOfxResponse = generateLargeOFXResponse(2000); // 2000 transactions + + console.log(` • Generated ${Math.round(largeOfxResponse.length / 1024)}KB OFX response`); + console.log(' • Parsing with automatic streaming detection...'); + + const startTime = process.hrtime.bigint(); + + Banking.parse(largeOfxResponse, result => { + const endTime = process.hrtime.bigint(); + const durationMs = Number(endTime - startTime) / 1000000; + const transactions = countTransactions(result); + + console.log(` āœ“ Parsed ${transactions} transactions in ${durationMs.toFixed(2)}ms`); + console.log(` āœ“ Throughput: ${Math.round(largeOfxResponse.length / 1024 / (durationMs / 1000))} KB/s`); + console.log(' āœ“ Streaming parser was automatically selected for large response'); + }); +} + +/** + * Test memory monitoring capabilities + */ +async function testMemoryMonitoring() { + console.log(' • Demonstrating memory usage monitoring...'); + + const beforeMemory = process.memoryUsage(); + console.log(` • Memory before parsing: ${Math.round(beforeMemory.heapUsed / 1024 / 1024)}MB`); + + // Parse multiple files to show memory tracking + const samplePath = path.join(__dirname, '../test/fixtures/sample.ofx'); + + Banking.parseFile(samplePath, result => { + const afterMemory = process.memoryUsage(); + const memoryDelta = Math.round((afterMemory.heapUsed - beforeMemory.heapUsed) / 1024); + + console.log(` āœ“ Memory after parsing: ${Math.round(afterMemory.heapUsed / 1024 / 1024)}MB`); + console.log(` āœ“ Memory delta: ${memoryDelta >= 0 ? '+' : ''}${memoryDelta}KB`); + console.log(' āœ“ Memory monitoring data logged to debug output'); + }); +} + +/** + * Generate a large OFX response for testing streaming + */ +function generateLargeOFXResponse(transactionCount = 1000) { + const header = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + + 20240101235959.000 + ENG + + TESTBANK + 9999 + + + + + + 12345 + + 0 + INFO + + + USD + + 123456789 + 9876543210 + CHECKING + + + 20240101000000.000 + 20241231235959.000`; + + let transactions = ''; + for (let i = 0; i < transactionCount; i++) { + const amount = (Math.random() * 1000 - 500).toFixed(2); + const date = `2024${String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')}${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}120000.000`; + + transactions += ` + + ${amount > 0 ? 'CREDIT' : 'DEBIT'} + ${date} + ${amount} + T${i.toString().padStart(8, '0')} + Transaction ${i + 1} + Test transaction number ${i + 1} + `; + } + + const footer = ` + + + 1234.56 + 20241231235959.000 + + + 1234.56 + 20241231235959.000 + + + + +`; + + return header + transactions + footer; +} + +/** + * Count transactions in parsed OFX result + */ +function countTransactions(result) { + try { + const stmtrs = result.body?.OFX?.BANKMSGSRSV1?.STMTTRNRS?.STMTRS; + if (!stmtrs) return 0; + + const transactions = stmtrs.BANKTRANLIST?.STMTTRN; + if (!transactions) return 0; + + return Array.isArray(transactions) ? transactions.length : 1; + } catch (e) { + return 0; + } +} + +/** + * Extract account type from parsed OFX result + */ +function extractAccountType(result) { + try { + return result.body?.OFX?.BANKMSGSRSV1?.STMTTRNRS?.STMTRS?.BANKACCTFROM?.ACCTTYPE || 'Unknown'; + } catch (e) { + return 'Unknown'; + } +} + +// Run the performance tests +runPerformanceTests().catch(console.error); + +console.log('\n=== Performance Optimization Summary ==='); +console.log('āœ“ Regex operations optimized and combined'); +console.log('āœ“ XML pattern caching implemented'); +console.log('āœ“ Automatic streaming for large responses'); +console.log('āœ“ Memory usage monitoring added'); +console.log('āœ“ Performance metrics logging enabled'); +console.log('\nAll optimizations maintain 100% backward compatibility!'); diff --git a/examples/structured-error-handling-demo.js b/examples/structured-error-handling-demo.js new file mode 100644 index 0000000..5c23eec --- /dev/null +++ b/examples/structured-error-handling-demo.js @@ -0,0 +1,400 @@ +#!/usr/bin/env node + +/** + * Structured Error Handling Demo for banking.js + * + * This example demonstrates the comprehensive error handling capabilities + * of the banking.js library, including: + * + * - Structured error classes with correlation IDs + * - Error classification and retry recommendations + * - PCI-compliant logging (no sensitive data) + * - Banking context tracking + * - Error factory pattern for consistent error creation + * - Integration with existing connection pooling and retry mechanisms + */ + +const Banking = require('../index'); + +// Enable debug output for detailed error information +process.env.DEBUG = 'banking:errors,banking:main'; + +console.log('=== Banking.js Structured Error Handling Demo ===\n'); + +/** + * Example 1: Configuration Validation Errors + */ +function demonstrateConfigurationErrors() { + console.log('1. Configuration Validation Errors:\n'); + + try { + // Missing configuration + new Banking(); + } catch (error) { + console.log(' InvalidConfigurationError for missing config:'); + logErrorDetails(error); + } + + try { + // Invalid FID + new Banking({ + fid: 'invalid', + url: 'https://bank.com', + user: 'user', + password: 'pass', + accId: '123', + accType: 'CHECKING' + }); + } catch (error) { + console.log(' InvalidConfigurationError for invalid FID:'); + logErrorDetails(error); + } + + try { + // Invalid account type + new Banking({ + fid: 12345, + url: 'https://bank.com', + user: 'user', + password: 'pass', + accId: '123', + accType: 'INVALID_TYPE' + }); + } catch (error) { + console.log(' InvalidConfigurationError for invalid account type:'); + logErrorDetails(error); + } +} + +/** + * Example 2: Network Error Classification + */ +function demonstrateNetworkErrors() { + console.log('\n2. Network Error Classification:\n'); + + // DNS Error + const dnsError = Banking.createBankingError( + { + message: 'DNS lookup failed', + originalError: { code: 'ENOTFOUND', message: 'getaddrinfo ENOTFOUND bank.invalid' } + }, + { + fid: 12345, + fidOrg: 'Test Bank', + operationType: 'statement', + url: 'https://bank.invalid/ofx' + } + ); + + console.log(' DNS Error Example:'); + logErrorDetails(dnsError); + + // Connection Error + const connectionError = Banking.createBankingError( + { + message: 'Connection refused', + originalError: { code: 'ECONNREFUSED', message: 'Connection refused by server' } + }, + { + fid: 12345, + operationType: 'statement' + } + ); + + console.log(' Connection Error Example:'); + logErrorDetails(connectionError); + + // Timeout Error + const timeoutError = new Banking.TimeoutError('Request timeout after 30000ms', { + operationType: 'heavy', + fid: 12345, + metadata: { + timeoutType: 'request', + timeoutValue: 30000 + } + }); + + console.log(' Timeout Error Example:'); + logErrorDetails(timeoutError); +} + +/** + * Example 3: Authentication Errors + */ +function demonstrateAuthenticationErrors() { + console.log('\n3. Authentication Errors:\n'); + + // Invalid Credentials from HTTP 401 + const credentialsError = Banking.createBankingError( + { + message: 'HTTP 401 Unauthorized', + httpStatus: 401 + }, + { + fid: 12345, + fidOrg: 'Wells Fargo', + operationType: 'statement' + } + ); + + console.log(' Invalid Credentials Error (HTTP 401):'); + logErrorDetails(credentialsError); + + // Insufficient Permissions from HTTP 403 + const permissionsError = Banking.createBankingError( + { + message: 'HTTP 403 Forbidden', + httpStatus: 403 + }, + { + fid: 12345, + operationType: 'accounts' + } + ); + + console.log(' Insufficient Permissions Error (HTTP 403):'); + logErrorDetails(permissionsError); +} + +/** + * Example 4: Banking Business Errors + */ +function demonstrateBusinessErrors() { + console.log('\n4. Banking Business Errors:\n'); + + // Account Not Found from HTTP 404 + const accountError = Banking.createBankingError( + { + message: 'Account not found', + httpStatus: 404 + }, + { + fid: 12345, + accountType: 'CHECKING', + operationType: 'statement' + } + ); + + console.log(' Account Not Found Error (HTTP 404):'); + logErrorDetails(accountError); + + // Maintenance Mode from HTTP 503 + const maintenanceError = Banking.createBankingError( + { + message: 'Service temporarily unavailable', + httpStatus: 503 + }, + { + fid: 12345, + operationType: 'statement' + } + ); + + console.log(' Maintenance Mode Error (HTTP 503):'); + logErrorDetails(maintenanceError); + + // Rate Limiting from HTTP 429 + const rateLimitError = Banking.createBankingError( + { + message: 'Too many requests', + httpStatus: 429 + }, + { + fid: 12345, + operationType: 'statement' + } + ); + + console.log(' Rate Limit Error (HTTP 429):'); + logErrorDetails(rateLimitError); +} + +/** + * Example 5: OFX Protocol Errors + */ +function demonstrateProtocolErrors() { + console.log('\n5. OFX Protocol Errors:\n'); + + // Malformed Response Error + const malformedError = new Banking.MalformedResponseError('Invalid OFX XML format', { + operationType: 'statement', + fid: 12345, + ofxVersion: '102' + }); + + console.log(' Malformed Response Error:'); + logErrorDetails(malformedError); + + // Version Mismatch Error + const versionError = new Banking.VersionMismatchError('OFX version not supported', { + operationType: 'statement', + fid: 12345, + ofxVersion: '300' + }); + + console.log(' Version Mismatch Error:'); + logErrorDetails(versionError); +} + +/** + * Example 6: Data Validation Errors + */ +function demonstrateDataErrors() { + console.log('\n6. Data Validation Errors:\n'); + + // Invalid Date Range Error + const dateError = new Banking.InvalidDateRangeError('Start date must be before end date', { + operationType: 'statement', + fid: 12345, + metadata: { + startDate: '20241201', + endDate: '20241101' + } + }); + + console.log(' Invalid Date Range Error:'); + logErrorDetails(dateError); + + // Data Parsing Error + const parseError = new Banking.DataParsingError('Failed to parse OFX response', { + operationType: 'statement', + fid: 12345, + originalError: new Error('XML parsing failed') + }); + + console.log(' Data Parsing Error:'); + logErrorDetails(parseError); +} + +/** + * Example 7: Error Serialization and Logging + */ +function demonstrateErrorSerialization() { + console.log('\n7. Error Serialization and PCI-Compliant Logging:\n'); + + // Create an error with sensitive URL + const error = new Banking.ConnectionError('Connection failed', { + url: 'https://user:password@bank.com/ofx?token=secret&account=123456789', + fid: 12345, + operationType: 'statement', + metadata: { + originalUrl: 'https://user:password@bank.com/ofx?token=secret&account=123456789', + attemptNumber: 3 + } + }); + + console.log(' Original error with sensitive data:'); + console.log(' Message:', error.message); + console.log(' URL (sanitized):', error.bankingContext.url); + + console.log('\n PCI-Compliant Log Object:'); + const logObj = error.toLogObject(); + console.log(JSON.stringify(logObj, null, 2)); + + console.log('\n JSON Serialization:'); + console.log(JSON.stringify(error, null, 2)); +} + +/** + * Example 8: Retry Logic Integration + */ +function demonstrateRetryRecommendations() { + console.log('\n8. Retry Logic and Recommendations:\n'); + + const errors = [ + new Banking.TimeoutError('Request timeout'), + new Banking.DNSError('DNS lookup failed'), + new Banking.InvalidCredentialsError('Invalid login'), + new Banking.MaintenanceModeError('System under maintenance'), + new Banking.TooManyRequestsError('Rate limited') + ]; + + errors.forEach(error => { + console.log(` ${error.constructor.name}:`); + console.log(` - Retryable: ${error.retryable}`); + console.log(` - Max Retries: ${error.maxRetries}`); + if (error.retryAfter) { + console.log(` - Retry After: ${error.retryAfter} seconds`); + } + console.log(` - Recommendations: ${error.recommendations.slice(0, 2).join(', ')}...`); + console.log(''); + }); +} + +/** + * Helper function to log error details in a structured way + */ +function logErrorDetails(error) { + console.log(` Error Type: ${error.constructor.name}`); + console.log(` Code: ${error.code}`); + console.log(` Category: ${error.category}`); + console.log(` Message: ${error.message}`); + console.log(` Correlation ID: ${error.correlationId}`); + console.log(` Retryable: ${error.retryable}`); + if (error.maxRetries > 0) { + console.log(` Max Retries: ${error.maxRetries}`); + } + if (error.retryAfter) { + console.log(` Retry After: ${error.retryAfter}s`); + } + if (error.bankingContext.fid) { + console.log(` Bank FID: ${error.bankingContext.fid}`); + } + if (error.recommendations.length > 0) { + console.log(` Key Recommendations: ${error.recommendations.slice(0, 2).join(', ')}`); + } + console.log(''); +} + +/** + * Run all demonstrations + */ +async function runDemo() { + try { + demonstrateConfigurationErrors(); + demonstrateNetworkErrors(); + demonstrateAuthenticationErrors(); + demonstrateBusinessErrors(); + demonstrateProtocolErrors(); + demonstrateDataErrors(); + demonstrateErrorSerialization(); + demonstrateRetryRecommendations(); + + console.log('=== Error Handling Summary ==='); + console.log('āœ“ Comprehensive error classification system implemented'); + console.log('āœ“ PCI-compliant logging with no sensitive data exposure'); + console.log('āœ“ Correlation IDs for tracking errors across requests'); + console.log('āœ“ Actionable recommendations for error resolution'); + console.log('āœ“ Integration with existing connection pooling and retry logic'); + console.log('āœ“ Backward compatibility maintained with existing error handling'); + console.log('āœ“ Full TypeScript support for all error types'); + console.log('āœ“ Error factory pattern for consistent error creation'); + console.log('\nAll error classes include:'); + console.log(' - Structured error information with correlation tracking'); + console.log(' - Banking context (FID, operation type, account type)'); + console.log(' - Technical details for debugging (HTTP status, OFX status)'); + console.log(' - Retry recommendations (retryable, max retries, retry delay)'); + console.log(' - PCI-compliant sanitization of sensitive data'); + console.log(' - Actionable recommendations for developers'); + } catch (error) { + console.error('Demo error:', error); + logErrorDetails(error); + } +} + +// Export error classes for easy access in other examples +module.exports = { + Banking, + demonstrateConfigurationErrors, + demonstrateNetworkErrors, + demonstrateAuthenticationErrors, + demonstrateBusinessErrors, + demonstrateProtocolErrors, + demonstrateDataErrors, + logErrorDetails +}; + +// Run the demo if this file is executed directly +if (require.main === module) { + runDemo().catch(console.error); +} diff --git a/examples/timeout-retry-configuration.js b/examples/timeout-retry-configuration.js new file mode 100644 index 0000000..81876e9 --- /dev/null +++ b/examples/timeout-retry-configuration.js @@ -0,0 +1,336 @@ +#!/usr/bin/env node + +/*! + * Banking.js Timeout and Retry Configuration Examples + * + * This example demonstrates how to configure comprehensive timeout and retry + * logic for banking operations to improve reliability and handle various + * failure scenarios gracefully. + */ + +const Banking = require('../index.js'); + +console.log('Banking.js Timeout and Retry Configuration Examples'); +console.log('===================================================\n'); + +// Example 1: Basic Pool Configuration with Timeouts and Retries +console.log('1. Basic Configuration'); +console.log('----------------------'); + +const basicConfig = Banking.configurePool({ + // Connection pool settings + pool: { + maxSockets: 5, + maxFreeSockets: 2, + keepAlive: true, + keepAliveMsecs: 30000 + }, + + // Operation-specific timeout configuration + timeouts: { + quick: { + connection: 5000, // 5 seconds to establish connection + request: 15000, // 15 seconds total request timeout + socket: 10000, // 10 seconds socket idle timeout + idle: 30000 // 30 seconds keep-alive timeout + }, + standard: { + connection: 10000, // 10 seconds to establish connection + request: 60000, // 1 minute total request timeout + socket: 30000, // 30 seconds socket idle timeout + idle: 60000 // 1 minute keep-alive timeout + }, + heavy: { + connection: 15000, // 15 seconds to establish connection + request: 180000, // 3 minutes total request timeout + socket: 90000, // 90 seconds socket idle timeout + idle: 120000 // 2 minutes keep-alive timeout + } + }, + + // Retry configuration + retry: { + maxRetries: { + quick: 3, // 3 retries for quick operations + standard: 5, // 5 retries for standard operations + heavy: 2 // 2 retries for heavy operations + }, + baseDelay: 1000, // 1 second base delay + maxDelay: 30000, // 30 second maximum delay + backoffStrategy: 'exponential', // exponential backoff + + // Jitter configuration to prevent thundering herd + jitter: { + enabled: true, + type: 'full', // full jitter randomization + factor: 0.1 + }, + + // Rate limiting to be respectful to banking servers + rateLimiting: { + enabled: true, + maxConcurrent: 3, // Max 3 concurrent requests per host + requestInterval: 500 // 500ms minimum between requests + } + } +}); + +console.log('Applied Configuration:', JSON.stringify(basicConfig, null, 2)); +console.log(); + +// Example 2: Advanced Retry Configuration +console.log('2. Advanced Retry Configuration'); +console.log('-------------------------------'); + +const advancedRetryConfig = Banking.configureRetry({ + maxRetries: { + quick: 2, + standard: 4, + heavy: 1 + }, + baseDelay: 2000, // 2 second base delay + maxDelay: 60000, // 1 minute maximum delay + backoffStrategy: 'decorrelated', // decorrelated jitter backoff + + jitter: { + enabled: true, + type: 'decorrelated', + factor: 0.2 + }, + + // Custom retry conditions + retryConditions: { + networkErrors: ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'ENOTFOUND', 'ENETUNREACH', 'EHOSTUNREACH'], + httpStatusCodes: [408, 429, 500, 502, 503, 504, 507, 520, 521, 522, 523, 524], + sslErrors: ['EPROTO', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'CERT_HAS_EXPIRED'], + // Banking-specific OFX codes that should NOT be retried + nonRetryableOFXCodes: [ + '15500', // Invalid credentials + '15501', // Account in use + '15502', // Invalid user ID + '15503', // Invalid password + '15505', // Password expired + '10500', // Invalid account number + '10401' // Account restricted + ] + } +}); + +console.log('Advanced Retry Configuration Applied:', JSON.stringify(advancedRetryConfig, null, 2)); +console.log(); + +// Example 3: Custom Timeout Configuration +console.log('3. Custom Timeout Configuration'); +console.log('-------------------------------'); + +const customTimeouts = Banking.configureTimeouts({ + quick: { + connection: 3000, // 3 seconds + request: 10000, // 10 seconds + socket: 5000 // 5 seconds + }, + standard: { + connection: 8000, // 8 seconds + request: 45000, // 45 seconds + socket: 20000 // 20 seconds + }, + heavy: { + connection: 12000, // 12 seconds + request: 300000, // 5 minutes + socket: 120000 // 2 minutes + } +}); + +console.log('Custom Timeouts Applied:', JSON.stringify(customTimeouts, null, 2)); +console.log(); + +// Example 4: Creating Banking Instance with Configuration +console.log('4. Banking Instance with Custom Configuration'); +console.log('--------------------------------------------'); + +// Wells Fargo example configuration +const wellsFargo = new Banking({ + fid: '3000', + fidOrg: 'WF', + url: 'https://www.oasis.cfree.com/3001.ofxgp', + bankId: '123456789', + user: 'your-username', + password: 'your-password', + accId: '987654321', + accType: 'CHECKING', + + // Instance-specific timeout configuration + timeoutConfig: { + operationType: 'standard', // Default operation type for this instance + customTimeouts: { + connection: 8000, + request: 45000, + socket: 25000 + } + }, + + // Instance-specific retry configuration + retryConfig: { + maxRetries: 4, + baseDelay: 1500, + backoffStrategy: 'exponential' + } +}); + +console.log('Wells Fargo Banking instance created with custom configuration'); +console.log(); + +// Example 5: Monitoring Metrics +console.log('5. Monitoring and Metrics'); +console.log('-------------------------'); + +// Function to demonstrate metrics collection +async function demonstrateMetrics() { + console.log('Initial Metrics:'); + const initialPoolMetrics = Banking.getPoolMetrics(); + const initialRetryMetrics = Banking.getRetryMetrics(); + + console.log('Pool Metrics:', JSON.stringify(initialPoolMetrics, null, 2)); + console.log('Retry Metrics:', JSON.stringify(initialRetryMetrics, null, 2)); + + // Example of making a request (would normally be to actual bank) + console.log('\nNote: In a real application, you would make banking requests here'); + console.log('and monitor the metrics to understand retry patterns and performance.\n'); + + // Simulate some metrics for demonstration + console.log('After banking operations, you might see metrics like:'); + console.log({ + poolMetrics: { + totalRequests: 15, + activeConnections: 0, + poolHits: 12, + poolMisses: 3, + errors: 2, + retries: 3, + timeouts: 1, + averageResponseTime: 2340, + operationTypes: { + quick: 8, + standard: 6, + heavy: 1 + } + }, + retryMetrics: { + totalAttempts: 18, + successfulRetries: 3, + failedRetries: 0, + timeouts: 1, + networkErrors: 2, + httpErrors: 1, + sslErrors: 0, + ofxErrors: 0, + averageAttempts: 1.2, + retrySuccessRate: 1.0, + averageDelay: 1850 + } + }); +} + +// Example 6: Error Handling Strategies +console.log('6. Error Handling Best Practices'); +console.log('--------------------------------'); + +function handleBankingErrors(error) { + console.log('Error handling strategy based on error type:'); + + if (error.code === 'ETIMEDOUT') { + console.log('- Timeout Error: Consider increasing timeout for this operation type'); + console.log('- Or check if server is experiencing high load'); + } else if (error.code === 'ECONNRESET') { + console.log('- Connection Reset: Network issue, likely to succeed on retry'); + } else if (error.statusCode === 429) { + console.log('- Rate Limited: Back off requests, respect Retry-After header'); + } else if (error.statusCode >= 500) { + console.log('- Server Error: Likely transient, good candidate for retry'); + } else if (error.ofxCode === '15500') { + console.log('- OFX Invalid Credentials: Do not retry, fix authentication'); + } else if (error.ofxCode === '10500') { + console.log('- OFX Invalid Account: Do not retry, check account number'); + } else { + console.log('- Unknown Error: Log for analysis, may not be retryable'); + } + + return error; +} + +// Example 7: Production Configuration Recommendations +console.log('7. Production Configuration Recommendations'); +console.log('------------------------------------------'); + +const productionConfig = { + // Conservative connection pooling for production + pool: { + maxSockets: 3, // Limit concurrent connections + maxFreeSockets: 1, // Conservative keep-alive + keepAlive: true, + keepAliveMsecs: 60000 // 1 minute keep-alive + }, + + // Production timeouts - longer to accommodate bank server variance + timeouts: { + quick: { + connection: 10000, // 10 seconds + request: 30000, // 30 seconds + socket: 20000 // 20 seconds + }, + standard: { + connection: 15000, // 15 seconds + request: 120000, // 2 minutes + socket: 60000 // 1 minute + }, + heavy: { + connection: 20000, // 20 seconds + request: 300000, // 5 minutes + socket: 180000 // 3 minutes + } + }, + + // Conservative retry policy + retry: { + maxRetries: { + quick: 2, + standard: 3, + heavy: 1 // Heavy operations get fewer retries + }, + baseDelay: 2000, // 2 second base delay + maxDelay: 60000, // 1 minute max delay + backoffStrategy: 'exponential', + + jitter: { + enabled: true, + type: 'equal', // Balanced jitter + factor: 0.1 + }, + + // Respectful rate limiting + rateLimiting: { + enabled: true, + maxConcurrent: 2, // Very conservative + requestInterval: 1000 // 1 second between requests + } + } +}; + +console.log('Production Configuration:', JSON.stringify(productionConfig, null, 2)); +console.log(); + +console.log('8. Clean Up'); +console.log('-----------'); + +// Always clean up resources when shutting down +process.on('SIGINT', () => { + console.log('Shutting down gracefully...'); + Banking.destroyPool(); + process.exit(0); +}); + +console.log('Banking.js timeout and retry configuration examples completed.'); +console.log('Use Ctrl+C to exit and clean up resources.'); + +// Run the metrics demonstration +demonstrateMetrics(); diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..88e5500 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,1173 @@ +// Type definitions for banking.js v1.2.0 +// Project: https://github.com/euforic/banking.js +// Definitions by: Claude +// TypeScript Version: 4.0+ + +/** + * Represents a monetary amount with precise decimal handling for financial calculations + * Should be used for all monetary values to ensure accuracy + */ +export type MonetaryAmount = string; + +/** + * OFX date format: YYYYMMDDHHMMSS or YYYYMMDD + * Examples: '20240101', '20240101120000' + */ +export type OFXDate = string; + +/** + * Standard account types supported by OFX specification + */ +export type AccountType = 'CHECKING' | 'SAVINGS' | 'MONEYMRKT' | 'CREDITCARD' | 'INVESTMENT'; + +/** + * Transaction types as defined by OFX specification + */ +export type TransactionType = + | 'CREDIT' // Credit/deposit transaction + | 'DEBIT' // Debit/withdrawal transaction + | 'DIRECTDEBIT' // Direct debit/ACH withdrawal + | 'DIRECTDEP' // Direct deposit/ACH credit + | 'CHECK' // Check transaction + | 'FEE' // Bank fee + | 'DEP' // Deposit + | 'ATM' // ATM transaction + | 'POS' // Point of sale transaction + | 'XFER' // Transfer + | 'PAYMENT' // Payment + | 'CASH' // Cash transaction + | 'DIVIDEND' // Dividend payment + | 'INTEREST' // Interest payment + | 'OTHER'; // Other transaction type + +/** + * Standard HTTP headers used in OFX requests + */ +export type OFXHeader = 'Host' | 'Accept' | 'User-Agent' | 'Content-Type' | 'Content-Length' | 'Connection'; + +/** + * Connection pool configuration options for optimized banking operations + */ +export interface ConnectionPoolConfig { + /** Maximum concurrent connections per host (default: 5) */ + maxSockets?: number; + + /** Maximum idle connections to keep alive (default: 2) */ + maxFreeSockets?: number; + + /** Enable persistent connections (default: true) */ + keepAlive?: boolean; + + /** Keep-alive timeout in milliseconds (default: 30000) */ + keepAliveMsecs?: number; + + /** Request timeout in milliseconds (default: 60000) */ + timeout?: number; + + /** SSL/TLS protocol version (default: 'TLSv1_2_method') */ + secureProtocol?: string; + + /** Verify SSL certificates (default: true) */ + rejectUnauthorized?: boolean; + + /** Verify server hostname (default: true) */ + checkServerIdentity?: boolean; + + /** Maximum retry attempts on failure (default: 3) */ + maxRetries?: number; + + /** Delay between retries in milliseconds (default: 1000) */ + retryDelay?: number; + + /** Enable connection pool metrics collection (default: true) */ + enableMetrics?: boolean; + + /** Metrics reporting interval in milliseconds (default: 60000) */ + metricsInterval?: number; +} + +/** + * Connection pool metrics and statistics + */ +export interface PoolMetrics { + /** Total number of requests made */ + totalRequests: number; + + /** Currently active connections */ + activeConnections: number; + + /** Number of times existing connections were reused */ + poolHits: number; + + /** Number of times new connections were created */ + poolMisses: number; + + /** Total number of errors encountered */ + errors: number; + + /** Total number of retries performed */ + retries: number; + + /** Average response time in milliseconds */ + averageResponseTime: number; + + /** Recent response times array */ + requestTimes: number[]; + + /** Per-agent connection statistics */ + poolStats: { + [agentKey: string]: { + sockets: number; + freeSockets: number; + requests: number; + }; + }; + + /** Total number of HTTP agents */ + agentCount: number; +} + +/** + * Cache configuration options for banking operations + */ +export interface CacheConfig { + /** Enable/disable caching globally (default: true) */ + enabled?: boolean; + + /** Maximum number of cache entries (default: 1000) */ + maxSize?: number; + + /** Default TTL in milliseconds (default: 300000 - 5 minutes) */ + defaultTTL?: number; + + /** Cache cleanup interval in milliseconds (default: 300000 - 5 minutes) */ + cleanupInterval?: number; + + /** Operation-specific TTL and configuration */ + operationTTL?: { + /** Account information caching */ + accounts?: CacheOperationConfig; + + /** Balance information caching */ + balance?: CacheOperationConfig; + + /** Transaction statement caching */ + statement?: CacheStatementConfig; + + /** Institution metadata caching */ + institution?: CacheOperationConfig; + + /** Authentication/session caching */ + auth?: CacheOperationConfig; + }; + + /** Security settings for PCI compliance */ + security?: { + /** Enable encryption for sensitive cached data (default: true) */ + encryptSensitiveData?: boolean; + + /** Encryption key (auto-generated if not provided) */ + encryptionKey?: Buffer | null; + + /** Fields that should never be cached in plain text */ + sensitiveFields?: string[]; + + /** Enable secure key generation with salts (default: true) */ + useSecureKeys?: boolean; + + /** Salt for key generation (auto-generated if not provided) */ + salt?: Buffer | null; + }; + + /** Cache storage configuration */ + storage?: { + /** Storage type: 'memory', 'redis', 'file' (default: 'memory') */ + type?: 'memory' | 'redis' | 'file'; + + /** Storage-specific options */ + options?: { + memory?: { + /** Use WeakRef for memory-sensitive environments */ + useWeakRef?: boolean; + }; + redis?: { + host?: string; + port?: number; + db?: number; + keyPrefix?: string; + }; + }; + }; + + /** Cache warming configuration */ + warming?: { + /** Enable cache warming (default: false) */ + enabled?: boolean; + + /** Preload frequently accessed data */ + preloadAccounts?: boolean; + preloadRecentStatements?: boolean; + + /** Warming schedule (cron-like format) */ + schedule?: { + accounts?: string; + statements?: string; + }; + }; + + /** Monitoring and metrics configuration */ + metrics?: { + /** Enable metrics collection (default: true) */ + enabled?: boolean; + + /** Track cache hit rate (default: true) */ + trackHitRate?: boolean; + + /** Track response time (default: true) */ + trackResponseTime?: boolean; + + /** Track memory usage (default: true) */ + trackMemoryUsage?: boolean; + + /** Metrics collection interval in milliseconds (default: 60000) */ + metricsInterval?: number; + }; +} + +/** + * Cache operation configuration + */ +export interface CacheOperationConfig { + /** TTL in milliseconds */ + ttl: number; + + /** Enable caching for this operation (default: true) */ + enabled?: boolean; + + /** Maximum entries for this operation type */ + maxEntries?: number; +} + +/** + * Statement cache configuration with dynamic TTL + */ +export interface CacheStatementConfig extends CacheOperationConfig { + /** Dynamic TTL based on query characteristics */ + dynamicTTL?: { + /** Historical data TTL (older than 30 days) */ + historical?: number; + + /** Recent data TTL (last 30 days) */ + recent?: number; + + /** Real-time data TTL (today) */ + realtime?: number; + }; +} + +/** + * Cache metrics and performance statistics + */ +export interface CacheMetrics { + /** Request statistics */ + requests: { + /** Cache hits */ + hits: number; + + /** Cache misses */ + misses: number; + + /** Cache sets */ + sets: number; + + /** Cache invalidations */ + invalidations: number; + + /** Cache clears */ + clears: number; + + /** Requests when caching was disabled */ + disabled: number; + }; + + /** Performance metrics */ + performance: { + /** Cache hit rate percentage */ + hitRate: number; + + /** Average response time for all operations */ + averageResponseTime: number; + + /** Average response time for cache hits */ + averageHitResponseTime: number; + + /** Average response time for cache sets */ + averageSetResponseTime: number; + }; + + /** Cache storage metrics */ + cache: { + /** Current cache size */ + size: number; + + /** Maximum cache size */ + maxSize: number; + + /** Cache utilization percentage */ + utilizationPercent: number; + }; + + /** Error statistics */ + errors: { + /** Errors during cache get operations */ + get: number; + + /** Errors during cache set operations */ + set: number; + + /** Errors during cache invalidation */ + invalidate: number; + + /** Errors during cache clear operations */ + clear: number; + }; + + /** Runtime metrics */ + uptime: number; + lastCleanup: number; + + /** Configuration snapshot */ + config: { + enabled: boolean; + operationTTL: Record; + }; +} + +/** + * Banking constructor configuration options + */ +export interface BankingConfig { + /** Financial Institution ID - uniquely identifies the bank */ + fid: number; + + /** Financial Institution Organization name */ + fidOrg?: string; + + /** OFX server URL endpoint */ + url: string; + + /** Bank routing number (required for bank accounts, not for credit cards) */ + bankId?: string; + + /** Username for bank login */ + user: string; + + /** Password for bank login */ + password: string; + + /** Account ID/number */ + accId: string; + + /** Broker ID (required for investment accounts) */ + brokerId?: string; + + /** Account type */ + accType: AccountType; + + /** Client ID for the application (optional) */ + clientId?: string; + + /** Application version (default: '1700') */ + appVer?: string; + + /** OFX version to use (default: '102') */ + ofxVer?: string; + + /** Application identifier (default: 'QWIN') */ + app?: string; + + /** User-Agent header (default: 'banking-js') */ + 'User-Agent'?: string; + + /** Content-Type header (default: 'application/x-ofx') */ + 'Content-Type'?: string; + + /** Accept header (default: 'application/ofx') */ + Accept?: string; + + /** Connection header (default: 'Close') */ + Connection?: string; + + /** Ordered list of HTTP headers to include in requests */ + headers?: OFXHeader[]; + + /** Enable connection pooling for improved performance (default: true) */ + usePooling?: boolean; +} + +/** + * Date range for statement requests + */ +export interface DateRange { + /** Start date in YYYYMMDD or YYYYMMDDHHMMSS format */ + start: number | OFXDate; + + /** End date in YYYYMMDD or YYYYMMDDHHMMSS format (optional) */ + end?: number | OFXDate; +} + +/** + * Status information from OFX response + */ +export interface OFXStatus { + /** Status code (0 = success) */ + CODE: string; + + /** Severity level */ + SEVERITY: 'INFO' | 'WARN' | 'ERROR'; + + /** Status message (optional) */ + MESSAGE?: string; +} + +/** + * Financial Institution information + */ +export interface FinancialInstitution { + /** Organization name */ + ORG: string; + + /** Financial Institution ID */ + FID: string; +} + +/** + * Sign-on response structure + */ +export interface SignOnResponse { + /** Response status */ + STATUS: OFXStatus; + + /** Server date/time */ + DTSERVER: string; + + /** Language code */ + LANGUAGE: string; + + /** Profile last update date (optional) */ + DTPROFUP?: string; + + /** Financial institution info */ + FI: FinancialInstitution; + + /** Intuit-specific fields (optional) */ + 'INTU.BID'?: string; + 'INTU.USERID'?: string; +} + +/** + * Bank account identification + */ +export interface BankAccount { + /** Bank routing number */ + BANKID: string; + + /** Account number */ + ACCTID: string; + + /** Account type */ + ACCTTYPE: AccountType; +} + +/** + * Credit card account identification + */ +export interface CreditCardAccount { + /** Account number */ + ACCTID: string; +} + +/** + * Investment account identification + */ +export interface InvestmentAccount { + /** Broker ID */ + BROKERID: string; + + /** Account number */ + ACCTID: string; +} + +/** + * Individual transaction details + */ +export interface Transaction { + /** Transaction type */ + TRNTYPE: TransactionType; + + /** Date transaction was posted */ + DTPOSTED: string; + + /** Date funds are available (optional) */ + DTAVAIL?: string; + + /** Transaction amount (negative for debits, positive for credits) */ + TRNAMT: MonetaryAmount; + + /** Financial Institution Transaction ID */ + FITID: string; + + /** Check number (for check transactions) */ + CHECKNUM?: string; + + /** Transaction description/name */ + NAME?: string; + + /** Transaction memo/notes */ + MEMO?: string; + + /** Standard Industrial Classification code (optional) */ + SIC?: string; +} + +/** + * Balance information + */ +export interface Balance { + /** Balance amount */ + BALAMT: MonetaryAmount; + + /** Date/time of balance */ + DTASOF: string; +} + +/** + * Transaction list container + */ +export interface TransactionList { + /** Start date of transaction range */ + DTSTART: string; + + /** End date of transaction range */ + DTEND: string; + + /** Array of transactions (can be single transaction or array) */ + STMTTRN: Transaction | Transaction[]; +} + +/** + * Bank statement response + */ +export interface BankStatementResponse { + /** Default currency */ + CURDEF: string; + + /** Account information */ + BANKACCTFROM: BankAccount; + + /** Transaction list */ + BANKTRANLIST: TransactionList; + + /** Ledger balance */ + LEDGERBAL: Balance; + + /** Available balance */ + AVAILBAL: Balance; +} + +/** + * Credit card statement response + */ +export interface CreditCardStatementResponse { + /** Default currency */ + CURDEF: string; + + /** Account information */ + CCACCTFROM: CreditCardAccount; + + /** Transaction list */ + BANKTRANLIST: TransactionList; + + /** Ledger balance */ + LEDGERBAL: Balance; + + /** Available balance */ + AVAILBAL: Balance; +} + +/** + * Statement transaction response wrapper + */ +export interface StatementTransactionResponse { + /** Transaction unique ID */ + TRNUID: string; + + /** Response status */ + STATUS: OFXStatus; + + /** Client cookie (optional) */ + CLTCOOKIE?: string; + + /** Statement response data */ + STMTRS: BankStatementResponse; +} + +/** + * Credit card statement transaction response wrapper + */ +export interface CreditCardTransactionResponse { + /** Transaction unique ID */ + TRNUID: string; + + /** Response status */ + STATUS: OFXStatus; + + /** Client cookie (optional) */ + CLTCOOKIE?: string; + + /** Statement response data */ + CCSTMTRS: CreditCardStatementResponse; +} + +/** + * Bank messages response container + */ +export interface BankMessagesResponse { + /** Statement transaction response */ + STMTTRNRS: StatementTransactionResponse; +} + +/** + * Credit card messages response container + */ +export interface CreditCardMessagesResponse { + /** Credit card statement transaction response */ + CCSTMTTRNRS: CreditCardTransactionResponse; +} + +/** + * Account information for account list response + */ +export interface AccountInfo { + /** Bank account info (for bank accounts) */ + BANKACCTINFO?: { + BANKACCTFROM: BankAccount; + SVCSTATUS: string; + XFERSRC?: 'Y' | 'N'; + XFERDEST?: 'Y' | 'N'; + SUPTXDL?: 'Y' | 'N'; + }; + + /** Credit card account info (for credit cards) */ + CCACCTINFO?: { + CCACCTFROM: CreditCardAccount; + SVCSTATUS: string; + XFERSRC?: 'Y' | 'N'; + XFERDEST?: 'Y' | 'N'; + SUPTXDL?: 'Y' | 'N'; + }; + + /** Investment account info (for investment accounts) */ + INVACCTINFO?: { + INVACCTFROM: InvestmentAccount; + SVCSTATUS: string; + XFERSRC?: 'Y' | 'N'; + XFERDEST?: 'Y' | 'N'; + SUPTXDL?: 'Y' | 'N'; + }; +} + +/** + * Account information response + */ +export interface AccountInfoResponse { + /** Date account info was last updated */ + DTACCTUP: string; + + /** Account information (can be single account or array) */ + ACCTINFO: AccountInfo | AccountInfo[]; +} + +/** + * Account list transaction response + */ +export interface AccountListTransactionResponse { + /** Transaction unique ID */ + TRNUID: string; + + /** Response status */ + STATUS: OFXStatus; + + /** Account information response */ + ACCTINFORS: AccountInfoResponse; +} + +/** + * Sign-up messages response (used for account lists) + */ +export interface SignUpMessagesResponse { + /** Account information transaction response */ + ACCTINFOTRNRS: AccountListTransactionResponse; +} + +/** + * Complete OFX response body structure + */ +export interface OFXResponseBody { + OFX: { + /** Sign-on message response */ + SIGNONMSGSRSV1: { + SONRS: SignOnResponse; + }; + + /** Bank messages response (for statements) */ + BANKMSGSRSV1?: BankMessagesResponse; + + /** Credit card messages response (for credit card statements) */ + CREDITCARDMSGSRSV1?: CreditCardMessagesResponse; + + /** Sign-up messages response (for account lists) */ + SIGNUPMSGSRSV1?: SignUpMessagesResponse; + }; +} + +/** + * OFX response header information + */ +export interface OFXResponseHeaders { + /** OFX header version */ + OFXHEADER?: string; + + /** Data type */ + DATA?: string; + + /** OFX version */ + VERSION?: string; + + /** Security level */ + SECURITY?: string; + + /** Text encoding */ + ENCODING?: string; + + /** Character set */ + CHARSET?: string; + + /** Compression type */ + COMPRESSION?: string; + + /** Old file UID */ + OLDFILEUID?: string; + + /** New file UID */ + NEWFILEUID?: string; + + /** HTTP headers (optional) */ + [key: string]: string | undefined; +} + +/** + * Complete parsed OFX response + */ +export interface OFXResponse { + /** Response headers */ + header: OFXResponseHeaders; + + /** Parsed response body */ + body: OFXResponseBody; + + /** Raw XML string */ + xml: string; +} + +/** + * Base error classification categories + */ +export type ErrorCategory = + | 'NETWORK' + | 'AUTHENTICATION' + | 'BANKING_BUSINESS' + | 'OFX_PROTOCOL' + | 'RATE_LIMIT' + | 'CONFIGURATION' + | 'DATA' + | 'UNKNOWN'; + +/** + * Banking context for error classification (PCI-compliant - no sensitive data) + */ +export interface BankingContext { + /** Financial Institution ID */ + fid?: number | null; + /** Financial Institution Organization name */ + fidOrg?: string | null; + /** Type of operation being performed */ + operationType?: string | null; + /** Account type */ + accountType?: AccountType | null; + /** Sanitized URL (no credentials or sensitive parameters) */ + url?: string | null; +} + +/** + * Technical details for debugging (non-sensitive) + */ +export interface TechnicalDetails { + /** Original Node.js or HTTP error */ + originalError?: Error | null; + /** HTTP status code */ + httpStatus?: number | null; + /** OFX status information */ + ofxStatus?: OFXStatus | null; + /** Request correlation ID */ + requestId?: string | null; + /** User agent string */ + userAgent?: string | null; + /** OFX version used */ + ofxVersion?: string | null; +} + +/** + * Base banking error class with structured information and correlation tracking + */ +export declare class BankingError extends Error { + /** Error code for programmatic identification */ + readonly code: string; + /** Unique correlation ID for tracking across requests */ + readonly correlationId: string; + /** ISO timestamp when error occurred */ + readonly timestamp: string; + /** Error category for classification */ + readonly category: ErrorCategory; + /** Whether this error can be retried */ + readonly retryable: boolean; + /** Recommended retry delay in seconds */ + readonly retryAfter: number | null; + /** Maximum recommended retry attempts */ + readonly maxRetries: number; + /** Banking context information (PCI-compliant) */ + readonly bankingContext: BankingContext; + /** Technical details for debugging */ + readonly technicalDetails: TechnicalDetails; + /** Actionable recommendations for resolution */ + readonly recommendations: string[]; + /** Additional metadata */ + readonly metadata: Record; + + constructor( + message: string, + options?: { + code?: string; + correlationId?: string; + timestamp?: string; + category?: ErrorCategory; + retryable?: boolean; + retryAfter?: number | null; + maxRetries?: number; + fid?: number; + fidOrg?: string; + operationType?: string; + accountType?: AccountType; + url?: string; + originalError?: Error; + httpStatus?: number; + ofxStatus?: OFXStatus; + requestId?: string; + userAgent?: string; + ofxVersion?: string; + recommendations?: string[]; + metadata?: Record; + } + ); + + /** Get PCI-compliant log representation */ + toLogObject(): Record; + /** JSON serialization */ + toJSON(): Record; +} + +/** + * Network-related errors + */ +export declare class NetworkError extends BankingError {} +export declare class ConnectionError extends NetworkError {} +export declare class TimeoutError extends NetworkError {} +export declare class DNSError extends NetworkError {} +export declare class CertificateError extends NetworkError {} + +/** + * Authentication and authorization errors + */ +export declare class AuthenticationError extends BankingError {} +export declare class InvalidCredentialsError extends AuthenticationError {} +export declare class ExpiredSessionError extends AuthenticationError {} +export declare class InsufficientPermissionsError extends AuthenticationError {} + +/** + * Banking-specific business logic errors + */ +export declare class BankingBusinessError extends BankingError {} +export declare class AccountNotFoundError extends BankingBusinessError {} +export declare class InsufficientFundsError extends BankingBusinessError {} +export declare class MaintenanceModeError extends BankingBusinessError {} +export declare class DailyLimitExceededError extends BankingBusinessError {} + +/** + * OFX protocol-specific errors + */ +export declare class OFXProtocolError extends BankingError {} +export declare class MalformedResponseError extends OFXProtocolError {} +export declare class VersionMismatchError extends OFXProtocolError {} +export declare class InvalidOFXHeaderError extends OFXProtocolError {} + +/** + * Rate limiting and throttling errors + */ +export declare class RateLimitError extends BankingError {} +export declare class TooManyRequestsError extends RateLimitError {} + +/** + * Configuration and setup errors + */ +export declare class ConfigurationError extends BankingError {} +export declare class InvalidConfigurationError extends ConfigurationError {} +export declare class MissingParameterError extends ConfigurationError {} + +/** + * Data validation and parsing errors + */ +export declare class DataError extends BankingError {} +export declare class InvalidDateRangeError extends DataError {} +export declare class DataParsingError extends DataError {} + +/** + * Error factory function + */ +export declare function createBankingError( + errorInfo: { + code?: string; + message: string; + httpStatus?: number; + originalError?: Error; + }, + options?: Record +): BankingError; + +/** + * Union type of all possible banking errors for callback functions + */ +export type BankingErrorType = + | BankingError + | NetworkError + | ConnectionError + | TimeoutError + | DNSError + | CertificateError + | AuthenticationError + | InvalidCredentialsError + | ExpiredSessionError + | InsufficientPermissionsError + | BankingBusinessError + | AccountNotFoundError + | InsufficientFundsError + | MaintenanceModeError + | DailyLimitExceededError + | OFXProtocolError + | MalformedResponseError + | VersionMismatchError + | InvalidOFXHeaderError + | RateLimitError + | TooManyRequestsError + | ConfigurationError + | InvalidConfigurationError + | MissingParameterError + | DataError + | InvalidDateRangeError + | DataParsingError + | false + | null; + +/** + * Callback function type for statement operations + */ +export type StatementCallback = (error: BankingErrorType, response: OFXResponse) => void; + +/** + * Callback function type for account list operations + */ +export type AccountListCallback = (error: BankingErrorType, response: OFXResponse) => void; + +/** + * Callback function type for parsing operations + */ +export type ParseCallback = (response: OFXResponse) => void; + +/** + * Main Banking class + */ +declare class Banking { + /** Library version */ + static readonly version: string; + + /** Banking instance options */ + readonly opts: Required; + + /** + * Creates a new Banking instance + * @param config Configuration options for the banking connection + */ + constructor(config: BankingConfig); + + /** + * Retrieve bank statements for the specified date range + * @param dateRange Date range for the statement request + * @param callback Callback function to handle the response + */ + getStatement(dateRange: DateRange, callback: StatementCallback): void; + + /** + * Get a list of accounts from the OFX server + * @param callback Callback function to handle the response + */ + getAccounts(callback: AccountListCallback): void; + + /** + * Parse an OFX file from filesystem + * @param filePath Path to the OFX file + * @param callback Callback function to handle the parsed response + */ + static parseFile(filePath: string, callback: ParseCallback): void; + + /** + * Parse an OFX string directly + * @param ofxString OFX data as string + * @param callback Callback function to handle the parsed response + */ + static parse(ofxString: string, callback: ParseCallback): void; + + /** + * Configure connection pooling settings for all banking operations + * @param config Connection pool configuration options + * @returns Applied pool configuration + */ + static configurePool(config?: ConnectionPoolConfig): ConnectionPoolConfig; + + /** + * Get current connection pool metrics and statistics + * @returns Pool metrics or null if pooling is not enabled + */ + static getPoolMetrics(): PoolMetrics | null; + + /** + * Configure caching for banking operations + * @param config Cache configuration options + * @returns Applied cache configuration + */ + static configureCache(config?: CacheConfig): CacheConfig; + + /** + * Get cache metrics and statistics + * @returns Cache metrics or null if caching is not enabled + */ + static getCacheMetrics(): CacheMetrics | null; + + /** + * Reset cache metrics (useful for testing or monitoring) + */ + static resetCacheMetrics(): void; + + /** + * Clear all cached data + * @returns Number of entries cleared + */ + static clearCache(): number; + + /** + * Invalidate cache entries for specific operation + * @param operation Operation type to invalidate (accounts, statement, etc.) + * @param params Specific parameters to invalidate (optional) + * @returns Number of entries invalidated + */ + static invalidateCache(operation: string, params?: object): number; + + /** + * Destroy the connection pool and clean up all resources + * Call this when shutting down your application + */ + static destroyPool(): void; +} + +/** + * Banking constructor function (alternative to new Banking()) + * @param config Configuration options for the banking connection + * @returns Banking instance + */ +declare function Banking(config: BankingConfig): Banking; + +// Module exports +declare namespace Banking { + export { + BankingConfig, + ConnectionPoolConfig, + PoolMetrics, + CacheConfig, + CacheOperationConfig, + CacheStatementConfig, + CacheMetrics, + DateRange, + OFXResponse, + OFXResponseBody, + OFXResponseHeaders, + Transaction, + Balance, + TransactionList, + BankAccount, + CreditCardAccount, + InvestmentAccount, + AccountInfo, + AccountType, + TransactionType, + MonetaryAmount, + OFXDate, + OFXHeader, + StatementCallback, + AccountListCallback, + ParseCallback, + // Error types and interfaces + ErrorCategory, + BankingContext, + TechnicalDetails, + BankingErrorType, + // Error classes + BankingError, + NetworkError, + ConnectionError, + TimeoutError, + DNSError, + CertificateError, + AuthenticationError, + InvalidCredentialsError, + ExpiredSessionError, + InsufficientPermissionsError, + BankingBusinessError, + AccountNotFoundError, + InsufficientFundsError, + MaintenanceModeError, + DailyLimitExceededError, + OFXProtocolError, + MalformedResponseError, + VersionMismatchError, + InvalidOFXHeaderError, + RateLimitError, + TooManyRequestsError, + ConfigurationError, + InvalidConfigurationError, + MissingParameterError, + DataError, + InvalidDateRangeError, + DataParsingError, + createBankingError + }; +} + +export = Banking; diff --git a/index.js b/index.js index 0c686f9..16950f2 100644 --- a/index.js +++ b/index.js @@ -1,2 +1,7 @@ +const Banking = require('./lib/banking'); +const Errors = require('./lib/errors'); -module.exports = require('./lib/banking') +// Attach error classes to the main Banking export for easy access +Object.assign(Banking, Errors); + +module.exports = Banking; diff --git a/lib/banking.js b/lib/banking.js index 8ef60c9..a503ce2 100644 --- a/lib/banking.js +++ b/lib/banking.js @@ -1,4 +1,3 @@ - /*! * banking.js */ @@ -8,12 +7,18 @@ * @type {[type]} */ -var fs = require('fs') - , ofx = require('./ofx') - , pkg = require('../package') - , util = require('./utils') - , debug = require('debug')('banking:main'); - +const fs = require('fs'), + ofx = require('./ofx'), + pkg = require('../package'), + util = require('./utils'), + debug = require('debug')('banking:main'); +const { + createBankingError, + InvalidConfigurationError, + MissingParameterError, + DataParsingError, + InvalidDateRangeError +} = require('./errors'); /** * expose Banking @@ -26,27 +31,67 @@ module.exports = Banking; * @param {[type]} args [description] */ -function Banking(args){ +function Banking(args) { if (!(this instanceof Banking)) return new Banking(args); + + // Validate required parameters + if (!args) { + throw new InvalidConfigurationError('Configuration object is required'); + } + + // Validate required fields + const requiredFields = ['fid', 'url', 'user', 'password', 'accId', 'accType']; + for (const field of requiredFields) { + if (!args[field] && args[field] !== 0) { + throw new MissingParameterError(`Required parameter '${field}' is missing`); + } + } + + // Validate FID format + if (typeof args.fid !== 'number' || args.fid <= 0) { + throw new InvalidConfigurationError('FID must be a positive number'); + } + + // Validate URL format + try { + new URL(args.url); + } catch (e) { + throw new InvalidConfigurationError(`Invalid URL format: ${args.url}`); + } + + // Validate account type + const validAccountTypes = ['CHECKING', 'SAVINGS', 'MONEYMRKT', 'CREDITCARD', 'INVESTMENT']; + if (!validAccountTypes.includes(args.accType)) { + throw new InvalidConfigurationError( + `Invalid account type '${args.accType}'. Must be one of: ${validAccountTypes.join(', ')}` + ); + } + this.opts = { fid: args.fid, fidOrg: args.fidOrg || '', url: args.url, - bankId: args.bankId || '', /* If bank account use your bank routing number otherwise set to null */ + bankId: args.bankId || '' /* If bank account use your bank routing number otherwise set to null */, user: args.user, password: args.password, - accId: args.accId, /* Account Number */ - brokerId: args.brokerId, /* For investment accounts */ + accId: args.accId /* Account Number */, + brokerId: args.brokerId /* For investment accounts */, accType: args.accType, clientId: args.clientId, appVer: args.appVer || '1700', ofxVer: args.ofxVer || '102', app: args.app || 'QWIN', 'User-Agent': args['User-Agent'] || 'banking-js', - 'Content-Type': args['Content-Type'] || 'application/x-ofx' , + 'Content-Type': args['Content-Type'] || 'application/x-ofx', Accept: args.Accept || 'application/ofx', Connection: args.Connection || 'Close', - headers: args.headers || ['Host', 'Accept', 'User-Agent', 'Content-Type', 'Content-Length', 'Connection'] + headers: args.headers || ['Host', 'Accept', 'User-Agent', 'Content-Type', 'Content-Length', 'Connection'], + + // Timeout and retry configuration (can be overridden per instance) + timeoutConfig: args.timeoutConfig || null, + retryConfig: args.retryConfig || null, + operationType: args.operationType || 'standard', // Default operation type + usePooling: args.usePooling !== false // Enable pooling by default }; } @@ -63,12 +108,26 @@ Banking.version = pkg.version; * @return {[type]} [description] */ -Banking.parseFile = function(file, fn) { - fs.readFile(file, 'utf8', function (err, data) { - if (err) throw new Error(err); - ofx.parse(data, function (res){ - fn(res); - }); +Banking.parseFile = function (file, fn) { + fs.readFile(file, 'utf8', (err, data) => { + if (err) { + const parseError = new DataParsingError(`Failed to read OFX file: ${err.message}`, { + originalError: err, + metadata: { filePath: file } + }); + throw parseError; + } + try { + ofx.parse(data, res => { + fn(res); + }); + } catch (parseError) { + const dataError = new DataParsingError(`Failed to parse OFX data: ${parseError.message}`, { + originalError: parseError, + metadata: { filePath: file } + }); + throw dataError; + } }); }; @@ -79,10 +138,127 @@ Banking.parseFile = function(file, fn) { * @return {[type]} [description] */ -Banking.parse = function(str, fn){ - ofx.parse(str, function (res){ - fn(res); - }); +Banking.parse = function (str, fn) { + try { + ofx.parse(str, res => { + fn(res); + }); + } catch (parseError) { + const dataError = new DataParsingError(`Failed to parse OFX string: ${parseError.message}`, { + originalError: parseError + }); + throw dataError; + } +}; + +/** + * Configure connection pooling and retry settings for all banking operations + * @param {object} config - Connection pool and retry configuration options + * @param {object} config.pool - Connection pool specific settings + * @param {object} config.retry - Retry manager specific settings + * @param {object} config.timeouts - Operation-specific timeout configurations + * @returns {object} Applied configuration + */ +Banking.configurePool = function (config) { + return util.configurePool(config); +}; + +/** + * Configure retry policies for banking operations + * @param {object} config - Retry configuration options + * @returns {object} Applied retry configuration + */ +Banking.configureRetry = function (config) { + return util.configureRetry(config); +}; + +/** + * Configure timeout settings for different operation types + * @param {object} timeouts - Timeout configuration by operation type + * @param {object} timeouts.quick - Timeouts for quick operations (account validation, balance checks) + * @param {object} timeouts.standard - Timeouts for standard operations (statement downloads) + * @param {object} timeouts.heavy - Timeouts for heavy operations (large date ranges) + * @returns {object} Applied timeout configuration + */ +Banking.configureTimeouts = function (timeouts) { + return util.configureTimeouts(timeouts); +}; + +/** + * Get current connection pool metrics and statistics + * @returns {object} Pool metrics or null if pooling is not enabled + */ +Banking.getPoolMetrics = function () { + return util.getPoolMetrics(); +}; + +/** + * Get current retry metrics and statistics + * @returns {object} Retry metrics or null if retry manager is not enabled + */ +Banking.getRetryMetrics = function () { + return util.getRetryMetrics(); +}; + +/** + * Reset retry metrics (useful for testing or monitoring) + */ +Banking.resetRetryMetrics = function () { + return util.resetRetryMetrics(); +}; + +/** + * Configure caching for banking operations + * @param {object} config - Cache configuration options + * @param {boolean} config.enabled - Enable/disable caching + * @param {number} config.maxSize - Maximum cache size + * @param {object} config.operationTTL - TTL settings for different operations + * @param {object} config.security - Security settings for PCI compliance + * @returns {object} Applied cache configuration + */ +Banking.configureCache = function (config) { + return util.configureCache(config); +}; + +/** + * Get cache metrics and statistics + * @returns {object} Cache metrics or null if caching is not enabled + */ +Banking.getCacheMetrics = function () { + return util.getCacheMetrics(); +}; + +/** + * Reset cache metrics (useful for testing or monitoring) + */ +Banking.resetCacheMetrics = function () { + return util.resetCacheMetrics(); +}; + +/** + * Clear all cached data + * @returns {number} Number of entries cleared + */ +Banking.clearCache = function () { + return util.clearCache(); +}; + +/** + * Invalidate cache entries for specific operation + * @param {string} operation - Operation type to invalidate (accounts, statement, etc.) + * @param {object} [params] - Specific parameters to invalidate (optional) + * @returns {number} Number of entries invalidated + */ +Banking.invalidateCache = function (operation, params = null) { + return util.invalidateCache(operation, params); +}; + +/** + * Destroy the connection pool and clean up all resources + * Call this when shutting down your application + */ +Banking.destroyPool = function () { + return util.destroyPool(); }; /** @@ -90,16 +266,67 @@ Banking.parse = function(str, fn){ * @param args set start and end date for transaction range * @param fn callback(error, transactions) */ -Banking.prototype.getStatement = function(args, fn) { - var opts = util.mixin(this.opts, args); - var ofxReq = ofx.buildStatementRequest(opts); +Banking.prototype.getStatement = function (args, fn) { + // Validate date range parameters + if (!args || !args.start) { + const error = new InvalidDateRangeError('Start date is required'); + return fn(error, null); + } - util.request(this.opts, ofxReq, function(err, response) { + // Validate date format (YYYYMMDD or YYYYMMDDHHMMSS) + const dateRegex = /^\d{8}(\d{6})?$/; + if (!dateRegex.test(args.start.toString())) { + const error = new InvalidDateRangeError('Start date must be in YYYYMMDD or YYYYMMDDHHMMSS format'); + return fn(error, null); + } + + if (args.end && !dateRegex.test(args.end.toString())) { + const error = new InvalidDateRangeError('End date must be in YYYYMMDD or YYYYMMDDHHMMSS format'); + return fn(error, null); + } + + // Validate that start date is before end date + if (args.end && parseInt(args.start) > parseInt(args.end)) { + const error = new InvalidDateRangeError('Start date must be before end date'); + return fn(error, null); + } + + const opts = util.mixin(this.opts, args); + const ofxReq = ofx.buildStatementRequest(opts); + + // Add operation type hint for proper timeout classification + const operationType = this._classifyStatementOperation(args); + const requestOpts = Object.assign({}, this.opts, { + operationType: operationType, + // Cache configuration + cacheOperation: 'statement', + cacheParams: { + fid: this.opts.fid, + accId: this.opts.accId, + accType: this.opts.accType, + start: args.start, + end: args.end, + operationType: operationType + } + }); + + util.request(requestOpts, ofxReq, (err, response) => { debug('Raw-Response:', response); - if (err) return fn(err, err); - ofx.parse(response, function(ofxObj) { - fn(false, ofxObj); - }); + if (err) return fn(err, null); + + try { + ofx.parse(response, ofxObj => { + fn(false, ofxObj); + }); + } catch (parseError) { + const error = new DataParsingError(`Failed to parse OFX response: ${parseError.message}`, { + originalError: parseError, + operationType: requestOpts.operationType, + fid: this.opts.fid, + fidOrg: this.opts.fidOrg + }); + fn(error, null); + } }); }; @@ -108,14 +335,69 @@ Banking.prototype.getStatement = function(args, fn) { * @param args * @param fn */ -Banking.prototype.getAccounts = function(fn) { - var ofxReq = ofx.buildAccountListRequest(this.opts); +Banking.prototype.getAccounts = function (fn) { + const ofxReq = ofx.buildAccountListRequest(this.opts); + + // Account list requests are typically quick operations + const requestOpts = Object.assign({}, this.opts, { + operationType: 'quick', + // Cache configuration + cacheOperation: 'accounts', + cacheParams: { + fid: this.opts.fid, + user: this.opts.user, // Will be hashed for security + operationType: 'quick' + } + }); - util.request(this.opts, ofxReq, function(err, response) { + util.request(requestOpts, ofxReq, (err, response) => { debug('Raw-Response:', response); - if (err) return fn(err, err); - ofx.parse(response, function(ofxObj) { - fn(false, ofxObj); - }); + if (err) return fn(err, null); + + try { + ofx.parse(response, ofxObj => { + fn(false, ofxObj); + }); + } catch (parseError) { + const error = new DataParsingError(`Failed to parse OFX account list response: ${parseError.message}`, { + originalError: parseError, + operationType: 'quick', + fid: this.opts.fid, + fidOrg: this.opts.fidOrg + }); + fn(error, null); + } }); }; + +/** + * Classify statement operation type based on date range and other parameters + * @param {object} args - Statement request arguments + * @returns {string} Operation type: 'quick', 'standard', or 'heavy' + */ +Banking.prototype._classifyStatementOperation = function (args) { + if (!args.start || !args.end) { + return 'standard'; + } + + // Convert date format (YYYYMMDD) to Date objects + const startStr = args.start.toString(); + const endStr = args.end.toString(); + + const startDate = new Date( + startStr.substring(0, 4), + parseInt(startStr.substring(4, 6)) - 1, + startStr.substring(6, 8) + ); + const endDate = new Date(endStr.substring(0, 4), parseInt(endStr.substring(4, 6)) - 1, endStr.substring(6, 8)); + + const daysDiff = (endDate - startDate) / (1000 * 60 * 60 * 24); + + if (daysDiff <= 30) { + return 'quick'; // 30 days or less + } else if (daysDiff <= 365) { + return 'standard'; // Up to 1 year + } else { + return 'heavy'; // More than 1 year + } +}; diff --git a/lib/cache-manager.js b/lib/cache-manager.js new file mode 100644 index 0000000..f32f701 --- /dev/null +++ b/lib/cache-manager.js @@ -0,0 +1,844 @@ +/*! + * Cache Manager for banking.js + * Provides sophisticated caching for banking operations with PCI compliance + */ + +const crypto = require('crypto'); +const debug = require('debug')('banking:cache'); +const { createBankingError, CacheError } = require('./errors'); + +// Constants +const STRINGIFY_SPACE = 2; +const DEFAULT_CLEANUP_INTERVAL = 300000; // 5 minutes +const HASH_ALGORITHM = 'sha256'; +const HASH_ENCODING = 'hex'; +const SENSITIVE_FIELDS = ['password', 'user', 'accId', 'pin', 'ssn', 'credentials']; + +/** + * Default cache configuration optimized for banking operations + */ +const defaultCacheConfig = { + // Global cache settings + enabled: true, + maxSize: 1000, // Maximum number of cache entries + defaultTTL: 300000, // 5 minutes default TTL + cleanupInterval: DEFAULT_CLEANUP_INTERVAL, + + // Operation-specific TTL settings (in milliseconds) + operationTTL: { + // Account information - moderate caching (5-10 minutes) + accounts: { + ttl: 600000, // 10 minutes + enabled: true, + maxEntries: 50 + }, + + // Balance information - short caching (1-2 minutes) + balance: { + ttl: 120000, // 2 minutes + enabled: true, + maxEntries: 100 + }, + + // Transaction statements - smart caching based on date range + statement: { + ttl: 300000, // 5 minutes default + enabled: true, + maxEntries: 200, + // Dynamic TTL based on query characteristics + dynamicTTL: { + // Historical data (older than 30 days) can be cached longer + historical: 3600000, // 1 hour + // Recent data (last 30 days) shorter cache + recent: 300000, // 5 minutes + // Real-time data (today) very short cache + realtime: 60000 // 1 minute + } + }, + + // Institution metadata - long caching (hours/days) + institution: { + ttl: 86400000, // 24 hours + enabled: true, + maxEntries: 20 + }, + + // Authentication/session data - short caching + auth: { + ttl: 900000, // 15 minutes + enabled: true, + maxEntries: 10 + } + }, + + // Security settings for PCI compliance + security: { + // Enable encryption for sensitive cache data + encryptSensitiveData: true, + // Encryption key (should be provided or auto-generated) + encryptionKey: null, + // Fields that should never be cached in plain text + sensitiveFields: SENSITIVE_FIELDS, + // Enable secure key generation with salts + useSecureKeys: true, + // Salt for key generation (auto-generated if not provided) + salt: null + }, + + // Cache storage options + storage: { + // Storage type: 'memory', 'redis', 'file' + type: 'memory', + // Storage-specific options + options: { + // Memory storage options + memory: { + // Use WeakRef for memory-sensitive environments + useWeakRef: false + }, + // Redis options (if using Redis storage) + redis: { + host: 'localhost', + port: 6379, + db: 0, + keyPrefix: 'banking:cache:' + } + } + }, + + // Cache warming configuration + warming: { + enabled: false, + // Preload frequently accessed data + preloadAccounts: true, + preloadRecentStatements: true, + // Warming schedule + schedule: { + accounts: '0 */30 * * * *', // Every 30 minutes + statements: '0 */15 * * * *' // Every 15 minutes + } + }, + + // Monitoring and metrics + metrics: { + enabled: true, + // Track cache performance + trackHitRate: true, + trackResponseTime: true, + trackMemoryUsage: true, + // Metrics collection interval + metricsInterval: 60000 // 1 minute + } +}; + +/** + * LRU Cache implementation with TTL support + */ +class LRUCache { + constructor(maxSize = 1000) { + this.maxSize = maxSize; + this.cache = new Map(); + this.head = null; + this.tail = null; + } + + get(key) { + const node = this.cache.get(key); + if (!node) return null; + + // Check if expired + if (this._isExpired(node)) { + this.delete(key); + return null; + } + + // Move to front (most recently used) + this._moveToFront(node); + return node.value; + } + + set(key, value, ttl) { + const expiresAt = ttl ? Date.now() + ttl : null; + + if (this.cache.has(key)) { + // Update existing node + const node = this.cache.get(key); + node.value = value; + node.expiresAt = expiresAt; + this._moveToFront(node); + } else { + // Create new node + const node = { + key, + value, + expiresAt, + prev: null, + next: null + }; + + this.cache.set(key, node); + this._addToFront(node); + + // Evict if over capacity + if (this.cache.size > this.maxSize) { + this._evictLRU(); + } + } + } + + delete(key) { + const node = this.cache.get(key); + if (!node) return false; + + this.cache.delete(key); + this._removeNode(node); + return true; + } + + clear() { + this.cache.clear(); + this.head = null; + this.tail = null; + } + + size() { + return this.cache.size; + } + + keys() { + return Array.from(this.cache.keys()); + } + + _isExpired(node) { + return node.expiresAt && Date.now() > node.expiresAt; + } + + _moveToFront(node) { + this._removeNode(node); + this._addToFront(node); + } + + _addToFront(node) { + node.prev = null; + node.next = this.head; + + if (this.head) { + this.head.prev = node; + } + this.head = node; + + if (!this.tail) { + this.tail = node; + } + } + + _removeNode(node) { + if (node.prev) { + node.prev.next = node.next; + } else { + this.head = node.next; + } + + if (node.next) { + node.next.prev = node.prev; + } else { + this.tail = node.prev; + } + } + + _evictLRU() { + if (this.tail) { + this.cache.delete(this.tail.key); + this._removeNode(this.tail); + } + } + + cleanup() { + const now = Date.now(); + const expiredKeys = []; + + for (const [key, node] of this.cache) { + if (this._isExpired(node)) { + expiredKeys.push(key); + } + } + + expiredKeys.forEach(key => this.delete(key)); + return expiredKeys.length; + } +} + +/** + * Main Cache Manager class + */ +class CacheManager { + constructor(config = {}) { + this.config = this._mergeConfig(defaultCacheConfig, config); + this.cache = new LRUCache(this.config.maxSize); + this.metrics = this._initializeMetrics(); + + // Initialize security components + this._initializeSecurity(); + + // Start cleanup interval + this._startCleanupInterval(); + + // Start metrics collection + if (this.config.metrics.enabled) { + this._startMetricsCollection(); + } + + debug('Cache manager initialized with config:', JSON.stringify(this.config, null, STRINGIFY_SPACE)); + } + + /** + * Get cached data with automatic TTL handling + * @param {string} operation - Operation type (accounts, statement, etc.) + * @param {object} params - Operation parameters for key generation + * @returns {object|null} Cached data or null if not found/expired + */ + get(operation, params = {}) { + if (!this.config.enabled || !this._isOperationEnabled(operation)) { + this.metrics.requests.disabled++; + return null; + } + + const startTime = Date.now(); + const key = this._generateCacheKey(operation, params); + + try { + const cached = this.cache.get(key); + const duration = Date.now() - startTime; + + if (cached) { + this.metrics.requests.hits++; + this.metrics.performance.totalResponseTime += duration; + this.metrics.performance.hitResponseTime += duration; + + debug(`Cache HIT for ${operation}:${key.substring(0, 16)}... (${duration}ms)`); + + // Decrypt sensitive data if needed + const decrypted = this._decryptIfNeeded(cached); + return decrypted; + } else { + this.metrics.requests.misses++; + this.metrics.performance.totalResponseTime += duration; + debug(`Cache MISS for ${operation}:${key.substring(0, 16)}... (${duration}ms)`); + return null; + } + } catch (error) { + this.metrics.errors.get++; + debug(`Cache GET error for ${operation}:`, error.message); + throw new CacheError(`Failed to get cached data: ${error.message}`, { + originalError: error, + operation, + params: this._sanitizeParams(params) + }); + } + } + + /** + * Set cached data with operation-specific TTL + * @param {string} operation - Operation type + * @param {object} params - Operation parameters for key generation + * @param {any} data - Data to cache + * @param {number} [customTTL] - Custom TTL override + */ + set(operation, params = {}, data, customTTL = null) { + if (!this.config.enabled || !this._isOperationEnabled(operation)) { + this.metrics.requests.disabled++; + return; + } + + const startTime = Date.now(); + const key = this._generateCacheKey(operation, params); + const ttl = customTTL || this._calculateTTL(operation, params); + + try { + // Encrypt sensitive data if needed + const encrypted = this._encryptIfNeeded(data); + + this.cache.set(key, encrypted, ttl); + + const duration = Date.now() - startTime; + this.metrics.requests.sets++; + this.metrics.performance.totalResponseTime += duration; + this.metrics.performance.setResponseTime += duration; + + debug(`Cache SET for ${operation}:${key.substring(0, 16)}... TTL=${ttl}ms (${duration}ms)`); + } catch (error) { + this.metrics.errors.set++; + debug(`Cache SET error for ${operation}:`, error.message); + throw new CacheError(`Failed to set cached data: ${error.message}`, { + originalError: error, + operation, + params: this._sanitizeParams(params) + }); + } + } + + /** + * Invalidate cache entries for specific operation or pattern + * @param {string} operation - Operation type to invalidate + * @param {object} [params] - Specific parameters to invalidate (optional) + */ + invalidate(operation, params = null) { + const startTime = Date.now(); + let invalidated = 0; + + try { + if (params) { + // Invalidate specific entry + const key = this._generateCacheKey(operation, params); + if (this.cache.delete(key)) { + invalidated = 1; + } + } else { + // Invalidate all entries for operation + const prefix = this._generateOperationPrefix(operation); + const keys = this.cache.keys(); + + for (const key of keys) { + if (key.startsWith(prefix)) { + this.cache.delete(key); + invalidated++; + } + } + } + + const duration = Date.now() - startTime; + this.metrics.requests.invalidations += invalidated; + this.metrics.performance.totalResponseTime += duration; + + debug(`Cache INVALIDATE for ${operation}: ${invalidated} entries (${duration}ms)`); + return invalidated; + } catch (error) { + this.metrics.errors.invalidate++; + throw new CacheError(`Failed to invalidate cache: ${error.message}`, { + originalError: error, + operation, + params: params ? this._sanitizeParams(params) : null + }); + } + } + + /** + * Clear all cache entries + */ + clear() { + try { + const size = this.cache.size(); + this.cache.clear(); + this.metrics.requests.clears++; + debug(`Cache CLEAR: ${size} entries removed`); + return size; + } catch (error) { + this.metrics.errors.clear++; + throw new CacheError(`Failed to clear cache: ${error.message}`, { + originalError: error + }); + } + } + + /** + * Get cache statistics and metrics + * @returns {object} Cache metrics and statistics + */ + getMetrics() { + const totalRequests = this.metrics.requests.hits + this.metrics.requests.misses; + const hitRate = totalRequests > 0 ? (this.metrics.requests.hits / totalRequests) * 100 : 0; + + return { + requests: { ...this.metrics.requests }, + performance: { + ...this.metrics.performance, + hitRate: Number(hitRate.toFixed(2)), + averageResponseTime: + totalRequests > 0 ? Number((this.metrics.performance.totalResponseTime / totalRequests).toFixed(2)) : 0, + averageHitResponseTime: + this.metrics.requests.hits > 0 + ? Number((this.metrics.performance.hitResponseTime / this.metrics.requests.hits).toFixed(2)) + : 0, + averageSetResponseTime: + this.metrics.requests.sets > 0 + ? Number((this.metrics.performance.setResponseTime / this.metrics.requests.sets).toFixed(2)) + : 0 + }, + cache: { + size: this.cache.size(), + maxSize: this.config.maxSize, + utilizationPercent: Number(((this.cache.size() / this.config.maxSize) * 100).toFixed(2)) + }, + errors: { ...this.metrics.errors }, + uptime: Date.now() - this.metrics.startTime, + lastCleanup: this.metrics.lastCleanup, + config: { + enabled: this.config.enabled, + operationTTL: this.config.operationTTL + } + }; + } + + /** + * Reset cache metrics + */ + resetMetrics() { + this.metrics = this._initializeMetrics(); + debug('Cache metrics reset'); + } + + /** + * Destroy cache manager and clean up resources + */ + destroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + + if (this.metricsInterval) { + clearInterval(this.metricsInterval); + } + + this.clear(); + debug('Cache manager destroyed'); + } + + // Private methods + + /** + * Generate secure cache key from operation and parameters + * @param {string} operation - Operation type + * @param {object} params - Parameters to include in key + * @returns {string} Secure cache key + */ + _generateCacheKey(operation, params) { + const prefix = this._generateOperationPrefix(operation); + + // Sanitize parameters to remove sensitive data + const sanitized = this._sanitizeParams(params); + + // Create deterministic parameter string + const paramString = this._createParamString(sanitized); + + if (this.config.security.useSecureKeys) { + // Generate secure hash of parameters + const hash = this._hashData(paramString); + return `${prefix}:${hash}`; + } else { + // Simple concatenation (less secure, for development only) + return `${prefix}:${Buffer.from(paramString).toString('base64')}`; + } + } + + /** + * Generate operation prefix for cache keys + * @param {string} operation - Operation type + * @returns {string} Operation prefix + */ + _generateOperationPrefix(operation) { + return `banking:${operation}`; + } + + /** + * Create deterministic parameter string from sanitized parameters + * @param {object} params - Sanitized parameters + * @returns {string} Parameter string + */ + _createParamString(params) { + // Sort keys for deterministic output + const sorted = Object.keys(params) + .sort() + .reduce((acc, key) => { + acc[key] = params[key]; + return acc; + }, {}); + + return JSON.stringify(sorted); + } + + /** + * Remove sensitive data from parameters for key generation + * @param {object} params - Original parameters + * @returns {object} Sanitized parameters + */ + _sanitizeParams(params) { + const sanitized = { ...params }; + + // Remove sensitive fields + for (const field of this.config.security.sensitiveFields) { + if (sanitized[field]) { + // Create hash of sensitive field for key uniqueness without exposing data + sanitized[`${field}_hash`] = this._hashData(sanitized[field].toString()); + delete sanitized[field]; + } + } + + return sanitized; + } + + /** + * Calculate TTL for operation based on configuration and parameters + * @param {string} operation - Operation type + * @param {object} params - Operation parameters + * @returns {number} TTL in milliseconds + */ + _calculateTTL(operation, params) { + const operationConfig = this.config.operationTTL[operation]; + if (!operationConfig) { + return this.config.defaultTTL; + } + + // For statement operations, use dynamic TTL based on date range + if (operation === 'statement' && operationConfig.dynamicTTL && params.start) { + return this._calculateStatementTTL(params, operationConfig.dynamicTTL); + } + + return operationConfig.ttl || this.config.defaultTTL; + } + + /** + * Calculate dynamic TTL for statement operations based on date range + * @param {object} params - Statement parameters + * @param {object} dynamicConfig - Dynamic TTL configuration + * @returns {number} TTL in milliseconds + */ + _calculateStatementTTL(params, dynamicConfig) { + try { + const now = new Date(); + const startDate = this._parseOFXDate(params.start); + const daysDiff = Math.floor((now - startDate) / (1000 * 60 * 60 * 24)); + + if (daysDiff === 0) { + // Today's data - very short cache + return dynamicConfig.realtime; + } else if (daysDiff <= 30) { + // Recent data (last 30 days) - short cache + return dynamicConfig.recent; + } else { + // Historical data - longer cache + return dynamicConfig.historical; + } + } catch (error) { + debug('Error calculating dynamic TTL, using default:', error.message); + return dynamicConfig.recent; // Fallback to recent TTL + } + } + + /** + * Parse OFX date format (YYYYMMDD or YYYYMMDDHHMMSS) + * @param {string} ofxDate - OFX formatted date + * @returns {Date} Parsed date + */ + _parseOFXDate(ofxDate) { + const dateStr = ofxDate.toString(); + const year = parseInt(dateStr.substring(0, 4)); + const month = parseInt(dateStr.substring(4, 6)) - 1; // Month is 0-indexed + const day = parseInt(dateStr.substring(6, 8)); + + if (dateStr.length >= 14) { + // Include time if provided + const hour = parseInt(dateStr.substring(8, 10)); + const minute = parseInt(dateStr.substring(10, 12)); + const second = parseInt(dateStr.substring(12, 14)); + return new Date(year, month, day, hour, minute, second); + } else { + return new Date(year, month, day); + } + } + + /** + * Check if operation caching is enabled + * @param {string} operation - Operation type + * @returns {boolean} Whether caching is enabled for operation + */ + _isOperationEnabled(operation) { + const operationConfig = this.config.operationTTL[operation]; + return operationConfig ? operationConfig.enabled !== false : true; + } + + /** + * Initialize security components + */ + _initializeSecurity() { + // Generate encryption key if not provided + if (this.config.security.encryptSensitiveData && !this.config.security.encryptionKey) { + this.config.security.encryptionKey = crypto.randomBytes(32); + debug('Generated encryption key for sensitive data'); + } + + // Generate salt if not provided + if (this.config.security.useSecureKeys && !this.config.security.salt) { + this.config.security.salt = crypto.randomBytes(16); + debug('Generated salt for secure key generation'); + } + } + + /** + * Hash data using secure algorithm + * @param {string} data - Data to hash + * @returns {string} Hashed data + */ + _hashData(data) { + const hash = crypto.createHash(HASH_ALGORITHM); + + if (this.config.security.salt) { + hash.update(this.config.security.salt); + } + + hash.update(data); + return hash.digest(HASH_ENCODING); + } + + /** + * Encrypt sensitive data if encryption is enabled + * @param {any} data - Data to potentially encrypt + * @returns {any} Encrypted data or original data + */ + _encryptIfNeeded(data) { + if (!this.config.security.encryptSensitiveData || !this.config.security.encryptionKey) { + return data; + } + + try { + // Check if data contains sensitive information + if (this._containsSensitiveData(data)) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipher('aes-256-cbc', this.config.security.encryptionKey); + + let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return { + _encrypted: true, + _iv: iv.toString('hex'), + _data: encrypted + }; + } + + return data; + } catch (error) { + debug('Encryption error:', error.message); + return data; // Return original data if encryption fails + } + } + + /** + * Decrypt data if it was encrypted + * @param {any} data - Data to potentially decrypt + * @returns {any} Decrypted data or original data + */ + _decryptIfNeeded(data) { + if (!data || !data._encrypted || !this.config.security.encryptionKey) { + return data; + } + + try { + const decipher = crypto.createDecipher('aes-256-cbc', this.config.security.encryptionKey); + + let decrypted = decipher.update(data._data, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return JSON.parse(decrypted); + } catch (error) { + debug('Decryption error:', error.message); + return null; // Return null if decryption fails + } + } + + /** + * Check if data contains sensitive information + * @param {any} data - Data to check + * @returns {boolean} Whether data contains sensitive fields + */ + _containsSensitiveData(data) { + if (!data || typeof data !== 'object') return false; + + const jsonStr = JSON.stringify(data).toLowerCase(); + + return this.config.security.sensitiveFields.some(field => jsonStr.includes(field.toLowerCase())); + } + + /** + * Initialize metrics object + * @returns {object} Initial metrics + */ + _initializeMetrics() { + return { + startTime: Date.now(), + lastCleanup: Date.now(), + requests: { + hits: 0, + misses: 0, + sets: 0, + invalidations: 0, + clears: 0, + disabled: 0 + }, + performance: { + totalResponseTime: 0, + hitResponseTime: 0, + setResponseTime: 0 + }, + errors: { + get: 0, + set: 0, + invalidate: 0, + clear: 0 + } + }; + } + + /** + * Start automatic cleanup interval + */ + _startCleanupInterval() { + this.cleanupInterval = setInterval(() => { + try { + const cleaned = this.cache.cleanup(); + this.metrics.lastCleanup = Date.now(); + + if (cleaned > 0) { + debug(`Cache cleanup: removed ${cleaned} expired entries`); + } + } catch (error) { + debug('Cache cleanup error:', error.message); + } + }, this.config.cleanupInterval); + } + + /** + * Start metrics collection interval + */ + _startMetricsCollection() { + this.metricsInterval = setInterval(() => { + const metrics = this.getMetrics(); + debug('Cache metrics:', JSON.stringify(metrics, null, STRINGIFY_SPACE)); + }, this.config.metrics.metricsInterval); + } + + /** + * Merge configuration objects + * @param {object} defaults - Default configuration + * @param {object} custom - Custom configuration + * @returns {object} Merged configuration + */ + _mergeConfig(defaults, custom) { + const merged = JSON.parse(JSON.stringify(defaults)); // Deep clone + + function deepMerge(target, source) { + for (const key in source) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + if (!target[key]) target[key] = {}; + deepMerge(target[key], source[key]); + } else { + target[key] = source[key]; + } + } + } + + deepMerge(merged, custom); + return merged; + } +} + +module.exports = CacheManager; diff --git a/lib/connection-pool.js b/lib/connection-pool.js new file mode 100644 index 0000000..cca65ba --- /dev/null +++ b/lib/connection-pool.js @@ -0,0 +1,714 @@ +/*! + * Connection Pool for banking.js + * Provides HTTP connection pooling and management for OFX requests + */ + +const https = require('https'); +const http = require('http'); +const url = require('url'); +const debug = require('debug')('banking:pool'); +const RetryManager = require('./retry-manager'); +const { createBankingError, TimeoutError, ConnectionError } = require('./errors'); + +// Constants +const STRINGIFY_SPACE = 2; +const HTTPS_PORT = 443; +const HTTP_PORT = 80; +const LARGE_PAYLOAD_SIZE = 50000; // 50KB +const DAYS_PER_YEAR = 365; +const DAYS_PER_MONTH = 30; + +/** + * Default connection pool configuration optimized for banking operations + */ +const defaultPoolConfig = { + // Conservative connection limits for banking compliance + maxSockets: 5, // Maximum concurrent connections per host + maxFreeSockets: 2, // Maximum idle connections to keep alive + + // Keep-alive settings for persistent connections + keepAlive: true, + keepAliveMsecs: 30000, // 30 seconds keep-alive timeout + + // Advanced timeout configuration for different operation types + timeouts: { + quick: { + connection: 5000, // 5 seconds for connection establishment + request: 15000, // 15 seconds total request timeout + socket: 10000, // 10 seconds socket timeout + idle: 30000 // 30 seconds idle timeout + }, + standard: { + connection: 10000, // 10 seconds for connection establishment + request: 60000, // 60 seconds total request timeout + socket: 30000, // 30 seconds socket timeout + idle: 60000 // 60 seconds idle timeout + }, + heavy: { + connection: 15000, // 15 seconds for connection establishment + request: 180000, // 3 minutes total request timeout + socket: 90000, // 90 seconds socket timeout + idle: 120000 // 2 minutes idle timeout + } + }, + + // Default timeout (for backward compatibility) + timeout: 60000, // 60 second total request timeout + + // Legacy retry settings (for backward compatibility) + maxRetries: 3, + retryDelay: 1000, + + // SSL/TLS settings for secure banking communications + secureProtocol: 'TLSv1_2_method', // Force TLS 1.2+ + rejectUnauthorized: true, // Verify SSL certificates + checkServerIdentity: undefined, // Use Node.js default server identity check + + // Operation type classification patterns + operationClassification: { + quick: [/getAccounts/i, /balance/i, /validate/i, /ping/i, /status/i], + heavy: [/statement.*large/i, /download.*bulk/i, /export.*all/i, /history.*full/i] + // Everything else defaults to 'standard' + }, + + // Pool monitoring + enableMetrics: true, + metricsInterval: 60000, // Report metrics every minute + + // Retry manager configuration + retryManager: { + enabled: true + // RetryManager config will be passed through + } +}; + +/** + * ConnectionPool class for managing HTTP agents and connection pooling + */ +function ConnectionPool(config) { + if (!(this instanceof ConnectionPool)) return new ConnectionPool(config); + + this.config = this._mergeConfig(defaultPoolConfig, config || {}); + this.agents = new Map(); // Map of hostname -> agent + + // Initialize retry manager if enabled + if (this.config.retryManager.enabled) { + this.retryManager = new RetryManager(this.config.retryManager); + debug('Retry manager initialized'); + } + + this.metrics = { + totalRequests: 0, + activeConnections: 0, + poolHits: 0, + poolMisses: 0, + errors: 0, + retries: 0, + timeouts: 0, + averageResponseTime: 0, + requestTimes: [], + operationTypes: { + quick: 0, + standard: 0, + heavy: 0 + } + }; + + // Initialize metrics reporting if enabled + if (this.config.enableMetrics) { + this.metricsTimer = setInterval(() => { + this.reportMetrics(); + }, this.config.metricsInterval); + } + + debug('Connection pool initialized with config:', JSON.stringify(this.config, null, 2)); +} + +/** + * Deep merge configuration objects + * @param {object} defaultConfig - Default configuration + * @param {object} userConfig - User-provided configuration + * @returns {object} Merged configuration + */ +ConnectionPool.prototype._mergeConfig = function (defaultConfig, userConfig) { + const merged = JSON.parse(JSON.stringify(defaultConfig)); + + function deepMerge(target, source) { + for (const key in source) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + target[key] = target[key] || {}; + deepMerge(target[key], source[key]); + } else { + target[key] = source[key]; + } + } + } + + deepMerge(merged, userConfig); + return merged; +}; + +/** + * Classify operation type based on request parameters + * @param {object} options - Request options + * @param {string} data - Request data/payload + * @returns {string} Operation type: 'quick', 'standard', or 'heavy' + */ +ConnectionPool.prototype._classifyOperation = function (options, data) { + const url = options.url || ''; + const payload = data || ''; + + // Check for quick operations + if (this.config.operationClassification && this.config.operationClassification.quick) { + for (const pattern of this.config.operationClassification.quick) { + if (pattern && typeof pattern.test === 'function' && (pattern.test(url) || pattern.test(payload))) { + return 'quick'; + } + } + } + + // Check for heavy operations + if (this.config.operationClassification && this.config.operationClassification.heavy) { + for (const pattern of this.config.operationClassification.heavy) { + if (pattern && typeof pattern.test === 'function' && (pattern.test(url) || pattern.test(payload))) { + return 'heavy'; + } + } + } + + // Check payload size for heavy operations + if (data && data.length > 50000) { + // > 50KB + return 'heavy'; + } + + // Check for large date ranges in OFX requests + if (payload.includes('') && payload.includes('')) { + const startMatch = payload.match(/(\d{8})/i); + const endMatch = payload.match(/(\d{8})/i); + + if (startMatch && endMatch) { + const startDate = new Date( + startMatch[1].substring(0, 4), + parseInt(startMatch[1].substring(4, 6)) - 1, + startMatch[1].substring(6, 8) + ); + const endDate = new Date( + endMatch[1].substring(0, 4), + parseInt(endMatch[1].substring(4, 6)) - 1, + endMatch[1].substring(6, 8) + ); + + const daysDiff = (endDate - startDate) / (1000 * 60 * 60 * 24); + + if (daysDiff > 365) { + // More than 1 year + return 'heavy'; + } else if (daysDiff < 30) { + // Less than 30 days + return 'quick'; + } + } + } + + return 'standard'; +}; + +/** + * Get timeout configuration for operation type + * @param {string} operationType - Operation type + * @returns {object} Timeout configuration + */ +ConnectionPool.prototype._getTimeoutConfig = function (operationType) { + return this.config.timeouts[operationType] || this.config.timeouts.standard; +}; + +/** + * Get or create an HTTP agent for the specified host with operation-specific configuration + * @param {string} hostname - The target hostname + * @param {boolean} isHttps - Whether to use HTTPS + * @param {string} operationType - Operation type for timeout configuration + * @returns {object} HTTP/HTTPS agent + */ +ConnectionPool.prototype.getAgent = function (hostname, isHttps, operationType = 'standard') { + const agentKey = `${isHttps ? 'https:' : 'http:'}${hostname}:${operationType}`; + + if (this.agents.has(agentKey)) { + if (this.metrics) { + this.metrics.poolHits++; + } + debug('Reusing existing agent for', agentKey); + return this.agents.get(agentKey); + } + + if (this.metrics) { + this.metrics.poolMisses++; + } + debug('Creating new agent for', agentKey); + + const timeoutConfig = this._getTimeoutConfig(operationType); + const AgentClass = isHttps ? https.Agent : http.Agent; + const agentOptions = { + keepAlive: this.config.keepAlive, + keepAliveMsecs: this.config.keepAliveMsecs, + maxSockets: this.config.maxSockets, + maxFreeSockets: this.config.maxFreeSockets, + timeout: timeoutConfig.connection, + freeSocketTimeout: timeoutConfig.idle + }; + + // Add HTTPS-specific options + if (isHttps) { + agentOptions.secureProtocol = this.config.secureProtocol; + agentOptions.rejectUnauthorized = this.config.rejectUnauthorized; + // Only set checkServerIdentity if it's defined and is a function + if (typeof this.config.checkServerIdentity === 'function') { + agentOptions.checkServerIdentity = this.config.checkServerIdentity; + } + } + + const agent = new AgentClass(agentOptions); + this.agents.set(agentKey, agent); + + return agent; +}; + +/** + * Make an HTTP request using connection pooling with advanced timeout and retry logic + * @param {object} options - Request options + * @param {string} data - Request body data + * @param {function} callback - Callback function (err, response) + */ +ConnectionPool.prototype.request = function (options, data, callback) { + const self = this; + const startTime = Date.now(); + + // Classify operation type for appropriate timeouts + const operationType = this._classifyOperation(options, data); + const timeoutConfig = this._getTimeoutConfig(operationType); + + if (this.metrics) { + this.metrics.totalRequests++; + this.metrics.operationTypes[operationType]++; + } + + debug(`Classified request as '${operationType}' operation with timeouts:`, timeoutConfig); + + // Use retry manager if available, otherwise fall back to legacy retry logic + if (this.retryManager) { + this._requestWithRetryManager(options, data, operationType, timeoutConfig, callback); + } else { + this._requestLegacy(options, data, operationType, timeoutConfig, callback); + } +}; + +/** + * Make request with retry manager integration + */ +ConnectionPool.prototype._requestWithRetryManager = function (options, data, operationType, timeoutConfig, callback) { + const self = this; + const parsedUrl = url.parse(options.url); + + // Check rate limiting + const rateLimitDelay = this.retryManager.checkRateLimit(parsedUrl.hostname); + if (rateLimitDelay > 0) { + debug(`Rate limiting: waiting ${rateLimitDelay}ms before request`); + setTimeout(() => { + this._executeRequestWithRetry(options, data, operationType, timeoutConfig, callback); + }, rateLimitDelay); + } else { + this._executeRequestWithRetry(options, data, operationType, timeoutConfig, callback); + } +}; + +/** + * Execute request with retry manager + */ +ConnectionPool.prototype._executeRequestWithRetry = function (options, data, operationType, timeoutConfig, callback) { + const self = this; + const parsedUrl = url.parse(options.url); + + // Record request start for rate limiting + if (this.retryManager) { + this.retryManager.recordRequestStart(parsedUrl.hostname); + } + + const requestOperation = attempt => { + return new Promise((resolve, reject) => { + self._makeSingleRequest(options, data, operationType, timeoutConfig, (error, response) => { + if (error) { + // Enhance error with OFX-specific information if available + self._enhanceErrorWithOFXInfo(error, response); + reject(error); + } else { + resolve(response); + } + }); + }); + }; + + this.retryManager + .executeWithRetry(requestOperation, { operationType }) + .then(response => { + if (this.retryManager) { + this.retryManager.recordRequestEnd(parsedUrl.hostname); + } + callback(false, response); + }) + .catch(error => { + if (this.retryManager) { + this.retryManager.recordRequestEnd(parsedUrl.hostname); + } + callback(error); + }); +}; + +/** + * Legacy request method (fallback when retry manager is disabled) + */ +ConnectionPool.prototype._requestLegacy = function (options, data, operationType, timeoutConfig, callback) { + // Legacy implementation with basic retry logic + this._makeSingleRequest(options, data, operationType, timeoutConfig, callback); +}; + +/** + * Make a single HTTP request + */ +ConnectionPool.prototype._makeSingleRequest = function (options, data, operationType, timeoutConfig, callback) { + const self = this; + const startTime = Date.now(); + + const parsedUrl = url.parse(options.url); + const isHttps = parsedUrl.protocol === 'https:'; + const agent = self.getAgent(parsedUrl.hostname, isHttps, operationType); + + const requestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (isHttps ? HTTPS_PORT : HTTP_PORT), + path: parsedUrl.path, + method: 'POST', + agent: agent, + timeout: timeoutConfig.request, + headers: {} + }; + + // Build headers from banking options + if (options.headers && Array.isArray(options.headers)) { + options.headers.forEach(header => { + let value; + if (options[header]) { + value = options[header]; + } else if (header === 'Content-Length') { + value = Buffer.byteLength(data, 'utf8'); + } else if (header === 'Host') { + value = parsedUrl.host; + } + if (value !== undefined) { + requestOptions.headers[header] = value; + } + }); + } + + debug(`Making ${operationType} request to`, parsedUrl.href); + if (self.metrics) { + self.metrics.activeConnections++; + } + + const clientRequest = (isHttps ? https : http).request(requestOptions, response => { + let responseData = ''; + + // Set socket timeout + response.socket.setTimeout(timeoutConfig.socket, () => { + debug('Socket timeout after', `${timeoutConfig.socket}ms`); + if (self.metrics) { + self.metrics.timeouts++; + } + const timeoutError = new TimeoutError(`Socket timeout after ${timeoutConfig.socket}ms`, { + code: 'ESOCKETTIMEDOUT', + operationType: options.operationType, + fid: options.fid, + fidOrg: options.fidOrg, + url: options.url, + metadata: { + timeoutType: 'socket', + timeoutValue: timeoutConfig.socket + } + }); + response.destroy(); + callback(timeoutError); + }); + + response.on('data', chunk => { + responseData += chunk; + }); + + response.on('end', () => { + const endTime = Date.now(); + const responseTime = endTime - startTime; + + if (self.metrics) { + self.metrics.activeConnections--; + self.updateResponseTimeMetrics(responseTime); + } + + debug(`${operationType} request completed in`, `${responseTime}ms`, 'status:', response.statusCode); + + // Check for HTTP errors + if (response.statusCode !== 200) { + const error = createBankingError( + { + message: `HTTP ${response.statusCode} ${response.statusMessage}`, + httpStatus: response.statusCode + }, + { + operationType: options.operationType, + fid: options.fid, + fidOrg: options.fidOrg, + url: options.url, + metadata: { + responseTime, + responseSize: responseData.length + } + } + ); + + if (self.metrics) { + self.metrics.errors++; + } + + return callback(error, responseData); + } + + // Transform response to match existing format + let httpResponse = `HTTP/${response.httpVersion} ${response.statusCode} ${response.statusMessage}\r\n`; + + // Add response headers + Object.keys(response.headers).forEach(header => { + httpResponse += `${header}: ${response.headers[header]}\r\n`; + }); + + httpResponse += `\r\n${responseData}`; + + callback(false, httpResponse); + }); + + response.on('error', error => { + if (self.metrics) { + self.metrics.activeConnections--; + self.metrics.errors++; + } + + debug('Response error:', error.message); + + // Create appropriate banking error from the original error + const bankingError = createBankingError( + { + message: error.message, + originalError: error + }, + { + operationType: options.operationType, + fid: options.fid, + fidOrg: options.fidOrg, + url: options.url + } + ); + + callback(bankingError); + }); + }); + + clientRequest.on('error', error => { + if (self.metrics) { + self.metrics.activeConnections--; + self.metrics.errors++; + } + + debug('Request error:', error.message); + + // Create appropriate banking error from the original error + const bankingError = createBankingError( + { + message: error.message, + originalError: error + }, + { + operationType: options.operationType, + fid: options.fid, + fidOrg: options.fidOrg, + url: options.url + } + ); + + callback(bankingError); + }); + + clientRequest.on('timeout', () => { + if (self.metrics) { + self.metrics.activeConnections--; + self.metrics.timeouts++; + } + + debug('Request timeout after', `${timeoutConfig.request}ms`); + clientRequest.destroy(); + + const timeoutError = new TimeoutError(`Request timeout after ${timeoutConfig.request}ms`, { + code: 'ETIMEDOUT', + operationType: options.operationType, + fid: options.fid, + fidOrg: options.fidOrg, + url: options.url, + metadata: { + timeoutType: 'request', + timeoutValue: timeoutConfig.request + } + }); + callback(timeoutError); + }); + + // Set connection timeout + clientRequest.setTimeout(timeoutConfig.connection, () => { + debug('Connection timeout after', `${timeoutConfig.connection}ms`); + if (self.metrics) { + self.metrics.timeouts++; + } + clientRequest.destroy(); + const timeoutError = new TimeoutError(`Connection timeout after ${timeoutConfig.connection}ms`, { + code: 'ECONNTIMEDOUT', + operationType: options.operationType, + fid: options.fid, + fidOrg: options.fidOrg, + url: options.url, + metadata: { + timeoutType: 'connection', + timeoutValue: timeoutConfig.connection + } + }); + callback(timeoutError); + }); + + // Write request data + clientRequest.end(data); +}; + +/** + * Enhance error with OFX-specific information + */ +ConnectionPool.prototype._enhanceErrorWithOFXInfo = function (error, response) { + if (!response || typeof response !== 'string') { + return; + } + + // Try to extract OFX error codes from response + try { + const codeMatch = response.match(/(\d+)/i); + const messageMatch = response.match(/([^<]+)/i); + + if (codeMatch) { + error.ofxCode = codeMatch[1]; + } + if (messageMatch) { + error.ofxMessage = messageMatch[1].trim(); + } + } catch (parseError) { + debug('Failed to parse OFX error information:', parseError.message); + } +}; + +/** + * Update response time metrics + * @param {number} responseTime - Response time in milliseconds + */ +ConnectionPool.prototype.updateResponseTimeMetrics = function (responseTime) { + this.metrics.requestTimes.push(responseTime); + + // Keep only last 100 response times for average calculation + if (this.metrics.requestTimes.length > 100) { + this.metrics.requestTimes.shift(); + } + + // Calculate average response time + const sum = this.metrics.requestTimes.reduce((a, b) => { + return a + b; + }, 0); + this.metrics.averageResponseTime = Math.round(sum / this.metrics.requestTimes.length); +}; + +/** + * Get current pool metrics including retry manager metrics + * @returns {object} Current metrics + */ +ConnectionPool.prototype.getMetrics = function () { + const poolStats = {}; + + // Get connection statistics from agents + this.agents.forEach((agent, key) => { + const sockets = agent.sockets || {}; + const freeSockets = agent.freeSockets || {}; + const requests = agent.requests || {}; + + poolStats[key] = { + sockets: Object.keys(sockets).reduce((count, host) => { + return count + sockets[host].length; + }, 0), + freeSockets: Object.keys(freeSockets).reduce((count, host) => { + return count + freeSockets[host].length; + }, 0), + requests: Object.keys(requests).reduce((count, host) => { + return count + requests[host].length; + }, 0) + }; + }); + + const result = Object.assign({}, this.metrics, { + poolStats: poolStats, + agentCount: this.agents.size + }); + + // Include retry manager metrics if available + if (this.retryManager) { + result.retryMetrics = this.retryManager.getMetrics(); + } + + return result; +}; + +/** + * Report current metrics (called periodically if enabled) + */ +ConnectionPool.prototype.reportMetrics = function () { + const metrics = this.getMetrics(); + debug('Pool metrics:', JSON.stringify(metrics, null, 2)); +}; + +/** + * Close all connections and clean up resources + */ +ConnectionPool.prototype.destroy = function () { + debug('Destroying connection pool'); + + // Clear metrics timer + if (this.metricsTimer) { + clearInterval(this.metricsTimer); + this.metricsTimer = null; + } + + // Destroy all agents + this.agents.forEach(agent => { + if (agent.destroy) { + agent.destroy(); + } + }); + + this.agents.clear(); + this.metrics = null; + + // Clean up retry manager + if (this.retryManager) { + this.retryManager.resetMetrics(); + this.retryManager = null; + } +}; + +// Export the ConnectionPool class +module.exports = ConnectionPool; diff --git a/lib/errors.js b/lib/errors.js new file mode 100644 index 0000000..a1a30be --- /dev/null +++ b/lib/errors.js @@ -0,0 +1,597 @@ +/*! + * Structured Error Classes for banking.js + * Provides comprehensive error handling and classification for banking operations + */ + +const debug = require('debug')('banking:errors'); + +/** + * Generate a simple UUID for correlation IDs + * @param {number} len - Length of the UUID + * @returns {string} - Generated UUID + */ +function generateCorrelationId(len = 16) { + const CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split(''); + const chars = CHARS; + const uuid = []; + + for (let i = 0; i < len; i++) { + uuid[i] = chars[Math.floor(Math.random() * chars.length)]; + } + + return uuid.join(''); +} + +/** + * Base error class for all banking operations + * Provides structured error information, correlation IDs, and PCI-compliant logging + */ +class BankingError extends Error { + constructor(message, options = {}) { + super(message); + + // Set error name and maintain prototype chain + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + + // Core error properties + this.code = options.code || 'BANKING_ERROR'; + this.correlationId = options.correlationId || generateCorrelationId(16); + this.timestamp = options.timestamp || new Date().toISOString(); + this.category = options.category || 'UNKNOWN'; + + // Classification and retry information + this.retryable = options.retryable !== undefined ? options.retryable : false; + this.retryAfter = options.retryAfter || null; + this.maxRetries = options.maxRetries || 0; + + // Context information (PCI-compliant - no sensitive data) + this.bankingContext = { + fid: options.fid || null, + fidOrg: options.fidOrg || null, + operationType: options.operationType || null, + accountType: options.accountType || null, + // Never include account numbers, credentials, or transaction details + url: options.url ? this._sanitizeUrl(options.url) : null + }; + + // Technical details for debugging + this.technicalDetails = { + originalError: options.originalError || null, + httpStatus: options.httpStatus || null, + ofxStatus: options.ofxStatus || null, + requestId: options.requestId || null, + userAgent: options.userAgent || 'banking-js', + ofxVersion: options.ofxVersion || null + }; + + // Recommendations for resolution + this.recommendations = options.recommendations || []; + + // Additional metadata + this.metadata = options.metadata || {}; + + debug('BankingError created:', { + name: this.name, + code: this.code, + correlationId: this.correlationId, + category: this.category, + retryable: this.retryable + }); + } + + /** + * Sanitize URL to remove sensitive query parameters or credentials + * @private + */ + _sanitizeUrl(url) { + try { + const urlObj = new URL(url); + // Remove any query parameters that might contain sensitive data + urlObj.search = ''; + // Remove user info if present + urlObj.username = ''; + urlObj.password = ''; + return urlObj.toString(); + } catch (e) { + return '[INVALID_URL]'; + } + } + + /** + * Get a sanitized version of the error for logging (PCI compliant) + */ + toLogObject() { + return { + name: this.name, + message: this.message, + code: this.code, + correlationId: this.correlationId, + timestamp: this.timestamp, + category: this.category, + retryable: this.retryable, + bankingContext: { + fid: this.bankingContext.fid, + fidOrg: this.bankingContext.fidOrg, + operationType: this.bankingContext.operationType, + accountType: this.bankingContext.accountType, + url: this.bankingContext.url + }, + technicalDetails: { + httpStatus: this.technicalDetails.httpStatus, + ofxStatus: this.technicalDetails.ofxStatus, + requestId: this.technicalDetails.requestId, + userAgent: this.technicalDetails.userAgent, + ofxVersion: this.technicalDetails.ofxVersion + }, + recommendations: this.recommendations + }; + } + + /** + * Get JSON representation of the error + */ + toJSON() { + return this.toLogObject(); + } +} + +/** + * Network-related errors + */ +class NetworkError extends BankingError { + constructor(message, options = {}) { + super(message, { ...options, category: 'NETWORK' }); + this.retryable = options.retryable !== undefined ? options.retryable : true; + this.maxRetries = options.maxRetries || 3; + } +} + +class ConnectionError extends NetworkError { + constructor(message, options = {}) { + super(message, { ...options, code: 'CONNECTION_ERROR' }); + this.recommendations = [ + 'Check network connectivity', + 'Verify firewall settings', + 'Ensure the banking server is accessible', + 'Try again in a few moments' + ]; + } +} + +class TimeoutError extends NetworkError { + constructor(message, options = {}) { + super(message, { ...options, code: 'TIMEOUT_ERROR' }); + this.retryable = true; + this.maxRetries = 2; // Fewer retries for timeouts + this.recommendations = [ + 'Increase timeout values for the operation type', + 'Check network latency to the banking server', + 'Consider reducing the date range for large requests', + 'Retry the operation' + ]; + } +} + +class DNSError extends NetworkError { + constructor(message, options = {}) { + super(message, { ...options, code: 'DNS_ERROR' }); + this.retryable = false; // DNS errors usually indicate configuration issues + this.maxRetries = 0; // DNS errors shouldn't be retried + this.recommendations = [ + 'Verify the banking server URL is correct', + 'Check DNS configuration', + 'Ensure network connectivity', + 'Contact the financial institution for correct server details' + ]; + } +} + +class CertificateError extends NetworkError { + constructor(message, options = {}) { + super(message, { ...options, code: 'CERTIFICATE_ERROR' }); + this.retryable = false; + this.recommendations = [ + 'Verify SSL certificate configuration', + 'Check if rejectUnauthorized should be disabled (not recommended for production)', + 'Ensure system clock is correct', + 'Contact the financial institution about certificate issues' + ]; + } +} + +/** + * Authentication and authorization errors + */ +class AuthenticationError extends BankingError { + constructor(message, options = {}) { + super(message, { ...options, category: 'AUTHENTICATION' }); + this.retryable = false; // Authentication errors usually require user intervention + } +} + +class InvalidCredentialsError extends AuthenticationError { + constructor(message, options = {}) { + super(message, { ...options, code: 'INVALID_CREDENTIALS' }); + this.recommendations = [ + 'Verify username and password are correct', + 'Check if account is locked or suspended', + 'Ensure account has OFX access enabled', + 'Contact the financial institution if credentials are correct' + ]; + } +} + +class ExpiredSessionError extends AuthenticationError { + constructor(message, options = {}) { + super(message, { ...options, code: 'EXPIRED_SESSION' }); + this.retryable = true; + this.maxRetries = 1; + this.recommendations = [ + 'Reauthenticate with fresh credentials', + 'Clear any cached session data', + 'Retry the operation with new authentication' + ]; + } +} + +class InsufficientPermissionsError extends AuthenticationError { + constructor(message, options = {}) { + super(message, { ...options, code: 'INSUFFICIENT_PERMISSIONS' }); + this.recommendations = [ + 'Verify account has OFX download permissions', + 'Check with the financial institution about account access', + 'Ensure account type supports the requested operation' + ]; + } +} + +/** + * Banking-specific business logic errors + */ +class BankingBusinessError extends BankingError { + constructor(message, options = {}) { + super(message, { ...options, category: 'BANKING_BUSINESS' }); + } +} + +class AccountNotFoundError extends BankingBusinessError { + constructor(message, options = {}) { + super(message, { ...options, code: 'ACCOUNT_NOT_FOUND' }); + this.recommendations = [ + 'Verify the account ID/number is correct', + 'Check if the account type matches the actual account', + 'Ensure the account is active and not closed', + 'Verify bank routing number (for bank accounts)' + ]; + } +} + +class InsufficientFundsError extends BankingBusinessError { + constructor(message, options = {}) { + super(message, { ...options, code: 'INSUFFICIENT_FUNDS' }); + this.recommendations = [ + 'Check account balance', + 'Verify transaction amount', + 'Consider overdraft protection settings' + ]; + } +} + +class MaintenanceModeError extends BankingBusinessError { + constructor(message, options = {}) { + super(message, { ...options, code: 'MAINTENANCE_MODE' }); + this.retryable = true; + this.maxRetries = 1; + this.retryAfter = options.retryAfter || 3600; // Retry after 1 hour by default + this.recommendations = [ + 'Wait for maintenance window to complete', + "Check the financial institution's website for maintenance schedules", + 'Retry the operation later', + 'Consider using alternative banking channels temporarily' + ]; + } +} + +class DailyLimitExceededError extends BankingBusinessError { + constructor(message, options = {}) { + super(message, { ...options, code: 'DAILY_LIMIT_EXCEEDED' }); + this.retryable = true; + this.retryAfter = 86400; // Retry after 24 hours + this.recommendations = [ + 'Wait until the next business day', + 'Contact the financial institution to increase limits', + 'Split large requests into smaller date ranges' + ]; + } +} + +/** + * OFX protocol-specific errors + */ +class OFXProtocolError extends BankingError { + constructor(message, options = {}) { + super(message, { ...options, category: 'OFX_PROTOCOL' }); + } +} + +class MalformedResponseError extends OFXProtocolError { + constructor(message, options = {}) { + super(message, { ...options, code: 'MALFORMED_RESPONSE' }); + this.retryable = true; + this.maxRetries = 2; + this.recommendations = [ + 'Check OFX version compatibility', + 'Verify the financial institution supports the requested OFX version', + 'Try with a different OFX version', + 'Contact the financial institution about response format issues' + ]; + } +} + +class VersionMismatchError extends OFXProtocolError { + constructor(message, options = {}) { + super(message, { ...options, code: 'VERSION_MISMATCH' }); + this.retryable = true; + this.maxRetries = 1; + this.recommendations = [ + 'Update to a supported OFX version', + "Check the financial institution's supported OFX versions", + 'Try with OFX version 1.0.2 or 2.0.3', + 'Verify application version compatibility' + ]; + } +} + +class InvalidOFXHeaderError extends OFXProtocolError { + constructor(message, options = {}) { + super(message, { ...options, code: 'INVALID_OFX_HEADER' }); + this.recommendations = [ + 'Check OFX header format', + 'Verify required header fields are present', + 'Ensure proper encoding and character set', + 'Validate against OFX specification' + ]; + } +} + +/** + * Rate limiting and throttling errors + */ +class RateLimitError extends BankingError { + constructor(message, options = {}) { + super(message, { ...options, category: 'RATE_LIMIT', code: 'RATE_LIMITED' }); + this.retryable = true; + this.maxRetries = 3; + this.retryAfter = options.retryAfter || 60; // Default 1 minute + this.recommendations = [ + 'Reduce request frequency', + 'Implement exponential backoff', + 'Check rate limiting policies with the financial institution', + 'Consider batching requests' + ]; + } +} + +class TooManyRequestsError extends RateLimitError { + constructor(message, options = {}) { + super(message, { ...options, code: 'TOO_MANY_REQUESTS' }); + this.retryAfter = options.retryAfter || 300; // 5 minutes + this.recommendations = [ + 'Wait before making additional requests', + 'Implement request queuing with delays', + 'Contact the financial institution about rate limits', + 'Spread requests across multiple connections if allowed' + ]; + } +} + +/** + * Configuration and setup errors + */ +class ConfigurationError extends BankingError { + constructor(message, options = {}) { + super(message, { ...options, category: 'CONFIGURATION' }); + this.retryable = false; // Configuration errors require code changes + } +} + +class InvalidConfigurationError extends ConfigurationError { + constructor(message, options = {}) { + super(message, { ...options, code: 'INVALID_CONFIGURATION' }); + this.recommendations = [ + 'Verify all required configuration parameters', + 'Check parameter formats and types', + 'Ensure FID and bank ID are correct', + 'Validate URL format and accessibility' + ]; + } +} + +class MissingParameterError extends ConfigurationError { + constructor(message, options = {}) { + super(message, { ...options, code: 'MISSING_PARAMETER' }); + this.recommendations = [ + 'Provide all required parameters', + 'Check documentation for required fields', + 'Verify parameter names are spelled correctly' + ]; + } +} + +/** + * Data validation and parsing errors + */ +class DataError extends BankingError { + constructor(message, options = {}) { + super(message, { ...options, category: 'DATA' }); + } +} + +class InvalidDateRangeError extends DataError { + constructor(message, options = {}) { + super(message, { ...options, code: 'INVALID_DATE_RANGE' }); + this.recommendations = [ + 'Ensure start date is before end date', + 'Use YYYYMMDD or YYYYMMDDHHMMSS format', + 'Check date range limits imposed by the financial institution', + 'Verify dates are not in the future' + ]; + } +} + +class DataParsingError extends DataError { + constructor(message, options = {}) { + super(message, { ...options, code: 'DATA_PARSING_ERROR' }); + this.retryable = true; + this.maxRetries = 1; + this.recommendations = [ + 'Check data format and encoding', + 'Verify character set compatibility', + 'Try parsing with different options', + 'Contact support if data appears corrupted' + ]; + } +} + +/** + * Cache-related errors + * Used for cache operations, storage issues, and cache configuration problems + */ +class CacheError extends BankingError { + constructor(message, options = {}) { + super(message, { + ...options, + code: options.code || 'CACHE_ERROR', + category: 'CACHE' + }); + this.retryable = options.retryable !== undefined ? options.retryable : false; + this.maxRetries = options.maxRetries || 0; + this.recommendations = options.recommendations || [ + 'Check cache configuration', + 'Verify cache storage accessibility', + 'Consider disabling cache temporarily', + 'Review cache memory limits' + ]; + } +} + +/** + * Error factory function to create appropriate error types + */ +function createBankingError(errorInfo, options = {}) { + const { code, message, httpStatus, originalError } = errorInfo; + + // Map common Node.js error codes to banking error types + if (originalError) { + const nodeErrorCode = originalError.code; + const nodeMessage = originalError.message || message; + + switch (nodeErrorCode) { + case 'ENOTFOUND': + case 'EAI_NODATA': + case 'EAI_NONAME': + return new DNSError(nodeMessage, { ...options, originalError }); + + case 'ECONNREFUSED': + case 'ECONNRESET': + case 'ENETUNREACH': + case 'EHOSTUNREACH': + return new ConnectionError(nodeMessage, { ...options, originalError }); + + case 'ETIMEDOUT': + case 'ESOCKETTIMEDOUT': + return new TimeoutError(nodeMessage, { ...options, originalError }); + + case 'CERT_SIGNATURE_FAILURE': + case 'CERT_NOT_YET_VALID': + case 'CERT_HAS_EXPIRED': + case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE': + return new CertificateError(nodeMessage, { ...options, originalError }); + } + } + + // Map HTTP status codes + if (httpStatus) { + switch (httpStatus) { + case 401: + return new InvalidCredentialsError(message, { ...options, httpStatus }); + case 403: + return new InsufficientPermissionsError(message, { ...options, httpStatus }); + case 404: + return new AccountNotFoundError(message, { ...options, httpStatus }); + case 429: + return new TooManyRequestsError(message, { ...options, httpStatus }); + case 503: + return new MaintenanceModeError(message, { ...options, httpStatus }); + } + } + + // Map by error code + switch (code) { + case 'INVALID_CREDENTIALS': + return new InvalidCredentialsError(message, options); + case 'ACCOUNT_NOT_FOUND': + return new AccountNotFoundError(message, options); + case 'MALFORMED_RESPONSE': + return new MalformedResponseError(message, options); + case 'RATE_LIMITED': + return new RateLimitError(message, options); + case 'INVALID_CONFIGURATION': + return new InvalidConfigurationError(message, options); + default: + return new BankingError(message, { ...options, code }); + } +} + +// Export all error classes +module.exports = { + // Base error + BankingError, + + // Network errors + NetworkError, + ConnectionError, + TimeoutError, + DNSError, + CertificateError, + + // Authentication errors + AuthenticationError, + InvalidCredentialsError, + ExpiredSessionError, + InsufficientPermissionsError, + + // Banking business errors + BankingBusinessError, + AccountNotFoundError, + InsufficientFundsError, + MaintenanceModeError, + DailyLimitExceededError, + + // OFX protocol errors + OFXProtocolError, + MalformedResponseError, + VersionMismatchError, + InvalidOFXHeaderError, + + // Rate limiting errors + RateLimitError, + TooManyRequestsError, + + // Configuration errors + ConfigurationError, + InvalidConfigurationError, + MissingParameterError, + + // Data errors + DataError, + InvalidDateRangeError, + DataParsingError, + + // Cache errors + CacheError, + + // Factory function + createBankingError +}; diff --git a/lib/ofx.js b/lib/ofx.js index a7bebc1..2876901 100644 --- a/lib/ofx.js +++ b/lib/ofx.js @@ -1,49 +1,60 @@ - /*! * [OFX description] * @type {[type]} */ -var xml2js = require('xml2js') - , parser = new xml2js.Parser({explicitArray: false}) - , util = require('./utils') - , debug = require('debug')('banking:ofx'); +const xml2js = require('xml2js'), + parser = new xml2js.Parser({ explicitArray: false }), + streamingParser = new xml2js.Parser({ + explicitArray: false, + normalize: false, + trim: true, + mergeAttrs: true + }), + util = require('./utils'), + debug = require('debug')('banking:ofx'); // expose OFX -var OFX = module.exports = {}; +const OFX = (module.exports = {}); + +// Performance optimization: Cache for preprocessed XML patterns +const _xmlPatternCache = new Map(); +const MAX_CACHE_SIZE = 100; // Limit memory usage +const CACHE_CLEANUP_THRESHOLD = 80; function getSignOnMsg(opts) { - var dtClient = (new Date()).toISOString().substring(0, 20).replace(/[^0-9]/g, ''); - - return '' + - '' + - '' + dtClient + - '' + opts.user + - '' + opts.password + - 'ENG' + - '' + - '' + opts.fidOrg + - '' + opts.fid + - '' + - '' + opts.app + - '' + opts.appVer + - (typeof opts.clientId !== 'undefined' ? '' + opts.clientId : '') + - '' + - ''; + const dtClient = new Date() + .toISOString() + .substring(0, 20) + .replace(/[^0-9]/g, ''); + + return ( + `` + + `` + + `${dtClient}${opts.user}${opts.password}ENG` + + `` + + `${opts.fidOrg}${opts.fid}` + + `${opts.app}${opts.appVer}${ + typeof opts.clientId !== 'undefined' ? `${opts.clientId}` : '' + }` + + `` + ); } function getOfxHeaders(opts) { - return 'OFXHEADER:100\r\n' + - 'DATA:OFXSGML\r\n' + - 'VERSION:' + opts.ofxVer + '\r\n' + - 'SECURITY:NONE\r\n' + - 'ENCODING:USASCII\r\n' + - 'CHARSET:1252\r\n' + - 'COMPRESSION:NONE\r\n' + - 'OLDFILEUID:NONE\r\n' + - 'NEWFILEUID:' + util.uuid(32) + '\r\n' + - '\r\n'; + return ( + `OFXHEADER:100\r\n` + + `DATA:OFXSGML\r\n` + + `VERSION:${opts.ofxVer}\r\n` + + `SECURITY:NONE\r\n` + + `ENCODING:USASCII\r\n` + + `CHARSET:1252\r\n` + + `COMPRESSION:NONE\r\n` + + `OLDFILEUID:NONE\r\n` + + `NEWFILEUID:${util.uuid(32)}\r\n` + + `\r\n` + ); } /** @@ -52,16 +63,16 @@ function getOfxHeaders(opts) { * @returns {string} */ OFX.buildAccountListRequest = function (opts) { - var reqStr = getOfxHeaders(opts) + '' + getSignOnMsg(opts); - reqStr += '' + - '' + - '' + util.uuid(32) + - '' + - '19900101' + - '' + - '' + - '' + - ''; + let reqStr = `${getOfxHeaders(opts)}${getSignOnMsg(opts)}`; + reqStr += + `` + + `` + + `${util.uuid(32)}` + + `19900101` + + `` + + `` + + `` + + ``; return reqStr; }; @@ -72,70 +83,55 @@ OFX.buildAccountListRequest = function (opts) { * @returns {string} */ OFX.buildStatementRequest = function (opts) { - var type = (opts.accType || '').toUpperCase(); - var reqStr = getOfxHeaders(opts) + '' + getSignOnMsg(opts); + const type = (opts.accType || '').toUpperCase(); + let reqStr = `${getOfxHeaders(opts)}${getSignOnMsg(opts)}`; switch (type) { case 'INVESTMENT': - reqStr += '' + - '' + - '' + util.uuid(32) + - '' + util.uuid(5) + - '' + - '' + - '' + opts.brokerId + - '' + opts.accId + - '' + - '' + - '' + opts.start + - (typeof opts.end !== 'undefined' ? '' + opts.end : '') + - 'Y' + - 'Y' + - '' + - 'Y' + - '' + - 'Y' + - '' + - '' + - ''; + reqStr += + `` + + `` + + `${util.uuid(32)}${util.uuid(5)}` + + `` + + `${opts.brokerId}${opts.accId}` + + `` + + `${opts.start}${typeof opts.end !== 'undefined' ? `${opts.end}` : ''}Y` + + `Y` + + `` + + `Y` + + `` + + `Y` + + `` + + `` + + ``; break; case 'CREDITCARD': - reqStr += '' + - '' + - '' + util.uuid(32) + - '' + util.uuid(5) + - '' + - '' + - '' + opts.accId + - '' + - '' + - '' + opts.start + - (typeof opts.end !== 'undefined' ? '' + opts.end : '') + - 'Y' + - '' + - '' + - ''; + reqStr += + `` + + `` + + `${util.uuid(32)}${util.uuid(5)}` + + `` + + `${opts.accId}` + + `` + + `${opts.start}${typeof opts.end !== 'undefined' ? `${opts.end}` : ''}Y` + + `` + + `` + + ``; break; default: - reqStr += '' + - '' + - '' + util.uuid(32) + - '' + util.uuid(5) + - '' + - '' + - '' + opts.bankId + - '' + opts.accId + - '' + type + - '' + - '' + - '' + opts.start + - (typeof opts.end !== 'undefined' ? '' + opts.end : '') + - 'Y' + - '' + - '' + - ''; + reqStr += + `` + + `` + + `${util.uuid(32)}${util.uuid(5)}` + + `` + + `${opts.bankId}${opts.accId}${type}` + + `` + + `${opts.start}${typeof opts.end !== 'undefined' ? `${opts.end}` : ''}Y` + + `` + + `` + + ``; } reqStr += ''; @@ -145,18 +141,132 @@ OFX.buildStatementRequest = function (opts) { }; /** - * Parse an OFX response string + * Parse an OFX response string - Performance Optimized * @param ofxStr * @param fn */ OFX.parse = function (ofxStr, fn) { - var data = {}; - var callback = fn; - var ofxRes = ofxStr.split('', 2); - var ofx = '' + ofxRes[1]; - var headerString = ofxRes[0].split(/\r|\n/); + const parseStartTime = process.hrtime.bigint(); + const initialMemory = process.memoryUsage(); + + debug(`OFX Parse Start: ${ofxStr.length} bytes, Memory: ${Math.round(initialMemory.heapUsed / 1024 / 1024)}MB`); + + const data = {}; + const ofxRes = ofxStr.split('', 2); + const ofx = `${ofxRes[1]}`; + const headerString = ofxRes[0].split(/\r|\n/); + + // Auto-detect if streaming parsing should be used for large responses + const shouldUseStreaming = _shouldUseStreamingParse(ofxStr); + + if (shouldUseStreaming) { + debug('Using streaming parser for large OFX response'); + return _parseWithStreaming(ofx, headerString, fn, parseStartTime, initialMemory); + } + + // Performance optimization: Single-pass XML preprocessing + // Combined multiple regex operations into one optimized function + data.xml = _optimizedXmlPreprocess(ofx); + + parser.parseString(data.xml, (err, result) => { + if (err) { + debug('XML Parsing Error:', err); + } + data.body = result; + }); + + // Performance optimization: Optimized header parsing + data.header = _optimizedHeaderParse(headerString); + + // Log performance metrics + _logParseMetrics(parseStartTime, initialMemory, ofxStr.length, shouldUseStreaming); + + fn(data); +}; + +/** + * Determine if streaming parsing should be used based on content size and complexity + * @private + */ +function _shouldUseStreamingParse(ofxStr) { + // Use streaming for responses larger than 1MB + if (ofxStr.length > 1024 * 1024) return true; - data.xml = ofx + // Use streaming if many transaction records detected + const transactionCount = (ofxStr.match(//g) || []).length; + if (transactionCount > 1000) return true; + + return false; +} + +/** + * Parse large OFX responses using streaming approach to minimize memory usage + * @private + */ +function _parseWithStreaming(ofx, headerString, fn, parseStartTime, initialMemory) { + const data = { + header: _optimizedHeaderParse(headerString), + body: null, + xml: null + }; + + // For streaming, we still need to preprocess but with memory optimization + const processedXml = _optimizedXmlPreprocessStreaming(ofx); + data.xml = processedXml; + + streamingParser.parseString(processedXml, (err, result) => { + if (err) { + debug('Streaming XML Parsing Error:', err); + } + data.body = result; + + // Log streaming performance metrics + _logParseMetrics(parseStartTime, initialMemory, ofx.length, true); + + fn(data); + }); +} + +/** + * Memory-optimized XML preprocessing for streaming + * @private + */ +function _optimizedXmlPreprocessStreaming(ofx) { + // For very large files, process in chunks to avoid memory spikes + const chunkSize = 64 * 1024; // 64KB chunks + let result = ''; + + for (let i = 0; i < ofx.length; i += chunkSize) { + const chunk = ofx.slice(i, i + chunkSize); + result += _optimizedXmlPreprocess(chunk); + + // Allow event loop to process other operations + if (i % (chunkSize * 10) === 0) { + // Force garbage collection opportunity for large files + if (global.gc) { + global.gc(); + } + } + } + + return result; +} + +/** + * Optimized XML preprocessing with combined regex operations and caching + * Uses the original proven logic but with performance optimizations + * @private + */ +function _optimizedXmlPreprocess(ofx) { + // Check cache first for small, common patterns + const cacheKey = _generateCacheKey(ofx); + if (cacheKey && _xmlPatternCache.has(cacheKey)) { + debug('XML preprocessing cache hit'); + return _xmlPatternCache.get(cacheKey); + } + + // Original proven logic with performance optimizations + let result = ofx // Remove empty spaces and line breaks between tags .replace(/>\s+<') // Remove empty spaces and line breaks before tags content @@ -164,23 +274,100 @@ OFX.parse = function (ofxStr, fn) { // Remove empty spaces and line breaks after tags content .replace(/>\s+/g, '>') // Remove dots in start-tags names and remove end-tags with dots - .replace(/<([A-Z0-9_]*)+\.+([A-Z0-9_]*)>([^<]+)(<\/\1\.\2>)?/g, '<\$1\$2>\$3') + .replace(/<([A-Z0-9_]*)+\.+([A-Z0-9_]*)>([^<]+)(<\/\1\.\2>)?/g, '<$1$2>$3') // Add a new end-tags for the ofx elements - .replace(/<(\w+?)>([^<]+)/g, '<\$1>\$2\$1>') + .replace(/<(\w+?)>([^<]+)/g, '<$1>$2$1>') // Remove duplicate end-tags - .replace(/<\/(\w+?)>(<\/\1>)?/g, ''); + .replace(/<\/(\w+?)>(<\/\1>)?/g, ''); - parser.parseString(data.xml, function (err, result) { - data.body = result; - }); + // Cache result if it's worth caching (small, common patterns) + if (cacheKey && ofx.length < 1024) { + _cacheXmlPattern(cacheKey, result); + } - data.header = {}; + return result; +} + +/** + * Generate cache key for XML patterns + * @private + */ +function _generateCacheKey(ofx) { + // Only cache small, common patterns to avoid memory bloat + if (ofx.length > 1024) return null; + + // Create hash-like key from first/last chars and length + const start = ofx.substring(0, 20); + const end = ofx.substring(Math.max(0, ofx.length - 20)); + return `${start.length}_${end.length}_${ofx.length}`; +} - for (var key in headerString) { - if (typeof headerString[key] === "string") { - var headAttributes = headerString[key].split(/:/, 2); +/** + * Cache XML pattern with LRU-style cleanup + * @private + */ +function _cacheXmlPattern(key, result) { + if (_xmlPatternCache.size >= MAX_CACHE_SIZE) { + // Simple LRU: remove oldest entries + const keysToDelete = Array.from(_xmlPatternCache.keys()).slice(0, MAX_CACHE_SIZE - CACHE_CLEANUP_THRESHOLD); + for (const oldKey of keysToDelete) { + _xmlPatternCache.delete(oldKey); } - if (headAttributes[0]) data.header[headAttributes[0]] = headAttributes[1]; + debug(`XML cache cleanup: removed ${keysToDelete.length} entries`); } - fn(data); -}; + + _xmlPatternCache.set(key, result); +} + +/** + * Log performance metrics for OFX parsing operations + * @private + */ +function _logParseMetrics(parseStartTime, initialMemory, inputSize, usedStreaming) { + const parseEndTime = process.hrtime.bigint(); + const finalMemory = process.memoryUsage(); + + const durationMs = Number(parseEndTime - parseStartTime) / 1000000; // Convert nanoseconds to milliseconds + const memoryDeltaMB = Math.round((finalMemory.heapUsed - initialMemory.heapUsed) / 1024 / 1024); + const throughputMBps = inputSize / 1024 / 1024 / (durationMs / 1000); + + debug(`OFX Parse Complete: + Duration: ${durationMs.toFixed(2)}ms + Input Size: ${(inputSize / 1024).toFixed(1)}KB + Memory Delta: ${memoryDeltaMB >= 0 ? '+' : ''}${memoryDeltaMB}MB + Throughput: ${throughputMBps.toFixed(2)}MB/s + Streaming: ${usedStreaming ? 'Yes' : 'No'} + Cache Hits: ${_xmlPatternCache.size}`); + + // Warn if memory usage is excessive + if (memoryDeltaMB > 100) { + debug(`WARNING: High memory usage detected (+${memoryDeltaMB}MB). Consider increasing streaming threshold.`); + } + + // Warn if performance is poor + if (throughputMBps < 1.0) { + debug(`WARNING: Low throughput detected (${throughputMBps.toFixed(2)}MB/s). Performance may be degraded.`); + } +} + +/** + * Optimized header parsing with better error handling + * @private + */ +function _optimizedHeaderParse(headerString) { + const headers = {}; + + for (let i = 0; i < headerString.length; i++) { + const line = headerString[i]; + if (typeof line === 'string' && line.includes(':')) { + const colonIndex = line.indexOf(':'); + const key = line.substring(0, colonIndex).trim(); + const value = line.substring(colonIndex + 1).trim(); + if (key) { + headers[key] = value; + } + } + } + + return headers; +} diff --git a/lib/retry-manager.js b/lib/retry-manager.js new file mode 100644 index 0000000..41a7a4c --- /dev/null +++ b/lib/retry-manager.js @@ -0,0 +1,471 @@ +/*! + * Retry Manager for banking.js + * Provides intelligent retry logic with exponential backoff and jitter + * Optimized for banking operations and various failure types + */ + +const debug = require('debug')('banking:retry'); + +// Constants +const STRINGIFY_SPACE = 2; + +/** + * Default retry configuration optimized for banking operations + */ +const defaultRetryConfig = { + // Maximum retry attempts for different operation types + maxRetries: { + quick: 3, // Account validation, balance checks + standard: 5, // Statement downloads, transaction lists + heavy: 2 // Large date range downloads + }, + + // Base delay in milliseconds for exponential backoff + baseDelay: 1000, + + // Maximum delay cap to prevent excessively long waits + maxDelay: 30000, + + // Backoff strategies + backoffStrategy: 'exponential', // 'exponential', 'linear', 'fixed' + + // Jitter configuration to prevent thundering herd + jitter: { + enabled: true, + type: 'full', // 'full', 'equal', 'decorrelated' + factor: 0.1 // Jitter factor (0.0 to 1.0) + }, + + // Timeout configuration for different operation types + timeouts: { + quick: { + connection: 5000, // 5 seconds for connection establishment + request: 15000, // 15 seconds total request timeout + socket: 10000 // 10 seconds socket timeout + }, + standard: { + connection: 10000, // 10 seconds for connection establishment + request: 60000, // 60 seconds total request timeout + socket: 30000 // 30 seconds socket timeout + }, + heavy: { + connection: 15000, // 15 seconds for connection establishment + request: 120000, // 2 minutes total request timeout + socket: 60000 // 60 seconds socket timeout + } + }, + + // Retry conditions - which errors should trigger retries + retryConditions: { + // Network-level errors that are typically transient + networkErrors: [ + 'ECONNRESET', // Connection reset by peer + 'ETIMEDOUT', // Connection/request timeout + 'ECONNREFUSED', // Connection refused (server down) + 'ENOTFOUND', // DNS resolution failure + 'ENETUNREACH', // Network unreachable + 'EHOSTUNREACH', // Host unreachable + 'EPIPE', // Broken pipe + 'ECONNABORTED' // Connection aborted + ], + + // HTTP status codes that warrant retries + httpStatusCodes: [ + 408, // Request Timeout + 429, // Too Many Requests (rate limiting) + 500, // Internal Server Error + 502, // Bad Gateway + 503, // Service Unavailable + 504, // Gateway Timeout + 507, // Insufficient Storage + 520, // Unknown Error (Cloudflare) + 521, // Web Server Is Down (Cloudflare) + 522, // Connection Timed Out (Cloudflare) + 523, // Origin Is Unreachable (Cloudflare) + 524 // A Timeout Occurred (Cloudflare) + ], + + // SSL/TLS errors that might be transient + sslErrors: [ + 'EPROTO', // SSL protocol error + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', // Certificate verification failed + 'CERT_HAS_EXPIRED', // Certificate expired + 'SSL_HANDSHAKE_FAILURE' // SSL handshake failed + ], + + // Banking-specific OFX error codes that should NOT be retried + nonRetryableOFXCodes: [ + '15500', // Invalid user credentials + '15501', // Customer account already in use + '15502', // Invalid user ID + '15503', // Invalid password + '15505', // Password expired + '15510', // Account suspended + '10500', // Invalid account number + '10401', // Account restricted + '2020', // Invalid date range + '10015' // Unsupported OFX version + ] + }, + + // Rate limiting configuration + rateLimiting: { + enabled: true, + maxConcurrent: 3, // Maximum concurrent requests per host + requestInterval: 500 // Minimum time between requests (ms) + } +}; + +/** + * RetryManager class for handling intelligent retry logic + */ +function RetryManager(config) { + if (!(this instanceof RetryManager)) return new RetryManager(config); + + this.config = this._mergeConfig(defaultRetryConfig, config || {}); + this.metrics = { + totalAttempts: 0, + successfulRetries: 0, + failedRetries: 0, + timeouts: 0, + networkErrors: 0, + httpErrors: 0, + sslErrors: 0, + ofxErrors: 0, + averageAttempts: 0, + totalDelay: 0 + }; + + // Rate limiting state per host + this.rateLimitState = new Map(); + + debug('RetryManager initialized with config:', JSON.stringify(this.config, null, STRINGIFY_SPACE)); +} + +/** + * Deep merge configuration objects + * @param {object} defaultConfig - Default configuration + * @param {object} userConfig - User-provided configuration + * @returns {object} Merged configuration + */ +RetryManager.prototype._mergeConfig = function (defaultConfig, userConfig) { + const merged = JSON.parse(JSON.stringify(defaultConfig)); + + function deepMerge(target, source) { + for (const key in source) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + target[key] = target[key] || {}; + deepMerge(target[key], source[key]); + } else { + target[key] = source[key]; + } + } + } + + deepMerge(merged, userConfig); + return merged; +}; + +/** + * Determine if an error should trigger a retry + * @param {Error} error - The error that occurred + * @param {number} attempt - Current attempt number + * @param {string} operationType - Type of operation (quick, standard, heavy) + * @returns {boolean} Whether the error should be retried + */ +RetryManager.prototype.shouldRetry = function (error, attempt, operationType = 'standard') { + const maxRetries = this.config.maxRetries[operationType] || this.config.maxRetries.standard; + + // Don't retry if we've exceeded max attempts + if (attempt >= maxRetries) { + debug(`Max retries (${maxRetries}) exceeded for ${operationType} operation`); + return false; + } + + // Check for network errors + if (error.code && this.config.retryConditions.networkErrors.includes(error.code)) { + debug(`Network error detected: ${error.code}, will retry`); + this.metrics.networkErrors++; + return true; + } + + // Check for HTTP status codes + if (error.statusCode && this.config.retryConditions.httpStatusCodes.includes(error.statusCode)) { + debug(`HTTP error detected: ${error.statusCode}, will retry`); + this.metrics.httpErrors++; + return true; + } + + // Check for SSL errors + if (error.code && this.config.retryConditions.sslErrors.includes(error.code)) { + debug(`SSL error detected: ${error.code}, will retry`); + this.metrics.sslErrors++; + return true; + } + + // Check for timeout errors + if (error.code === 'ETIMEDOUT' || error.message?.includes('timeout')) { + debug('Timeout error detected, will retry'); + this.metrics.timeouts++; + return true; + } + + // Check for OFX-specific errors that should not be retried + if (error.ofxCode && this.config.retryConditions.nonRetryableOFXCodes.includes(error.ofxCode)) { + debug(`Non-retryable OFX error: ${error.ofxCode}, will not retry`); + this.metrics.ofxErrors++; + return false; + } + + // Default: don't retry unknown errors + debug(`Unknown error type: ${error.code || error.message}, will not retry`); + return false; +}; + +/** + * Calculate delay for next retry attempt with jitter + * @param {number} attempt - Current attempt number (0-based) + * @param {string} operationType - Type of operation + * @returns {number} Delay in milliseconds + */ +RetryManager.prototype.calculateDelay = function (attempt, operationType = 'standard') { + let delay; + + switch (this.config.backoffStrategy) { + case 'exponential': + delay = this.config.baseDelay * Math.pow(2, attempt); + break; + case 'linear': + delay = this.config.baseDelay * (attempt + 1); + break; + case 'fixed': + default: + delay = this.config.baseDelay; + break; + } + + // Apply maximum delay cap + delay = Math.min(delay, this.config.maxDelay); + + // Apply jitter if enabled + if (this.config.jitter.enabled) { + delay = this._applyJitter(delay, attempt); + } + + this.metrics.totalDelay += delay; + debug(`Calculated retry delay: ${delay}ms for attempt ${attempt + 1}`); + return delay; +}; + +/** + * Apply jitter to delay value + * @param {number} delay - Base delay in milliseconds + * @param {number} attempt - Current attempt number + * @returns {number} Jittered delay + */ +RetryManager.prototype._applyJitter = function (delay, attempt) { + const jitterConfig = this.config.jitter; + const JITTER_MULTIPLIER = 2; + const DECORRELATED_MULTIPLIER = 3; + + switch (jitterConfig.type) { + case 'full': { + // Full jitter: random between 0 and delay + return Math.random() * delay; + } + case 'equal': { + // Equal jitter: delay/2 + random(0, delay/2) + const halfDelay = delay / 2; + return halfDelay + Math.random() * halfDelay; + } + case 'decorrelated': { + // Decorrelated jitter: exponential with randomness + const base = this.config.baseDelay; + return Math.random() * Math.min(this.config.maxDelay, base * DECORRELATED_MULTIPLIER * Math.pow(2, attempt)); + } + default: { + // Simple jitter factor + const jitterAmount = delay * jitterConfig.factor; + return delay + (Math.random() * JITTER_MULTIPLIER - 1) * jitterAmount; + } + } +}; + +/** + * Get timeout configuration for operation type + * @param {string} operationType - Type of operation (quick, standard, heavy) + * @returns {object} Timeout configuration + */ +RetryManager.prototype.getTimeoutConfig = function (operationType = 'standard') { + return this.config.timeouts[operationType] || this.config.timeouts.standard; +}; + +/** + * Check rate limiting for a host + * @param {string} hostname - Target hostname + * @returns {number} Delay needed for rate limiting (0 if none) + */ +RetryManager.prototype.checkRateLimit = function (hostname) { + if (!this.config.rateLimiting.enabled) { + return 0; + } + + const now = Date.now(); + const hostState = this.rateLimitState.get(hostname) || { + lastRequest: 0, + activeRequests: 0 + }; + + // Check concurrent request limit + if (hostState.activeRequests >= this.config.rateLimiting.maxConcurrent) { + debug(`Rate limit: max concurrent requests reached for ${hostname}`); + return this.config.rateLimiting.requestInterval; + } + + // Check minimum interval between requests + const timeSinceLastRequest = now - hostState.lastRequest; + if (timeSinceLastRequest < this.config.rateLimiting.requestInterval) { + const delay = this.config.rateLimiting.requestInterval - timeSinceLastRequest; + debug(`Rate limit: enforcing ${delay}ms delay for ${hostname}`); + return delay; + } + + return 0; +}; + +/** + * Record request start for rate limiting + * @param {string} hostname - Target hostname + */ +RetryManager.prototype.recordRequestStart = function (hostname) { + if (!this.config.rateLimiting.enabled) { + return; + } + + const now = Date.now(); + const hostState = this.rateLimitState.get(hostname) || { + lastRequest: 0, + activeRequests: 0 + }; + + hostState.lastRequest = now; + hostState.activeRequests++; + this.rateLimitState.set(hostname, hostState); +}; + +/** + * Record request end for rate limiting + * @param {string} hostname - Target hostname + */ +RetryManager.prototype.recordRequestEnd = function (hostname) { + if (!this.config.rateLimiting.enabled) { + return; + } + + const hostState = this.rateLimitState.get(hostname); + if (hostState && hostState.activeRequests > 0) { + hostState.activeRequests--; + this.rateLimitState.set(hostname, hostState); + } +}; + +/** + * Update retry metrics + * @param {boolean} success - Whether the retry was successful + * @param {number} totalAttempts - Total number of attempts made + */ +RetryManager.prototype.updateMetrics = function (success, totalAttempts) { + this.metrics.totalAttempts += totalAttempts; + + if (success && totalAttempts > 1) { + this.metrics.successfulRetries++; + } else if (!success && totalAttempts > 1) { + this.metrics.failedRetries++; + } + + // Update average attempts + const totalOperations = this.metrics.successfulRetries + this.metrics.failedRetries + (totalAttempts === 1 ? 1 : 0); + if (totalOperations > 0) { + this.metrics.averageAttempts = this.metrics.totalAttempts / totalOperations; + } +}; + +/** + * Get current retry metrics + * @returns {object} Current metrics + */ +RetryManager.prototype.getMetrics = function () { + return Object.assign({}, this.metrics, { + retrySuccessRate: + this.metrics.successfulRetries / Math.max(1, this.metrics.successfulRetries + this.metrics.failedRetries), + averageDelay: this.metrics.totalDelay / Math.max(1, this.metrics.totalAttempts - 1) + }); +}; + +/** + * Reset retry metrics + */ +RetryManager.prototype.resetMetrics = function () { + this.metrics = { + totalAttempts: 0, + successfulRetries: 0, + failedRetries: 0, + timeouts: 0, + networkErrors: 0, + httpErrors: 0, + sslErrors: 0, + ofxErrors: 0, + averageAttempts: 0, + totalDelay: 0 + }; + debug('Retry metrics reset'); +}; + +/** + * Create a retry wrapper function for any async operation + * @param {function} operation - The operation to retry + * @param {object} options - Retry options + * @returns {Promise} Promise that resolves with operation result + */ +RetryManager.prototype.executeWithRetry = function (operation, options = {}) { + const self = this; + const operationType = options.operationType || 'standard'; + const maxRetries = this.config.maxRetries[operationType] || this.config.maxRetries.standard; + + return new Promise((resolve, reject) => { + let attempt = 0; + + function tryOperation() { + attempt++; + self.metrics.totalAttempts++; + + debug(`Executing operation attempt ${attempt}/${maxRetries + 1}`); + + Promise.resolve(operation(attempt)) + .then(result => { + debug(`Operation succeeded on attempt ${attempt}`); + self.updateMetrics(true, attempt); + return resolve(result); + }) + .catch(error => { + debug(`Operation failed on attempt ${attempt}:`, error.message); + + if (self.shouldRetry(error, attempt - 1, operationType)) { + const delay = self.calculateDelay(attempt - 1, operationType); + debug(`Retrying operation in ${delay}ms`); + + setTimeout(tryOperation, delay); + } else { + debug(`Not retrying operation after ${attempt} attempts`); + self.updateMetrics(false, attempt); + reject(error); + } + }); + } + + tryOperation(); + }); +}; + +// Export the RetryManager class +module.exports = RetryManager; diff --git a/lib/utils.js b/lib/utils.js index 8d675ee..a4edf92 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,5 +1,14 @@ -var tls = require('tls'); -var url = require('url'); +const tls = require('tls'); +const url = require('url'); +const ConnectionPool = require('./connection-pool'); +const RetryManager = require('./retry-manager'); +const CacheManager = require('./cache-manager'); +const debug = require('debug')('banking:utils'); + +// Constants +const STRINGIFY_SPACE = 2; +const HTTPS_PORT = 443; +const HTTP_PORT = 80; /** * Unique Id Generator @@ -10,29 +19,253 @@ var url = require('url'); * @api private */ -var Util = module.exports = {}; +const Util = (module.exports = {}); + +// Global connection pool instance +let globalPool = null; +// Global retry manager instance +let globalRetryManager = null; +// Global cache manager instance +let globalCacheManager = null; + +/** + * Configure connection pooling and retry settings for banking operations + * @param {object} config - Combined pool and retry configuration options + * @param {object} config.pool - Connection pool specific settings + * @param {object} config.retry - Retry manager specific settings + * @param {object} config.timeouts - Operation-specific timeout configurations + * @returns {object} Applied configuration + */ +Util.configurePool = function (config) { + debug('Configuring connection pool with config:', JSON.stringify(config, null, STRINGIFY_SPACE)); + + if (globalPool) { + globalPool.destroy(); + } + + // Handle both old and new API formats + let poolConfig; + if (config && (config.pool || config.retry || config.timeouts)) { + // New API format with structured config + poolConfig = Object.assign({}, config.pool || {}); + if (config.retry) { + poolConfig.retryManager = config.retry; + } + if (config.timeouts) { + poolConfig.timeouts = config.timeouts; + } + } else { + // Old API format - direct pool config + poolConfig = Object.assign({}, config || {}); + } + + globalPool = new ConnectionPool(poolConfig); + + // Return appropriate format based on input + if (config && (config.pool || config.retry || config.timeouts)) { + return { + pool: globalPool.config, + retry: globalPool.retryManager ? globalPool.retryManager.config : null + }; + } else { + // Backward compatibility - return the pool config directly + return globalPool.config; + } +}; + +/** + * Configure retry policies for banking operations + * @param {object} config - Retry configuration options + * @returns {object} Applied retry configuration + */ +Util.configureRetry = function (config) { + debug('Configuring retry manager with config:', JSON.stringify(config, null, STRINGIFY_SPACE)); + + if (globalRetryManager) { + globalRetryManager.resetMetrics(); + } + + globalRetryManager = new RetryManager(config); + + // If pool exists, update its retry manager + if (globalPool) { + globalPool.retryManager = globalRetryManager; + } + + return globalRetryManager.config; +}; + +/** + * Configure timeout settings for different operation types + * @param {object} timeouts - Timeout configuration by operation type + * @returns {object} Applied timeout configuration + */ +Util.configureTimeouts = function (timeouts) { + debug('Configuring timeouts:', JSON.stringify(timeouts, null, STRINGIFY_SPACE)); + + if (!globalPool) { + globalPool = new ConnectionPool(); + } + + // Update timeout configuration + Object.assign(globalPool.config.timeouts, timeouts); + + return globalPool.config.timeouts; +}; + +/** + * Get connection pool metrics including retry metrics + * @returns {object} Current pool metrics + */ +Util.getPoolMetrics = function () { + if (!globalPool) return null; + return globalPool.getMetrics(); +}; + +/** + * Get retry metrics + * @returns {object} Current retry metrics or null + */ +Util.getRetryMetrics = function () { + if (globalPool && globalPool.retryManager) { + return globalPool.retryManager.getMetrics(); + } + if (globalRetryManager) { + return globalRetryManager.getMetrics(); + } + return null; +}; + +/** + * Reset retry metrics + */ +Util.resetRetryMetrics = function () { + if (globalPool && globalPool.retryManager) { + globalPool.retryManager.resetMetrics(); + } + if (globalRetryManager) { + globalRetryManager.resetMetrics(); + } +}; + +/** + * Configure caching for banking operations + * @param {object} config - Cache configuration options + * @returns {object} Applied cache configuration + */ +Util.configureCache = function (config) { + debug('Configuring cache manager with config:', JSON.stringify(config, null, STRINGIFY_SPACE)); + + if (globalCacheManager) { + globalCacheManager.destroy(); + } + + globalCacheManager = new CacheManager(config); + return globalCacheManager.config; +}; + +/** + * Get cache metrics and statistics + * @returns {object} Current cache metrics or null if caching is not enabled + */ +Util.getCacheMetrics = function () { + if (!globalCacheManager) return null; + return globalCacheManager.getMetrics(); +}; + +/** + * Reset cache metrics (useful for testing or monitoring) + */ +Util.resetCacheMetrics = function () { + if (globalCacheManager) { + globalCacheManager.resetMetrics(); + } +}; + +/** + * Clear all cached data + * @returns {number} Number of entries cleared + */ +Util.clearCache = function () { + if (!globalCacheManager) return 0; + return globalCacheManager.clear(); +}; + +/** + * Invalidate cache entries for specific operation + * @param {string} operation - Operation type to invalidate + * @param {object} [params] - Specific parameters to invalidate (optional) + * @returns {number} Number of entries invalidated + */ +Util.invalidateCache = function (operation, params = null) { + if (!globalCacheManager) return 0; + return globalCacheManager.invalidate(operation, params); +}; + +/** + * Destroy the connection pool, retry manager, and cache manager, clean up resources + */ +Util.destroyPool = function () { + if (globalPool) { + globalPool.destroy(); + globalPool = null; + } + if (globalRetryManager) { + globalRetryManager.resetMetrics(); + globalRetryManager = null; + } + if (globalCacheManager) { + globalCacheManager.destroy(); + globalCacheManager = null; + } +}; + +/** + * Get or create the global connection pool + * @returns {ConnectionPool} The global pool instance + */ +function getPool() { + if (!globalPool) { + debug('Creating default connection pool'); + globalPool = new ConnectionPool(); + } + return globalPool; +} + +/** + * Get or create the global cache manager + * @returns {CacheManager} The global cache manager instance + */ +function getCacheManager() { + if (!globalCacheManager) { + debug('Creating default cache manager'); + globalCacheManager = new CacheManager(); + } + return globalCacheManager; +} -Util.uuid = function(len,radix) { - var CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split(''); - var chars = CHARS, uuid = []; - radix = radix || chars.length; +Util.uuid = function (len, radix) { + const CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split(''); + const chars = CHARS, + uuid = []; + radix = radix || chars.length; - if (len) { - for (var i = 0; i < len; i++) uuid[i] = chars[0 | Math.random()*radix]; - } else { - var r; - uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-'; - uuid[14] = '4'; + if (len) { + for (let i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)]; + } else { + let r; + uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-'; + uuid[14] = '4'; - for (var i = 0; i < 36; i++) { - if (!uuid[i]) { - r = 0 | Math.random()*16; - uuid[i] = chars[(i == 19) ? (r & 0x3) | 0x8 : r]; - } + for (let j = 0; j < 36; j++) { + if (!uuid[j]) { + r = 0 | (Math.random() * 16); + uuid[j] = chars[j === 19 ? (r & 0x3) | 0x8 : r]; } } + } - return uuid.join(''); + return uuid.join(''); }; /** @@ -43,28 +276,92 @@ Util.uuid = function(len,radix) { */ Util.mixin = function (base, obj) { - for (var key in base) { - obj[key] = (obj[key]) ? obj[key] : base[key]; + for (const key in base) { + obj[key] = obj[key] ? obj[key] : base[key]; } return obj; }; /** * Makes a secure request to an ofx server and posts an OFX payload + * Uses connection pooling with advanced timeout and retry logic, plus caching + * @param options - Request options including url, headers, operationType, etc. + * @param ofxPayload - OFX payload to send + * @param cb - Callback function (error, response) + */ +Util.request = function (options, ofxPayload, cb) { + // Check if caching should be used + const useCache = options.useCache !== false; // Default to true unless explicitly disabled + const cacheOperation = options.cacheOperation || 'statement'; + const cacheParams = options.cacheParams || {}; + + // Check if connection pooling should be used + const usePooling = options.usePooling !== false; // Default to true unless explicitly disabled + + debug('Making request with options:', { + url: options.url, + operationType: options.operationType, + usePooling: usePooling, + useCache: useCache, + cacheOperation: cacheOperation + }); + + // Try to get cached response first + if (useCache) { + const cacheManager = getCacheManager(); + const cachedResponse = cacheManager.get(cacheOperation, cacheParams); + + if (cachedResponse) { + debug('Returning cached response for operation:', cacheOperation); + return process.nextTick(() => cb(null, cachedResponse)); + } + } + + // Make the actual request + const performRequest = (err, response) => { + if (err) return cb(err, response); + + // Cache the successful response + if (useCache && response) { + try { + const cacheManager = getCacheManager(); + cacheManager.set(cacheOperation, cacheParams, response); + debug('Cached response for operation:', cacheOperation); + } catch (cacheError) { + debug('Cache set error (non-fatal):', cacheError.message); + // Cache errors are non-fatal, continue with the response + } + } + + cb(err, response); + }; + + if (usePooling) { + // Use connection pooling for better performance with retry logic + const pool = getPool(); + pool.request(options, ofxPayload, performRequest); + } else { + // Fall back to legacy TLS socket implementation + Util.requestLegacy(options, ofxPayload, performRequest); + } +}; + +/** + * Legacy TLS socket implementation (for backward compatibility) * @param options * @param ofxPayload * @param cb */ -Util.request = function(options, ofxPayload, cb) { - var parsedUrl = url.parse(options.url); - var tlsOpts = { - port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), +Util.requestLegacy = function (options, ofxPayload, cb) { + const parsedUrl = url.parse(options.url); + const tlsOpts = { + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? HTTPS_PORT : HTTP_PORT), host: parsedUrl.hostname }; - var socket = tls.connect(tlsOpts, function() { - var buffer = 'POST ' + parsedUrl.path + ' HTTP/1.1\r\n'; - options.headers.forEach(function(header) { - var value; + const socket = tls.connect(tlsOpts, () => { + let buffer = `POST ${parsedUrl.path} HTTP/1.1\r\n`; + options.headers.forEach(header => { + let value; if (options[header]) { value = options[header]; } else if (header === 'Content-Length') { @@ -72,20 +369,20 @@ Util.request = function(options, ofxPayload, cb) { } else if (header === 'Host') { value = parsedUrl.host; } - buffer += header + ': ' + value + '\r\n'; + buffer += `${header}: ${value}\r\n`; }); buffer += '\r\n'; buffer += ofxPayload; socket.write(buffer); }); - var data = ''; - socket.on('data', function(chunk) { + let data = ''; + socket.on('data', chunk => { data += chunk; }); - socket.on('end', function() { - var error = true; - var httpHeaderMatcher = new RegExp(/HTTP\/\d\.\d (\d{3}) (.*)/); - var matches = httpHeaderMatcher.exec(data); + socket.on('end', () => { + let error = true; + const httpHeaderMatcher = new RegExp(/HTTP\/\d\.\d (\d{3}) (.*)/); + const matches = httpHeaderMatcher.exec(data); if (matches && matches.length > 2) { if (parseInt(matches[1], 10) === 200) { error = false; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dd8a47c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5108 @@ +{ + "name": "banking", + "version": "1.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "banking", + "version": "1.2.0", + "dependencies": { + "debug": "^2.3.3", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@stylistic/eslint-plugin": "^5.3.1", + "@types/node": "^24.3.3", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-n": "^17.21.3", + "eslint-plugin-promise": "^7.2.1", + "eslint-plugin-security": "^3.0.1", + "husky": "^9.1.7", + "lint-staged": "^16.1.6", + "mocha": "^11.7.2", + "msw": "^2.11.2", + "nock": "^14.0.10", + "prettier": "^3.6.2", + "should": "^11.1.1", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cookie": "^0.7.2" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "dev": true, + "license": "ISC", + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.35.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.16.tgz", + "integrity": "sha512-j1a5VstaK5KQy8Mu8cHmuQvN1Zc62TbLhjJxwHvKPPKEoowSF6h/0UdOpA9DNdWZ+9Inq73+puRq1df6OJ8Sag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.0.tgz", + "integrity": "sha512-NyDSjPqhSvpZEMZrLCYUquWNl+XC/moEcVFqS55IEYIYsY0a1cUCevSqk7ctOlnm/RaSBU5psFryNlxcmGrjaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@inquirer/core/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", + "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", + "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.39.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.6.tgz", + "integrity": "sha512-bndDP83naYYkfayr/qhBHMhk0YGwS1iv6vaEGcr0SQbO0IZtbOPqjKjds/WcG+bJA+1T5vCx6kprKOzn5Bg+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz", + "integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz", + "integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz", + "integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz", + "integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz", + "integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz", + "integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz", + "integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz", + "integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz", + "integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz", + "integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz", + "integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz", + "integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz", + "integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz", + "integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz", + "integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz", + "integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz", + "integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz", + "integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz", + "integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz", + "integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz", + "integrity": "sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/types": "^8.41.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.3.tgz", + "integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.43.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", + "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.14", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.2.4" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.5.tgz", + "integrity": "sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.30", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.35.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.35.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-n": { + "version": "17.21.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.5.0", + "enhanced-resolve": "^5.17.1", + "eslint-plugin-es-x": "^7.8.0", + "get-tsconfig": "^4.8.1", + "globals": "^15.11.0", + "globrex": "^0.1.2", + "ignore": "^5.3.2", + "semver": "^7.6.3", + "ts-declaration-location": "^1.0.6" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": ">=8.23.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/globals": { + "version": "15.15.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-promise": { + "version": "7.2.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-security": { + "version": "3.0.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/husky": { + "version": "9.1.7", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lint-staged": { + "version": "16.1.6", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.0", + "commander": "^14.0.0", + "debug": "^4.4.1", + "lilconfig": "^3.1.3", + "listr2": "^9.0.3", + "micromatch": "^4.0.8", + "nano-spawn": "^1.0.2", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/debug": { + "version": "4.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/lint-staged/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "9.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "11.7.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.2.tgz", + "integrity": "sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mocha/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.2.tgz", + "integrity": "sha512-MI54hLCsrMwiflkcqlgYYNJJddY5/+S0SnONvhv1owOplvqohKSQyGejpNdUGyCwgs4IH7PqaNbPw/sKOEze9Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.39.1", + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^4.26.1", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nano-spawn": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/nock": { + "version": "14.0.10", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.10.tgz", + "integrity": "sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.39.5", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", + "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.50.1", + "@rollup/rollup-android-arm64": "4.50.1", + "@rollup/rollup-darwin-arm64": "4.50.1", + "@rollup/rollup-darwin-x64": "4.50.1", + "@rollup/rollup-freebsd-arm64": "4.50.1", + "@rollup/rollup-freebsd-x64": "4.50.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.1", + "@rollup/rollup-linux-arm-musleabihf": "4.50.1", + "@rollup/rollup-linux-arm64-gnu": "4.50.1", + "@rollup/rollup-linux-arm64-musl": "4.50.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.50.1", + "@rollup/rollup-linux-ppc64-gnu": "4.50.1", + "@rollup/rollup-linux-riscv64-gnu": "4.50.1", + "@rollup/rollup-linux-riscv64-musl": "4.50.1", + "@rollup/rollup-linux-s390x-gnu": "4.50.1", + "@rollup/rollup-linux-x64-gnu": "4.50.1", + "@rollup/rollup-linux-x64-musl": "4.50.1", + "@rollup/rollup-openharmony-arm64": "4.50.1", + "@rollup/rollup-win32-arm64-msvc": "4.50.1", + "@rollup/rollup-win32-ia32-msvc": "4.50.1", + "@rollup/rollup-win32-x64-msvc": "4.50.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, + "node_modules/sax": { + "version": "1.4.1", + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/should": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/should/-/should-11.2.1.tgz", + "integrity": "sha512-TsX7f9e6f7h+ijEJwga7baYTCspzs0KJRJafafCqiPpgp2/eZ1b7LK3RNZZ5uIK8J0nhFOXVvpbcRwy09piO4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-equal": "^1.0.0", + "should-format": "^3.0.2", + "should-type": "^1.4.0", + "should-type-adaptors": "^1.0.1", + "should-util": "^1.0.0" + } + }, + "node_modules/should-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-1.0.1.tgz", + "integrity": "sha512-V78BKdBq92TbvEHjVJRwWItXtV6G813dMBviJZ01sn57sdOqb8pJUPHZhs9vUa8e6wQOHzPW7RTWv5wFFyJYmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.0.0" + } + }, + "node_modules/should-format": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", + "integrity": "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.3.0", + "should-type-adaptors": "^1.0.1" + } + }, + "node_modules/should-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", + "integrity": "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/should-type-adaptors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", + "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.3.0", + "should-util": "^1.0.0" + } + }, + "node_modules/should-util": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz", + "integrity": "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tapable": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.14.tgz", + "integrity": "sha512-lMNHE4aSI3LlkMUMicTmAG3tkkitjOQGDTFboPJwAg2kJXKP1ryWEyqujktg5qhrFZOkk5YFzgkxg3jErE+i5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.14" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.14.tgz", + "integrity": "sha512-viZGNK6+NdluOJWwTO9olaugx0bkKhscIdriQQ+lNNhwitIKvb+SvhbYgnCz6j9p7dX3cJntt4agQAKMXLjJ5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ts-declaration-location": { + "version": "1.0.7", + "dev": true, + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/ts-declaration-location" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "picomatch": "^4.0.2" + }, + "peerDependencies": { + "typescript": ">=4.0.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", + "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.8.1", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index 5006346..0e16204 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "ofx", "financial", "bank", - "quickbooks" + "quickbooks", + "typescript", + "types" ], "private": false, "homepage": "http://euforic.github.com/banking.js", @@ -16,17 +18,54 @@ "debug": "^2.3.3", "xml2js": "^0.6.2" }, - "devDependencies": { - "mocha": "^3.2.0", - "should": "^11.1.1" - }, "repository": { "type": "git", "url": "http://github.com/euforic/banking.js.git" }, - "main": "lib/banking.js", + "main": "index.js", + "types": "index.d.ts", + "engines": { + "node": ">=18.0.0" + }, "scripts": { "prepublish": "npm prune", - "test": "mocha --require should --reporter spec --globals i" + "test": "vitest run", + "test:watch": "vitest watch", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", + "test:integration": "vitest run test/integration/", + "test:legacy": "mocha --require should --reporter spec --globals i test/parsing.js", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:check": "eslint . --max-warnings 200", + "format": "prettier --write .", + "format:check": "prettier --check .", + "format:diff": "prettier --check --list-different .", + "validate": "npm run format:check && npm run lint:check", + "fix": "npm run format && npm run lint:fix", + "pretest": "npm run validate", + "prepare": "husky" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@stylistic/eslint-plugin": "^5.3.1", + "@types/node": "^24.3.3", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-n": "^17.21.3", + "eslint-plugin-promise": "^7.2.1", + "eslint-plugin-security": "^3.0.1", + "husky": "^9.1.7", + "lint-staged": "^16.1.6", + "mocha": "^11.7.2", + "msw": "^2.11.2", + "nock": "^14.0.10", + "prettier": "^3.6.2", + "should": "^11.1.1", + "typescript": "^5.9.2", + "vitest": "^3.2.4" } } diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..f4de82c --- /dev/null +++ b/test/README.md @@ -0,0 +1,306 @@ +# Banking.js Integration Test Suite + +This directory contains comprehensive integration tests for the Banking.js +library, providing reliable testing coverage for OFX connectivity and parsing +across multiple major financial institutions. + +## Overview + +The test suite includes: + +- **Integration tests** for 5 major banks (Wells Fargo, Discover, Chase, Bank of + America, US Bank) +- **Comprehensive error handling** tests for network, OFX protocol, and parsing + errors +- **Edge case testing** for boundary conditions, special characters, and unusual + scenarios +- **Mock HTTP server** setup for reliable CI/CD testing +- **Sandbox mode support** for testing against real bank environments + +## Quick Start + +```bash +# Run all integration tests (mock mode) +npm test + +# Run integration tests only +npm run test:integration + +# Run with coverage report +npm run test:coverage + +# Run in watch mode for development +npm run test:watch + +# Open Vitest UI +npm run test:ui + +# Run legacy Mocha tests +npm run test:legacy +``` + +## Test Structure + +``` +test/ +ā”œā”€ā”€ README.md # This file +ā”œā”€ā”€ setup.js # Global test setup and configuration +ā”œā”€ā”€ test-runner.js # Custom test runner with advanced options +ā”œā”€ā”€ fixtures/ +│ ā”œā”€ā”€ data.js # Legacy test data +│ ā”œā”€ā”€ responses.js # Mock OFX responses for all banks +│ ā”œā”€ā”€ sample.ofx # Sample OFX files +│ └── sample-with-end-tags.ofx +ā”œā”€ā”€ integration/ +│ ā”œā”€ā”€ wells-fargo.test.js # Wells Fargo integration tests +│ ā”œā”€ā”€ discover.test.js # Discover Financial tests +│ ā”œā”€ā”€ chase.test.js # Chase Bank tests +│ ā”œā”€ā”€ bank-of-america.test.js # Bank of America tests +│ ā”œā”€ā”€ us-bank.test.js # US Bank tests +│ ā”œā”€ā”€ error-handling.test.js # Comprehensive error scenarios +│ └── edge-cases.test.js # Edge cases and boundary testing +└── parsing.js # Legacy parsing tests (Mocha) +``` + +## Test Modes + +### Mock Mode (Default) + +Uses [nock](https://github.com/nock/nock) to intercept HTTP requests and return +predefined responses. This mode: + +- āœ… Runs completely offline +- āœ… Fast and reliable for CI/CD +- āœ… No real bank credentials required +- āœ… Tests OFX parsing and error handling + +### Sandbox Mode + +Attempts to connect to real bank sandbox environments (where available): + +- šŸ–ļø Tests actual network connectivity +- šŸ–ļø Validates real OFX endpoints +- āš ļø Requires sandbox credentials +- āš ļø May be slower and less reliable + +```bash +# Run in sandbox mode +node test/test-runner.js --sandbox + +# Run specific bank in sandbox mode +node test/test-runner.js --sandbox --bank wells-fargo +``` + +## Supported Banks + +Each bank has comprehensive test coverage including: + +| Bank | FID | Test Coverage | Sandbox Support | +| ------------------ | ---- | ------------- | --------------- | +| Wells Fargo | 3001 | āœ… Full | šŸ”¶ Limited | +| Discover Financial | 7101 | āœ… Full | šŸ”¶ Limited | +| Chase Bank | 636 | āœ… Full | āŒ No | +| Bank of America | 5959 | āœ… Full | āŒ No | +| US Bank | 1001 | āœ… Full | šŸ”¶ Limited | + +### Test Coverage Per Bank + +- āœ… Statement retrieval (`getStatement`) +- āœ… Account listing (`getAccounts`) +- āœ… Authentication error handling +- āœ… Network error scenarios +- āœ… OFX parsing validation +- āœ… Bank-specific features +- āœ… Edge cases and boundary conditions + +## Advanced Usage + +### Custom Test Runner + +The `test-runner.js` script provides advanced options: + +```bash +# Show all available options +node test/test-runner.js --help + +# Run specific bank with verbose output +node test/test-runner.js --bank chase --verbose + +# Run with coverage and timeout adjustment +node test/test-runner.js --coverage --timeout 60000 + +# Serial execution for debugging +node test/test-runner.js --serial --verbose +``` + +### Environment Variables + +Configure test behavior with environment variables: + +```bash +# Enable debug logging +DEBUG=banking:* npm test + +# Set custom timeout +BANKING_REQUEST_TIMEOUT=30000 npm test + +# CI mode (affects output format) +CI=true npm test + +# Sandbox mode with credentials +BANKING_TEST_MODE=sandbox npm test +``` + +## Writing New Tests + +### Adding a New Bank + +1. **Create test file**: `test/integration/new-bank.test.js` +2. **Add bank config**: Update `bankConfigs` in `fixtures/responses.js` +3. **Create mock responses**: Add OFX response fixtures +4. **Update test runner**: Add bank name to `supportedBanks` array + +### Test Structure Template + +```javascript +import { describe, it, expect, beforeEach } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; +import { bankConfigs } from '../fixtures/responses.js'; + +describe('New Bank Integration Tests', () => { + let banking; + + beforeEach(() => { + banking = new Banking(bankConfigs.newBank); + }); + + describe('getStatement', () => { + it('should successfully retrieve statement', done => { + nock('https://bank-ofx-endpoint.com') + .post('/ofx') + .reply(200, mockResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + // Add specific assertions + done(); + }); + }); + }); +}); +``` + +## Best Practices + +### Test Isolation + +- Each test cleans up HTTP mocks automatically +- No shared state between tests +- Deterministic test execution + +### Error Handling + +- Test both success and failure scenarios +- Verify specific error codes and messages +- Test network timeouts and retries + +### Mock Responses + +- Use realistic OFX data structures +- Include edge cases in fixtures +- Maintain consistency with real bank responses + +### Performance + +- Tests run in parallel by default +- Use `--serial` flag for debugging +- Timeout configuration for slow connections + +## Troubleshooting + +### Common Issues + +**Tests failing with connection errors:** + +```bash +# Check if nock is properly mocking requests +DEBUG=nock* npm test +``` + +**Timeout errors:** + +```bash +# Increase timeout +node test/test-runner.js --timeout 60000 +``` + +**Parsing errors:** + +```bash +# Run with verbose output +node test/test-runner.js --verbose +``` + +### Debugging + +1. **Enable debug logging:** + + ```bash + DEBUG=banking:* npm test + ``` + +2. **Run single test file:** + + ```bash + npx vitest run test/integration/wells-fargo.test.js + ``` + +3. **Use Vitest UI for interactive debugging:** + ```bash + npm run test:ui + ``` + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +- name: Run Integration Tests + run: | + npm run test:coverage + env: + CI: true + NODE_ENV: test +``` + +### Test Reports + +Coverage reports are generated in: + +- `coverage/` directory (HTML) +- Console output (text) +- JSON format for CI tools + +## Contributing + +When contributing new tests: + +1. Follow existing patterns and structure +2. Add both positive and negative test cases +3. Include comprehensive error handling +4. Update this README if adding new features +5. Ensure all tests pass in both mock and sandbox modes + +## Security Notes + +- **Never commit real bank credentials** +- Use environment variables for sandbox testing +- Mock sensitive data in fixtures +- Follow responsible disclosure for security issues + +--- + +For more information about the Banking.js library, see the main +[README.md](../README.md). diff --git a/test/fixtures/data.js b/test/fixtures/data.js index c824cae..0180487 100644 --- a/test/fixtures/data.js +++ b/test/fixtures/data.js @@ -1 +1,3 @@ -exports.ofxString = unescape('OFXHEADER%3A100%0ADATA%3AOFXSGML%0AVERSION%3A102%0ASECURITY%3ANONE%0AENCODING%3AUSASCII%0ACHARSET%3A1252%0ACOMPRESSION%3ANONE%0AOLDFILEUID%3ANONE%0ANEWFILEUID%3ANONE%0A%0A%3COFX%3E%0A%20%20%3CSIGNONMSGSRSV1%3E%0A%20%20%20%20%3CSONRS%3E%0A%20%20%20%20%20%20%3CSTATUS%3E%0A%20%20%20%20%20%20%20%20%3CCODE%3E0%0A%20%20%20%20%20%20%20%20%3CSEVERITY%3EINFO%0A%20%20%20%20%20%20%3C/STATUS%3E%0A%20%20%20%20%20%20%3CDTSERVER%3E20120127235919.500%0A%20%20%20%20%20%20%3CLANGUAGE%3EENG%0A%20%20%20%20%20%20%3CDTPROFUP%3E20050531070000.000%0A%20%20%20%20%20%20%3CFI%3E%0A%20%20%20%20%20%20%20%20%3CORG%3EWFB%0A%20%20%20%20%20%20%20%20%3CFID%3E3000%0A%20%20%20%20%20%20%3C/FI%3E%0A%20%20%20%20%20%20%3CINTU.BID%3E3000%0A%20%20%20%20%20%20%3CINTU.USERID%3Exxx34tf%0A%20%20%20%20%3C/SONRS%3E%0A%20%20%3C/SIGNONMSGSRSV1%3E%0A%20%20%3CBANKMSGSRSV1%3E%0A%20%20%20%20%3CSTMTTRNRS%3E%0A%20%20%20%20%20%20%3CTRNUID%3E0%0A%20%20%20%20%20%20%3CSTATUS%3E%0A%20%20%20%20%20%20%20%20%3CCODE%3E0%0A%20%20%20%20%20%20%20%20%3CSEVERITY%3EINFO%0A%20%20%20%20%20%20%3C/STATUS%3E%0A%20%20%20%20%20%20%3CSTMTRS%3E%0A%20%20%20%20%20%20%20%20%3CCURDEF%3EUSD%0A%20%20%20%20%20%20%20%20%3CBANKACCTFROM%3E%0A%20%20%20%20%20%20%20%20%20%20%3CBANKID%3E000000000%0A%20%20%20%20%20%20%20%20%20%20%3CACCTID%3E1234567890%0A%20%20%20%20%20%20%20%20%20%20%3CACCTTYPE%3ECHECKING%0A%20%20%20%20%20%20%20%20%3C/BANKACCTFROM%3E%0A%20%20%20%20%20%20%20%20%3CBANKTRANLIST%3E%0A%20%20%20%20%20%20%20%20%20%20%3CDTSTART%3E20120101080000.000%0A%20%20%20%20%20%20%20%20%20%20%3CDTEND%3E20120126080000.000%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120103120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-49.95%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201031%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EPLANET%20BEACH%20AL001%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3ERECUR%20DEBIT%20CRD%20PMT0%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDIRECTDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120105120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-39.00%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201053%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3ECITY%20OF%20FAIRHOPE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EUTILITIES%2024-028490%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3ECREDIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120105120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E916.01%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201054%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EFROM%20CREDIT%20CARD%20OR%20LINE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EOVERDRAFT%20XFER%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDIRECTDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120105120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-3408.83%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201051%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3ECITY%20OF%20FAIRHOPE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EUTILITIES%2024-002394%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDIRECTDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120105120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-48.36%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201052%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3ECITY%20OF%20FAIRHOPE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EUTILITIES%2024-002393%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDIRECTDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120109120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-114.70%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201091%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EATT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EPayment%20JUAN%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3ECREDIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120109120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E114.70%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201092%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EFROM%20CREDIT%20CARD%20OR%20LINE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EOVERDRAFT%20XFER%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3ECREDIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120112120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E180.69%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201122%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EFROM%20CREDIT%20CARD%20OR%20LINE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EOVERDRAFT%20XFER%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDIRECTDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120112120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-180.69%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201121%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EVERIZON%20WIRELESS%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EPAYMENTS%20120112%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EFEE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120125120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-9.00%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201251%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EMONTHLY%20SERVICE%20FEE%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3ECREDIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120126120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E25.00%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201261%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EFROM%20CREDIT%20CARD%20OR%20LINE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EOVERDRAFT%20XFER%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%3C/BANKTRANLIST%3E%0A%20%20%20%20%20%20%20%20%3CLEDGERBAL%3E%0A%20%20%20%20%20%20%20%20%20%20%3CBALAMT%3E16.00%0A%20%20%20%20%20%20%20%20%20%20%3CDTASOF%3E20120126080000.000%5B0%3AGMT%5D%0A%20%20%20%20%20%20%20%20%3C/LEDGERBAL%3E%0A%20%20%20%20%20%20%20%20%3CAVAILBAL%3E%0A%20%20%20%20%20%20%20%20%20%20%3CBALAMT%3E16.00%0A%20%20%20%20%20%20%20%20%20%20%3CDTASOF%3E20120126080000.000%5B0%3AGMT%5D%0A%20%20%20%20%20%20%20%20%3C/AVAILBAL%3E%0A%20%20%20%20%20%20%3C/STMTRS%3E%0A%20%20%20%20%3C/STMTTRNRS%3E%0A%20%20%3C/BANKMSGSRSV1%3E%0A%3C/OFX%3E'); \ No newline at end of file +exports.ofxString = unescape( + 'OFXHEADER%3A100%0ADATA%3AOFXSGML%0AVERSION%3A102%0ASECURITY%3ANONE%0AENCODING%3AUSASCII%0ACHARSET%3A1252%0ACOMPRESSION%3ANONE%0AOLDFILEUID%3ANONE%0ANEWFILEUID%3ANONE%0A%0A%3COFX%3E%0A%20%20%3CSIGNONMSGSRSV1%3E%0A%20%20%20%20%3CSONRS%3E%0A%20%20%20%20%20%20%3CSTATUS%3E%0A%20%20%20%20%20%20%20%20%3CCODE%3E0%0A%20%20%20%20%20%20%20%20%3CSEVERITY%3EINFO%0A%20%20%20%20%20%20%3C/STATUS%3E%0A%20%20%20%20%20%20%3CDTSERVER%3E20120127235919.500%0A%20%20%20%20%20%20%3CLANGUAGE%3EENG%0A%20%20%20%20%20%20%3CDTPROFUP%3E20050531070000.000%0A%20%20%20%20%20%20%3CFI%3E%0A%20%20%20%20%20%20%20%20%3CORG%3EWFB%0A%20%20%20%20%20%20%20%20%3CFID%3E3000%0A%20%20%20%20%20%20%3C/FI%3E%0A%20%20%20%20%20%20%3CINTU.BID%3E3000%0A%20%20%20%20%20%20%3CINTU.USERID%3Exxx34tf%0A%20%20%20%20%3C/SONRS%3E%0A%20%20%3C/SIGNONMSGSRSV1%3E%0A%20%20%3CBANKMSGSRSV1%3E%0A%20%20%20%20%3CSTMTTRNRS%3E%0A%20%20%20%20%20%20%3CTRNUID%3E0%0A%20%20%20%20%20%20%3CSTATUS%3E%0A%20%20%20%20%20%20%20%20%3CCODE%3E0%0A%20%20%20%20%20%20%20%20%3CSEVERITY%3EINFO%0A%20%20%20%20%20%20%3C/STATUS%3E%0A%20%20%20%20%20%20%3CSTMTRS%3E%0A%20%20%20%20%20%20%20%20%3CCURDEF%3EUSD%0A%20%20%20%20%20%20%20%20%3CBANKACCTFROM%3E%0A%20%20%20%20%20%20%20%20%20%20%3CBANKID%3E000000000%0A%20%20%20%20%20%20%20%20%20%20%3CACCTID%3E1234567890%0A%20%20%20%20%20%20%20%20%20%20%3CACCTTYPE%3ECHECKING%0A%20%20%20%20%20%20%20%20%3C/BANKACCTFROM%3E%0A%20%20%20%20%20%20%20%20%3CBANKTRANLIST%3E%0A%20%20%20%20%20%20%20%20%20%20%3CDTSTART%3E20120101080000.000%0A%20%20%20%20%20%20%20%20%20%20%3CDTEND%3E20120126080000.000%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120103120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-49.95%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201031%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EPLANET%20BEACH%20AL001%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3ERECUR%20DEBIT%20CRD%20PMT0%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDIRECTDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120105120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-39.00%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201053%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3ECITY%20OF%20FAIRHOPE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EUTILITIES%2024-028490%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3ECREDIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120105120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E916.01%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201054%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EFROM%20CREDIT%20CARD%20OR%20LINE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EOVERDRAFT%20XFER%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDIRECTDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120105120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-3408.83%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201051%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3ECITY%20OF%20FAIRHOPE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EUTILITIES%2024-002394%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDIRECTDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120105120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-48.36%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201052%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3ECITY%20OF%20FAIRHOPE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EUTILITIES%2024-002393%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDIRECTDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120109120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-114.70%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201091%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EATT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EPayment%20JUAN%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3ECREDIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120109120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E114.70%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201092%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EFROM%20CREDIT%20CARD%20OR%20LINE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EOVERDRAFT%20XFER%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3ECREDIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120112120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E180.69%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201122%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EFROM%20CREDIT%20CARD%20OR%20LINE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EOVERDRAFT%20XFER%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EDIRECTDEBIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120112120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-180.69%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201121%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EVERIZON%20WIRELESS%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EPAYMENTS%20120112%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3EFEE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120125120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E-9.00%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201251%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EMONTHLY%20SERVICE%20FEE%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%3CSTMTTRN%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNTYPE%3ECREDIT%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CDTPOSTED%3E20120126120000.000%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CTRNAMT%3E25.00%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CFITID%3E201201261%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CNAME%3EFROM%20CREDIT%20CARD%20OR%20LINE%0A%20%20%20%20%20%20%20%20%20%20%20%20%3CMEMO%3EOVERDRAFT%20XFER%0A%20%20%20%20%20%20%20%20%20%20%3C/STMTTRN%3E%0A%20%20%20%20%20%20%20%20%3C/BANKTRANLIST%3E%0A%20%20%20%20%20%20%20%20%3CLEDGERBAL%3E%0A%20%20%20%20%20%20%20%20%20%20%3CBALAMT%3E16.00%0A%20%20%20%20%20%20%20%20%20%20%3CDTASOF%3E20120126080000.000%5B0%3AGMT%5D%0A%20%20%20%20%20%20%20%20%3C/LEDGERBAL%3E%0A%20%20%20%20%20%20%20%20%3CAVAILBAL%3E%0A%20%20%20%20%20%20%20%20%20%20%3CBALAMT%3E16.00%0A%20%20%20%20%20%20%20%20%20%20%3CDTASOF%3E20120126080000.000%5B0%3AGMT%5D%0A%20%20%20%20%20%20%20%20%3C/AVAILBAL%3E%0A%20%20%20%20%20%20%3C/STMTRS%3E%0A%20%20%20%20%3C/STMTTRNRS%3E%0A%20%20%3C/BANKMSGSRSV1%3E%0A%3C/OFX%3E' +); diff --git a/test/fixtures/responses.js b/test/fixtures/responses.js new file mode 100644 index 0000000..9deca7e --- /dev/null +++ b/test/fixtures/responses.js @@ -0,0 +1,585 @@ +// Mock OFX responses for different banks +// These responses are based on actual OFX format but with dummy data + +export const wellsFargoStatementResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:103 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-wells-fargo + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000[-8:PST] + ENG + + Wells Fargo + 3001 + + + + + + test-transaction-uid-123 + + 0 + INFO + SUCCESS + + + USD + + 123006800 + 1234567890 + CHECKING + + + 20241101120000.000 + 20241201120000.000 + + DEBIT + 20241115120000.000 + -150.00 + WF202411150001 + GROCERY STORE PURCHASE + WHOLE FOODS MARKET + + + CREDIT + 20241116120000.000 + 2500.00 + WF202411160001 + DIRECT DEPOSIT + PAYROLL DEPOSIT + + + FEE + 20241120120000.000 + -12.00 + WF202411200001 + MONTHLY SERVICE FEE + ACCOUNT MAINTENANCE FEE + + + + 2338.00 + 20241201120000.000[-8:PST] + + + 2338.00 + 20241201120000.000[-8:PST] + + + + +`; + +export const wellsFargoAccountListResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:103 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-wells-fargo-accounts + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000[-8:PST] + ENG + + Wells Fargo + 3001 + + + + + + account-list-uid-123 + + 0 + INFO + SUCCESS + + + 20241201120000.000 + + + + 123006800 + 1234567890 + CHECKING + + Y + Y + Y + ACTIVE + + + + + + 123006800 + 9876543210 + SAVINGS + + Y + Y + Y + ACTIVE + + + + + +`; + +export const discoverCardStatementResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-discover-card + + + + + + 0 + INFO + + 20241201120000.000[-5:EST] + ENG + + Discover Financial Services + 7101 + + + + + + discover-transaction-uid-456 + + 0 + INFO + + + USD + + 6011123456789012 + + + 20241101120000.000 + 20241201120000.000 + + DEBIT + 20241115120000.000 + -89.99 + DISC202411150001 + AMAZON.COM + ONLINE PURCHASE + + + DEBIT + 20241118120000.000 + -45.67 + DISC202411180001 + SHELL GAS STATION + FUEL PURCHASE + + + CREDIT + 20241125120000.000 + 100.00 + DISC202411250001 + PAYMENT THANK YOU + ONLINE PAYMENT + + + + -35.66 + 20241201120000.000[-5:EST] + + + + +`; + +export const chaseStatementResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:103 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-chase-bank + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000[-5:EST] + ENG + + JPMorgan Chase Bank, N.A. + 636 + + 636 + + + + + chase-transaction-uid-789 + + 0 + INFO + SUCCESS + + + USD + + 322271627 + 5555666677 + CHECKING + + + 20241101120000.000 + 20241201120000.000 + + DEBIT + 20241110120000.000 + -75.00 + CHASE202411100001 + ATM WITHDRAWAL + CHASE ATM #1234 + + + CREDIT + 20241115120000.000 + 3000.00 + CHASE202411150001 + DIRECT DEPOSIT + EMPLOYER PAYROLL + + + DEBIT + 20241120120000.000 + -1200.00 + CHASE202411200001 + RENT PAYMENT + AUTO PAY RENT + + + + 1725.00 + 20241201120000.000[-5:EST] + + + 1725.00 + 20241201120000.000[-5:EST] + + + + +`; + +export const bankOfAmericaStatementResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-boa + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000[-8:PST] + ENG + + Bank of America + 5959 + + + + + + boa-transaction-uid-101 + + 0 + INFO + SUCCESS + + + USD + + 026009593 + 3333444455 + CHECKING + + + 20241101120000.000 + 20241201120000.000 + + DEBIT + 20241112120000.000 + -125.50 + BOA202411120001 + COSTCO WHOLESALE + DEBIT CARD PURCHASE + + + CREDIT + 20241115120000.000 + 2800.00 + BOA202411150001 + DIRECT DEPOSIT + SALARY DEPOSIT + + + FEE + 20241130120000.000 + -25.00 + BOA202411300001 + OVERDRAFT FEE + INSUFFICIENT FUNDS FEE + + + + 2649.50 + 20241201120000.000[-8:PST] + + + 2649.50 + 20241201120000.000[-8:PST] + + + + +`; + +export const usBankStatementResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-usbank + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000[-6:CST] + ENG + + U.S. Bank + 1001 + + + + + + usbank-transaction-uid-202 + + 0 + INFO + SUCCESS + + + USD + + 091000022 + 7777888899 + SAVINGS + + + 20241101120000.000 + 20241201120000.000 + + CREDIT + 20241105120000.000 + 5000.00 + USB202411050001 + TRANSFER FROM CHECKING + INTERNAL TRANSFER + + + CREDIT + 20241115120000.000 + 15.25 + USB202411150001 + INTEREST PAYMENT + MONTHLY INTEREST + + + + 15015.25 + 20241201120000.000[-6:CST] + + + 15015.25 + 20241201120000.000[-6:CST] + + + + +`; + +// Error responses for testing error handling +export const invalidCredentialsResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 15500 + ERROR + INVALID SIGNON + + 20241201120000.000 + ENG + + +`; + +export const accountNotFoundResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + error-transaction-uid + + 10500 + ERROR + INVALID ACCOUNT NUMBER + + + +`; + +export const malformedOFXResponse = `INVALID OFX DATA +THIS IS NOT PROPER OFX FORMAT + + WITHOUT HEADER + MISSING CLOSING TAGS +`; + +// Bank configuration data for tests +export const bankConfigs = { + wellsFargo: { + fid: 3001, + fidOrg: 'Wells Fargo', + url: 'https://www.oasis.cfree.com/3001.ofxgp', + bankId: '123006800', + accType: 'CHECKING', + accId: '1234567890', + user: 'testuser', + password: 'testpass' + }, + discover: { + fid: 7101, + fidOrg: 'Discover Financial Services', + url: 'https://ofx.discovercard.com', + bankId: '', + accType: 'CREDITCARD', + accId: '6011123456789012', + user: 'testuser', + password: 'testpass' + }, + chase: { + fid: 636, + fidOrg: 'JPMorgan Chase Bank, N.A.', + url: 'https://ofx.chase.com', + bankId: '322271627', + accType: 'CHECKING', + accId: '5555666677', + user: 'testuser', + password: 'testpass', + clientId: 'test-client-id-123' + }, + bankOfAmerica: { + fid: 5959, + fidOrg: 'Bank of America', + url: 'https://eftx.bankofamerica.com/eftxweb/access.ofx', + bankId: '026009593', + accType: 'CHECKING', + accId: '3333444455', + user: 'testuser', + password: 'testpass' + }, + usBank: { + fid: 1001, + fidOrg: 'U.S. Bank', + url: 'https://www.usbank.com/ofxroot', + bankId: '091000022', + accType: 'SAVINGS', + accId: '7777888899', + user: 'testuser', + password: 'testpass' + } +}; diff --git a/test/integration/bank-of-america.test.js b/test/integration/bank-of-america.test.js new file mode 100644 index 0000000..6b6fef0 --- /dev/null +++ b/test/integration/bank-of-america.test.js @@ -0,0 +1,489 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; +import { + bankOfAmericaStatementResponse, + invalidCredentialsResponse, + accountNotFoundResponse, + malformedOFXResponse, + bankConfigs +} from '../fixtures/responses.js'; + +describe('Bank of America Integration Tests', () => { + let banking; + + beforeEach(() => { + banking = new Banking(bankConfigs.bankOfAmerica); + }); + + describe('getStatement', () => { + it('should successfully retrieve Bank of America statement', done => { + // Mock the OFX server response + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').reply(200, bankOfAmericaStatementResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body).toBeDefined(); + expect(res.body.OFX).toBeDefined(); + + // Verify OFX structure + const ofx = res.body.OFX; + expect(ofx.SIGNONMSGSRSV1).toBeDefined(); + expect(ofx.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('0'); + expect(ofx.SIGNONMSGSRSV1.SONRS.FI.ORG).toBe('Bank of America'); + expect(ofx.SIGNONMSGSRSV1.SONRS.FI.FID).toBe('5959'); + + // Verify bank statement data + expect(ofx.BANKMSGSRSV1).toBeDefined(); + const stmtrs = ofx.BANKMSGSRSV1.STMTTRNRS.STMTRS; + expect(stmtrs.CURDEF).toBe('USD'); + expect(stmtrs.BANKACCTFROM.BANKID).toBe('026009593'); + expect(stmtrs.BANKACCTFROM.ACCTID).toBe('3333444455'); + expect(stmtrs.BANKACCTFROM.ACCTTYPE).toBe('CHECKING'); + + // Verify transactions + const transactions = stmtrs.BANKTRANLIST.STMTTRN; + expect(Array.isArray(transactions)).toBe(true); + expect(transactions.length).toBeGreaterThan(0); + + // Verify specific Bank of America transactions + const costcoTransaction = transactions.find(t => t.NAME === 'COSTCO WHOLESALE'); + expect(costcoTransaction).toBeDefined(); + expect(costcoTransaction.TRNTYPE).toBe('DEBIT'); + expect(costcoTransaction.TRNAMT).toBe('-125.50'); + expect(costcoTransaction.MEMO).toBe('DEBIT CARD PURCHASE'); + + const salaryTransaction = transactions.find(t => t.NAME === 'DIRECT DEPOSIT'); + expect(salaryTransaction).toBeDefined(); + expect(salaryTransaction.TRNTYPE).toBe('CREDIT'); + expect(salaryTransaction.TRNAMT).toBe('2800.00'); + expect(salaryTransaction.MEMO).toBe('SALARY DEPOSIT'); + + const feeTransaction = transactions.find(t => t.NAME === 'OVERDRAFT FEE'); + expect(feeTransaction).toBeDefined(); + expect(feeTransaction.TRNTYPE).toBe('FEE'); + expect(feeTransaction.TRNAMT).toBe('-25.00'); + expect(feeTransaction.MEMO).toBe('INSUFFICIENT FUNDS FEE'); + + // Verify balance information + expect(stmtrs.LEDGERBAL).toBeDefined(); + expect(stmtrs.LEDGERBAL.BALAMT).toBe('2649.50'); + expect(stmtrs.AVAILBAL).toBeDefined(); + expect(stmtrs.AVAILBAL.BALAMT).toBe('2649.50'); + + done(); + }); + }); + + it('should handle Bank of America authentication errors', done => { + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').reply(200, invalidCredentialsResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15500'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.MESSAGE).toBe('INVALID SIGNON'); + done(); + }); + }); + + it('should send properly formatted Bank of America OFX request', done => { + const scope = nock('https://eftx.bankofamerica.com') + .post('/eftxweb/access.ofx', body => { + // Verify Bank of America-specific OFX request format + expect(body).toContain('OFXHEADER:100'); + expect(body).toContain('DATA:OFXSGML'); + expect(body).toContain('VERSION:102'); + expect(body).toContain('testuser'); + expect(body).toContain('testpass'); + expect(body).toContain('5959'); + expect(body).toContain('026009593'); + expect(body).toContain('3333444455'); + expect(body).toContain('CHECKING'); + expect(body).toContain('20241101'); + expect(body).toContain('20241201'); + return true; + }) + .reply(200, bankOfAmericaStatementResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + + it('should handle Bank of America server timeouts', done => { + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').delay(5000).reply(200, bankOfAmericaStatementResponse); + + const startTime = Date.now(); + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + const elapsedTime = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(elapsedTime).toBeGreaterThan(1000); + done(); + }); + }); + + it('should handle Bank of America SSL certificate issues', done => { + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').replyWithError('UNABLE_TO_VERIFY_LEAF_SIGNATURE'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle Bank of America account restrictions', done => { + const restrictionResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + boa-restriction-error + + 10401 + ERROR + ACCOUNT RESTRICTED + + + +`; + + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').reply(200, restrictionResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('10401'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('ACCOUNT RESTRICTED'); + done(); + }); + }); + }); + + describe('getAccounts', () => { + it('should successfully retrieve Bank of America account list', done => { + const boaAccountListResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-boa-accounts + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000[-8:PST] + ENG + + Bank of America + 5959 + + + + + + boa-accounts-uid-101 + + 0 + INFO + SUCCESS + + + 20241201120000.000 + + + + 026009593 + 3333444455 + CHECKING + + Y + Y + Y + ACTIVE + + + + + + 026009593 + 3333444466 + SAVINGS + + Y + Y + Y + ACTIVE + + + + + + 4111111111111112 + + Y + N + N + ACTIVE + + + + + +`; + + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').reply(200, boaAccountListResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getAccounts((err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX).toBeDefined(); + + const acctInfo = res.body.OFX.SIGNUPMSGSRSV1.ACCTINFOTRNRS.ACCTINFORS.ACCTINFO; + expect(Array.isArray(acctInfo)).toBe(true); + expect(acctInfo.length).toBe(3); // Checking, savings, and credit card + + // Verify checking account + const checkingAccount = acctInfo.find(acc => acc.BANKACCTINFO && acc.BANKACCTINFO.BANKACCTFROM.ACCTTYPE === 'CHECKING'); + expect(checkingAccount).toBeDefined(); + expect(checkingAccount.BANKACCTINFO.BANKACCTFROM.ACCTID).toBe('3333444455'); + expect(checkingAccount.BANKACCTINFO.BANKACCTFROM.BANKID).toBe('026009593'); + expect(checkingAccount.BANKACCTINFO.SVCSTATUS).toBe('ACTIVE'); + + // Verify savings account + const savingsAccount = acctInfo.find(acc => acc.BANKACCTINFO && acc.BANKACCTINFO.BANKACCTFROM.ACCTTYPE === 'SAVINGS'); + expect(savingsAccount).toBeDefined(); + expect(savingsAccount.BANKACCTINFO.BANKACCTFROM.ACCTID).toBe('3333444466'); + + // Verify credit card account + const creditCardAccount = acctInfo.find(acc => acc.CCACCTINFO); + expect(creditCardAccount).toBeDefined(); + expect(creditCardAccount.CCACCTINFO.CCACCTFROM.ACCTID).toBe('4111111111111112'); + expect(creditCardAccount.CCACCTINFO.SVCSTATUS).toBe('ACTIVE'); + + done(); + }); + }); + }); + + describe('Bank of America Specific Features', () => { + it('should handle Bank of America fee transactions correctly', done => { + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').reply(200, bankOfAmericaStatementResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + + // Verify fee transaction handling + const feeTransactions = transactions.filter(t => t.TRNTYPE === 'FEE'); + expect(feeTransactions.length).toBeGreaterThan(0); + + const overdraftFee = feeTransactions.find(t => t.NAME === 'OVERDRAFT FEE'); + expect(overdraftFee).toBeDefined(); + expect(parseFloat(overdraftFee.TRNAMT)).toBeLessThan(0); + expect(overdraftFee.MEMO).toBe('INSUFFICIENT FUNDS FEE'); + + done(); + }); + }); + + it('should handle Bank of America routing number validation', done => { + // Test with invalid routing number + const invalidBankId = new Banking({ + ...bankConfigs.bankOfAmerica, + bankId: '000000000' // Invalid routing number + }); + + const invalidAccountResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + invalid-routing-error + + 10400 + ERROR + INVALID BANK ID + + + +`; + + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').reply(200, invalidAccountResponse, { + 'Content-Type': 'application/x-ofx' + }); + + invalidBankId.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('10400'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('INVALID BANK ID'); + done(); + }); + }); + + it('should handle Bank of America transaction limits', done => { + const limitResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + limit-error-transaction + + 13000 + ERROR + REQUEST TOO LARGE + + + +`; + + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').reply(200, limitResponse, { + 'Content-Type': 'application/x-ofx' + }); + + // Test with very large date range (5 years) + banking.getStatement({ start: 20200101, end: 20241231 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('13000'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('REQUEST TOO LARGE'); + done(); + }); + }); + + it('should handle different account types properly', done => { + // Test with a MONEYMRKT (Money Market) account + const mmBanking = new Banking({ + ...bankConfigs.bankOfAmerica, + accType: 'MONEYMRKT', + accId: '7777888899' + }); + + const mmResponse = bankOfAmericaStatementResponse + .replace('CHECKING', 'MONEYMRKT') + .replace('3333444455', '7777888899'); + + const scope = nock('https://eftx.bankofamerica.com') + .post('/eftxweb/access.ofx', body => { + expect(body).toContain('MONEYMRKT'); + expect(body).toContain('7777888899'); + return true; + }) + .reply(200, mmResponse); + + mmBanking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKACCTFROM.ACCTTYPE).toBe('MONEYMRKT'); + done(); + }); + }); + }); + + describe('Error Recovery and Resilience', () => { + it('should handle partial response corruption', done => { + const partiallyCorruptedResponse = bankOfAmericaStatementResponse.replace('', 'CORRUPT DATA'); + + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').reply(200, partiallyCorruptedResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + // Should still parse the valid parts + if (!err) { + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('0'); + } + done(); + }); + }); + + it('should handle network interruptions gracefully', done => { + nock('https://eftx.bankofamerica.com').post('/eftxweb/access.ofx').replyWithError('ECONNRESET'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + expect(err.code).toBe('ECONNRESET'); + done(); + }); + }); + }); +}); diff --git a/test/integration/chase.test.js b/test/integration/chase.test.js new file mode 100644 index 0000000..df68fb1 --- /dev/null +++ b/test/integration/chase.test.js @@ -0,0 +1,520 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; +import { + chaseStatementResponse, + invalidCredentialsResponse, + accountNotFoundResponse, + malformedOFXResponse, + bankConfigs +} from '../fixtures/responses.js'; + +describe('Chase Bank Integration Tests', () => { + let banking; + + beforeEach(() => { + banking = new Banking(bankConfigs.chase); + }); + + describe('getStatement', () => { + it('should successfully retrieve Chase bank statement', done => { + // Mock the OFX server response + nock('https://ofx.chase.com').post('/').reply(200, chaseStatementResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body).toBeDefined(); + expect(res.body.OFX).toBeDefined(); + + // Verify OFX structure + const ofx = res.body.OFX; + expect(ofx.SIGNONMSGSRSV1).toBeDefined(); + expect(ofx.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('0'); + expect(ofx.SIGNONMSGSRSV1.SONRS.FI.ORG).toBe('JPMorgan Chase Bank, N.A.'); + expect(ofx.SIGNONMSGSRSV1.SONRS.FI.FID).toBe('636'); + + // Verify Chase-specific INTU.BID field + expect(ofx.SIGNONMSGSRSV1.SONRS['INTU.BID']).toBe('636'); + + // Verify bank statement data + expect(ofx.BANKMSGSRSV1).toBeDefined(); + const stmtrs = ofx.BANKMSGSRSV1.STMTTRNRS.STMTRS; + expect(stmtrs.CURDEF).toBe('USD'); + expect(stmtrs.BANKACCTFROM.BANKID).toBe('322271627'); + expect(stmtrs.BANKACCTFROM.ACCTID).toBe('5555666677'); + expect(stmtrs.BANKACCTFROM.ACCTTYPE).toBe('CHECKING'); + + // Verify transactions + const transactions = stmtrs.BANKTRANLIST.STMTTRN; + expect(Array.isArray(transactions)).toBe(true); + expect(transactions.length).toBeGreaterThan(0); + + // Verify specific Chase transactions + const atmTransaction = transactions.find(t => t.NAME === 'ATM WITHDRAWAL'); + expect(atmTransaction).toBeDefined(); + expect(atmTransaction.TRNTYPE).toBe('DEBIT'); + expect(atmTransaction.TRNAMT).toBe('-75.00'); + expect(atmTransaction.MEMO).toBe('CHASE ATM #1234'); + + const payrollTransaction = transactions.find(t => t.NAME === 'DIRECT DEPOSIT'); + expect(payrollTransaction).toBeDefined(); + expect(payrollTransaction.TRNTYPE).toBe('CREDIT'); + expect(payrollTransaction.TRNAMT).toBe('3000.00'); + + const rentTransaction = transactions.find(t => t.NAME === 'RENT PAYMENT'); + expect(rentTransaction).toBeDefined(); + expect(rentTransaction.TRNTYPE).toBe('DEBIT'); + expect(rentTransaction.TRNAMT).toBe('-1200.00'); + expect(rentTransaction.MEMO).toBe('AUTO PAY RENT'); + + // Verify balance information + expect(stmtrs.LEDGERBAL).toBeDefined(); + expect(stmtrs.LEDGERBAL.BALAMT).toBe('1725.00'); + expect(stmtrs.AVAILBAL).toBeDefined(); + expect(stmtrs.AVAILBAL.BALAMT).toBe('1725.00'); + + done(); + }); + }); + + it('should handle Chase multi-factor authentication requirements', done => { + // Chase may require additional authentication steps + const mfaResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:103 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-chase-mfa + + + + + + 15000 + ERROR + MFA CHALLENGE REQUIRED + + 20241201120000.000[-5:EST] + ENG + + JPMorgan Chase Bank, N.A. + 636 + + + Please check your email for verification code + + + +`; + + nock('https://ofx.chase.com').post('/').reply(200, mfaResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15000'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.MESSAGE).toBe('MFA CHALLENGE REQUIRED'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.MFACHALLENGERQ).toBeDefined(); + done(); + }); + }); + + it('should send Chase-specific request format with ClientUID', done => { + const scope = nock('https://ofx.chase.com') + .post('/', body => { + // Verify Chase-specific OFX request format + expect(body).toContain('OFXHEADER:100'); + expect(body).toContain('VERSION:103'); // Chase requires version 103 + expect(body).toContain('testuser'); + expect(body).toContain('testpass'); + expect(body).toContain('636'); + expect(body).toContain('322271627'); + expect(body).toContain('5555666677'); + expect(body).toContain('CHECKING'); + + // Chase-specific fields + if (bankConfigs.chase.clientId) { + expect(body).toContain('test-client-id-123'); + } + + return true; + }) + .reply(200, chaseStatementResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + + it('should handle Chase server errors gracefully', done => { + nock('https://ofx.chase.com').post('/').reply(503, 'Service Temporarily Unavailable'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle Chase account verification errors', done => { + const chaseAccountError = `OFXHEADER:100 +DATA:OFXSGML +VERSION:103 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + chase-error-transaction + + 10403 + ERROR + ACCOUNT VERIFICATION REQUIRED + + + +`; + + nock('https://ofx.chase.com').post('/').reply(200, chaseAccountError, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('10403'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('ACCOUNT VERIFICATION REQUIRED'); + done(); + }); + }); + }); + + describe('getAccounts', () => { + it('should successfully retrieve Chase account list', done => { + const chaseAccountListResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:103 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-chase-accounts + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000[-5:EST] + ENG + + JPMorgan Chase Bank, N.A. + 636 + + 636 + + + + + chase-accounts-uid-789 + + 0 + INFO + SUCCESS + + + 20241201120000.000 + + + + 322271627 + 5555666677 + CHECKING + + Y + Y + Y + ACTIVE + + + + + + 322271627 + 5555666688 + SAVINGS + + Y + Y + Y + ACTIVE + + + + + + 4111111111111111 + + Y + N + N + ACTIVE + + + + + +`; + + nock('https://ofx.chase.com').post('/').reply(200, chaseAccountListResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getAccounts((err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX).toBeDefined(); + + const acctInfo = res.body.OFX.SIGNUPMSGSRSV1.ACCTINFOTRNRS.ACCTINFORS.ACCTINFO; + expect(Array.isArray(acctInfo)).toBe(true); + expect(acctInfo.length).toBe(3); // Checking, savings, and credit card + + // Verify checking account + const checkingAccount = acctInfo.find(acc => acc.BANKACCTINFO && acc.BANKACCTINFO.BANKACCTFROM.ACCTTYPE === 'CHECKING'); + expect(checkingAccount).toBeDefined(); + expect(checkingAccount.BANKACCTINFO.BANKACCTFROM.ACCTID).toBe('5555666677'); + expect(checkingAccount.BANKACCTINFO.BANKACCTFROM.BANKID).toBe('322271627'); + + // Verify savings account + const savingsAccount = acctInfo.find(acc => acc.BANKACCTINFO && acc.BANKACCTINFO.BANKACCTFROM.ACCTTYPE === 'SAVINGS'); + expect(savingsAccount).toBeDefined(); + expect(savingsAccount.BANKACCTINFO.BANKACCTFROM.ACCTID).toBe('5555666688'); + + // Verify credit card account + const creditCardAccount = acctInfo.find(acc => acc.CCACCTINFO); + expect(creditCardAccount).toBeDefined(); + expect(creditCardAccount.CCACCTINFO.CCACCTFROM.ACCTID).toBe('4111111111111111'); + expect(creditCardAccount.CCACCTINFO.SVCSTATUS).toBe('ACTIVE'); + + done(); + }); + }); + }); + + describe('Chase-Specific Error Handling', () => { + it('should handle Chase rate limiting', done => { + const rateLimitResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:103 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 2000 + ERROR + GENERAL ERROR - TOO MANY REQUESTS + + 20241201120000.000 + ENG + + +`; + + nock('https://ofx.chase.com').post('/').reply(429, rateLimitResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle Chase maintenance windows', done => { + const maintenanceResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:103 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 2025 + ERROR + SYSTEM TEMPORARILY UNAVAILABLE + + 20241201120000.000 + ENG + + +`; + + nock('https://ofx.chase.com').post('/').reply(200, maintenanceResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('2025'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.MESSAGE).toBe('SYSTEM TEMPORARILY UNAVAILABLE'); + done(); + }); + }); + + it('should handle invalid date range errors', done => { + const dateRangeError = `OFXHEADER:100 +DATA:OFXSGML +VERSION:103 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + chase-date-error + + 2020 + ERROR + INVALID DATE RANGE + + + +`; + + nock('https://ofx.chase.com').post('/').reply(200, dateRangeError, { + 'Content-Type': 'application/x-ofx' + }); + + // Test with invalid date range (end before start) + banking.getStatement({ start: 20241201, end: 20241101 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('2020'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('INVALID DATE RANGE'); + done(); + }); + }); + }); + + describe('Chase OFX Version Compatibility', () => { + it('should work with OFX version 103 (required by Chase)', done => { + const chaseWithV103 = new Banking({ + ...bankConfigs.chase, + ofxVer: '103' + }); + + const scope = nock('https://ofx.chase.com') + .post('/', body => { + expect(body).toContain('VERSION:103'); + return true; + }) + .reply(200, chaseStatementResponse); + + chaseWithV103.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + + it('should handle version mismatch gracefully', done => { + const versionErrorResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 10015 + ERROR + INVALID OFX VERSION + + 20241201120000.000 + ENG + + +`; + + nock('https://ofx.chase.com').post('/').reply(200, versionErrorResponse, { + 'Content-Type': 'application/x-ofx' + }); + + const chaseWithOldVersion = new Banking({ + ...bankConfigs.chase, + ofxVer: '102' + }); + + chaseWithOldVersion.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('10015'); + done(); + }); + }); + }); +}); diff --git a/test/integration/connection-pool.test.js b/test/integration/connection-pool.test.js new file mode 100644 index 0000000..7233243 --- /dev/null +++ b/test/integration/connection-pool.test.js @@ -0,0 +1,367 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; + +describe('Connection Pool Integration Tests', () => { + let banking; + let originalPoolConfig; + + beforeEach(() => { + // Store original pool config + originalPoolConfig = Banking.getPoolMetrics(); + + // Reset pool configuration + Banking.destroyPool(); + + banking = new Banking({ + fid: 3001, + fidOrg: 'Test Bank', + url: 'https://testbank.example.com/ofx', + bankId: '123456789', + user: 'testuser', + password: 'testpass', + accId: '1234567890', + accType: 'CHECKING' + }); + }); + + afterEach(() => { + Banking.destroyPool(); + nock.cleanAll(); + }); + + describe('Pool Configuration', () => { + it('should use default pool configuration', () => { + const config = Banking.configurePool(); + + expect(config.maxSockets).toBe(5); + expect(config.maxFreeSockets).toBe(2); + expect(config.keepAlive).toBe(true); + expect(config.keepAliveMsecs).toBe(30000); + expect(config.timeout).toBe(60000); + expect(config.maxRetries).toBe(3); + expect(config.enableMetrics).toBe(true); + }); + + it('should accept custom pool configuration', () => { + const customConfig = { + maxSockets: 10, + keepAlive: false, + timeout: 120000, + maxRetries: 5, + enableMetrics: false + }; + + const config = Banking.configurePool(customConfig); + + expect(config.maxSockets).toBe(10); + expect(config.keepAlive).toBe(false); + expect(config.timeout).toBe(120000); + expect(config.maxRetries).toBe(5); + expect(config.enableMetrics).toBe(false); + }); + + it('should return null metrics when pool not initialized', () => { + Banking.destroyPool(); + const metrics = Banking.getPoolMetrics(); + expect(metrics).toBeNull(); + }); + }); + + describe('Pool Metrics', () => { + beforeEach(() => { + Banking.configurePool({ + maxSockets: 3, + enableMetrics: true + }); + }); + + it('should track basic request metrics', done => { + const mockResponse = 'HTTP/1.1 200 OK\r\n\r\nOFXHEADER:100\r\nDATA:OFXSGML\r\n'; + + nock('https://testbank.example.com').post('/ofx').reply(200, mockResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + const metrics = Banking.getPoolMetrics(); + + expect(metrics.totalRequests).toBe(1); + expect(metrics.poolMisses).toBe(1); // First request creates new agent + expect(metrics.poolHits).toBe(0); + expect(metrics.errors).toBe(0); + expect(metrics.agentCount).toBe(1); + expect(metrics.averageResponseTime).toBeGreaterThan(0); + + done(); + }); + }); + + it('should track pool hits on subsequent requests', done => { + const mockResponse = 'HTTP/1.1 200 OK\r\n\r\nOFXHEADER:100\r\nDATA:OFXSGML\r\n'; + + nock('https://testbank.example.com').post('/ofx').times(2).reply(200, mockResponse); + + // First request + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + + // Second request to same host should reuse agent + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + + const metrics = Banking.getPoolMetrics(); + expect(metrics.totalRequests).toBe(2); + expect(metrics.poolMisses).toBe(1); // Only first request creates agent + expect(metrics.poolHits).toBe(1); // Second request reuses agent + + done(); + }); + }); + }); + + it('should track errors in metrics', done => { + nock('https://testbank.example.com').post('/ofx').reply(500, 'Internal Server Error'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + + const metrics = Banking.getPoolMetrics(); + expect(metrics.totalRequests).toBe(1); + expect(metrics.errors).toBeGreaterThan(0); // Should track the error + + done(); + }); + }); + }); + + describe('Connection Reuse', () => { + beforeEach(() => { + Banking.configurePool({ + maxSockets: 5, + keepAlive: true, + enableMetrics: true + }); + }); + + it('should reuse connections for same host', done => { + const mockResponse = 'HTTP/1.1 200 OK\r\n\r\nOFXHEADER:100\r\nDATA:OFXSGML\r\n'; + + nock('https://testbank.example.com').post('/ofx').times(3).reply(200, mockResponse); + + let completedRequests = 0; + const checkCompletion = () => { + completedRequests++; + if (completedRequests === 3) { + const metrics = Banking.getPoolMetrics(); + expect(metrics.totalRequests).toBe(3); + expect(metrics.agentCount).toBe(1); // Should only create one agent + expect(metrics.poolHits).toBe(2); // 2nd and 3rd requests reuse agent + done(); + } + }; + + // Make three concurrent requests + banking.getStatement({ start: 20241101, end: 20241201 }, checkCompletion); + banking.getStatement({ start: 20241101, end: 20241201 }, checkCompletion); + banking.getStatement({ start: 20241101, end: 20241201 }, checkCompletion); + }); + + it('should create separate agents for different hosts', done => { + const mockResponse = 'HTTP/1.1 200 OK\r\n\r\nOFXHEADER:100\r\nDATA:OFXSGML\r\n'; + + nock('https://testbank.example.com').post('/ofx').reply(200, mockResponse); + + nock('https://anotherbank.example.com').post('/ofx').reply(200, mockResponse); + + const anotherBanking = new Banking({ + ...banking.opts, + url: 'https://anotherbank.example.com/ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + + anotherBanking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + + const metrics = Banking.getPoolMetrics(); + expect(metrics.totalRequests).toBe(2); + expect(metrics.agentCount).toBe(2); // Should create agents for both hosts + expect(metrics.poolMisses).toBe(2); // Both are first requests to their hosts + + done(); + }); + }); + }); + }); + + describe('Retry Logic', () => { + beforeEach(() => { + Banking.configurePool({ + maxRetries: 2, + retryDelay: 100, // Short delay for testing + enableMetrics: true + }); + }); + + it('should retry on server errors', done => { + const mockResponse = 'HTTP/1.1 200 OK\r\n\r\nOFXHEADER:100\r\nDATA:OFXSGML\r\n'; + + nock('https://testbank.example.com').post('/ofx').reply(500, 'Internal Server Error').post('/ofx').reply(200, mockResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); // Should succeed after retry + + const metrics = Banking.getPoolMetrics(); + expect(metrics.retries).toBe(1); // Should have retried once + + done(); + }); + }); + + it('should retry on network errors', done => { + const mockResponse = 'HTTP/1.1 200 OK\r\n\r\nOFXHEADER:100\r\nDATA:OFXSGML\r\n'; + + nock('https://testbank.example.com').post('/ofx').replyWithError('ECONNRESET').post('/ofx').reply(200, mockResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); // Should succeed after retry + + const metrics = Banking.getPoolMetrics(); + expect(metrics.retries).toBe(1); // Should have retried once + + done(); + }); + }); + + it('should fail after max retries', done => { + nock('https://testbank.example.com') + .post('/ofx') + .times(3) // Initial + 2 retries + .reply(500, 'Internal Server Error'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); // Should fail after max retries + + const metrics = Banking.getPoolMetrics(); + expect(metrics.retries).toBe(2); // Should have retried max times + expect(metrics.errors).toBeGreaterThan(0); + + done(); + }); + }); + }); + + describe('Timeout Handling', () => { + beforeEach(() => { + Banking.configurePool({ + timeout: 1000, // 1 second timeout for testing + maxRetries: 1, + enableMetrics: true + }); + }); + + it('should timeout slow requests', done => { + nock('https://testbank.example.com') + .post('/ofx') + .delay(2000) // 2 second delay + .reply(200, 'Too late'); + + const startTime = Date.now(); + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + const elapsedTime = Date.now() - startTime; + + expect(err).toBeTruthy(); + expect(err.code).toBe('ETIMEDOUT'); + expect(elapsedTime).toBeGreaterThan(1000); + expect(elapsedTime).toBeLessThan(4000); // Should timeout before 4s (with retries) + + const metrics = Banking.getPoolMetrics(); + expect(metrics.errors).toBeGreaterThan(0); + + done(); + }); + }); + }); + + describe('Legacy Mode Support', () => { + it('should use legacy TLS sockets when pooling disabled', done => { + const bankingWithoutPool = new Banking({ + ...banking.opts, + usePooling: false + }); + + const mockResponse = 'HTTP/1.1 200 OK\r\n\r\nOFXHEADER:100\r\nDATA:OFXSGML\r\n'; + + nock('https://testbank.example.com').post('/ofx').reply(200, mockResponse); + + bankingWithoutPool.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + + // Pool metrics should not be affected since pooling was disabled + const metrics = Banking.getPoolMetrics(); + // Metrics might be null if no other pooled requests were made + if (metrics) { + expect(metrics.totalRequests).toBe(0); + } + + done(); + }); + }); + }); + + describe('Pool Lifecycle', () => { + it('should properly initialize and destroy pool', () => { + expect(Banking.getPoolMetrics()).toBeNull(); + + Banking.configurePool(); + // Pool is created immediately when configured + const metrics = Banking.getPoolMetrics(); + expect(metrics).not.toBeNull(); + expect(metrics.totalRequests).toBe(0); + + Banking.destroyPool(); + expect(Banking.getPoolMetrics()).toBeNull(); + }); + + it('should handle multiple destroy calls gracefully', () => { + Banking.configurePool(); + Banking.destroyPool(); + Banking.destroyPool(); // Should not throw + expect(Banking.getPoolMetrics()).toBeNull(); + }); + }); + + describe('Banking-Specific Optimizations', () => { + beforeEach(() => { + Banking.configurePool({ + maxSockets: 5, + keepAlive: true, + keepAliveMsecs: 30000, + enableMetrics: true + }); + }); + + it('should handle OFX-specific headers correctly', done => { + const mockResponse = 'HTTP/1.1 200 OK\r\nContent-Type: application/x-ofx\r\n\r\nOFXHEADER:100\r\n'; + + nock('https://testbank.example.com') + .post('/ofx') + .matchHeader('Content-Type', 'application/x-ofx') + .matchHeader('Content-Length', /\d+/) + .reply(200, mockResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + + it('should maintain SSL security for banking connections', () => { + const config = Banking.configurePool(); + + expect(config.secureProtocol).toBe('TLSv1_2_method'); + expect(config.rejectUnauthorized).toBe(true); + }); + }); +}); diff --git a/test/integration/discover.test.js b/test/integration/discover.test.js new file mode 100644 index 0000000..0196f5d --- /dev/null +++ b/test/integration/discover.test.js @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; +import { discoverCardStatementResponse, invalidCredentialsResponse, malformedOFXResponse, bankConfigs } from '../fixtures/responses.js'; + +describe('Discover Financial Integration Tests', () => { + let banking; + + beforeEach(() => { + banking = new Banking(bankConfigs.discover); + }); + + describe('getStatement', () => { + it('should successfully retrieve credit card statement', done => { + // Mock the OFX server response + nock('https://ofx.discovercard.com').post('/').reply(200, discoverCardStatementResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body).toBeDefined(); + expect(res.body.OFX).toBeDefined(); + + // Verify OFX structure + const ofx = res.body.OFX; + expect(ofx.SIGNONMSGSRSV1).toBeDefined(); + expect(ofx.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('0'); + expect(ofx.SIGNONMSGSRSV1.SONRS.FI.ORG).toBe('Discover Financial Services'); + expect(ofx.SIGNONMSGSRSV1.SONRS.FI.FID).toBe('7101'); + + // Verify credit card statement data (different structure than bank statements) + expect(ofx.CREDITCARDMSGSRSV1).toBeDefined(); + const ccstmtrs = ofx.CREDITCARDMSGSRSV1.CCSTMTTRNRS.CCSTMTRS; + expect(ccstmtrs.CURDEF).toBe('USD'); + expect(ccstmtrs.CCACCTFROM.ACCTID).toBe('6011123456789012'); + + // Verify transactions + const transactions = ccstmtrs.BANKTRANLIST.STMTTRN; + expect(Array.isArray(transactions)).toBe(true); + expect(transactions.length).toBeGreaterThan(0); + + // Verify specific transaction data + const amazonTransaction = transactions.find(t => t.NAME === 'AMAZON.COM'); + expect(amazonTransaction).toBeDefined(); + expect(amazonTransaction.TRNTYPE).toBe('DEBIT'); + expect(amazonTransaction.TRNAMT).toBe('-89.99'); + expect(amazonTransaction.MEMO).toBe('ONLINE PURCHASE'); + + const paymentTransaction = transactions.find(t => t.NAME === 'PAYMENT THANK YOU'); + expect(paymentTransaction).toBeDefined(); + expect(paymentTransaction.TRNTYPE).toBe('CREDIT'); + expect(paymentTransaction.TRNAMT).toBe('100.00'); + + // Verify balance information (credit cards show negative balance when you owe money) + expect(ccstmtrs.LEDGERBAL).toBeDefined(); + expect(ccstmtrs.LEDGERBAL.BALAMT).toBe('-35.66'); + + done(); + }); + }); + + it('should handle invalid credentials for Discover', done => { + nock('https://ofx.discovercard.com').post('/').reply(200, invalidCredentialsResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15500'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.MESSAGE).toBe('INVALID SIGNON'); + done(); + }); + }); + + it('should handle Discover-specific headers correctly', done => { + const discoverWithHeaders = new Banking({ + ...bankConfigs.discover, + headers: ['Content-Type', 'Host', 'Content-Length', 'Connection'] + }); + + const scope = nock('https://ofx.discovercard.com') + .post('/', body => { + // Verify Discover-specific OFX request + expect(body).toContain('OFXHEADER:100'); + expect(body).toContain('7101'); + expect(body).toContain('6011123456789012'); + expect(body).not.toContain(''); // Credit cards don't use bank routing numbers + return true; + }) + .reply(200, discoverCardStatementResponse); + + discoverWithHeaders.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + + it('should handle HTTP errors from Discover servers', done => { + nock('https://ofx.discovercard.com').post('/').reply(503, 'Service Unavailable'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle connection timeouts', done => { + nock('https://ofx.discovercard.com').post('/').delay(10000).reply(200, discoverCardStatementResponse); + + const startTime = Date.now(); + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + const elapsedTime = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(elapsedTime).toBeGreaterThan(1000); + done(); + }); + }); + + it('should handle malformed responses from Discover', done => { + nock('https://ofx.discovercard.com').post('/').reply(200, malformedOFXResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + }); + + describe('getAccounts', () => { + it('should successfully retrieve Discover card account info', done => { + const discoverAccountResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-discover-accounts + + + + + + 0 + INFO + + 20241201120000.000[-5:EST] + ENG + + Discover Financial Services + 7101 + + + + + + discover-accounts-uid-456 + + 0 + INFO + + + 20241201120000.000 + + + + 6011123456789012 + + Y + N + N + ACTIVE + + + + + +`; + + nock('https://ofx.discovercard.com').post('/').reply(200, discoverAccountResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getAccounts((err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX).toBeDefined(); + + const acctInfo = res.body.OFX.SIGNUPMSGSRSV1.ACCTINFOTRNRS.ACCTINFORS.ACCTINFO; + expect(acctInfo.CCACCTINFO).toBeDefined(); + expect(acctInfo.CCACCTINFO.CCACCTFROM.ACCTID).toBe('6011123456789012'); + expect(acctInfo.CCACCTINFO.SVCSTATUS).toBe('ACTIVE'); + expect(acctInfo.CCACCTINFO.SUPTXDL).toBe('Y'); // Supports transaction download + + done(); + }); + }); + + it('should handle authentication error for Discover account list', done => { + nock('https://ofx.discovercard.com').post('/').reply(200, invalidCredentialsResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getAccounts((err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15500'); + done(); + }); + }); + }); + + describe('Credit Card Specific Features', () => { + it('should correctly parse credit card transaction types', done => { + nock('https://ofx.discovercard.com').post('/').reply(200, discoverCardStatementResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + + const transactions = res.body.OFX.CREDITCARDMSGSRSV1.CCSTMTTRNRS.CCSTMTRS.BANKTRANLIST.STMTTRN; + + // Verify transaction types common to credit cards + const debitTransactions = transactions.filter(t => t.TRNTYPE === 'DEBIT'); + expect(debitTransactions.length).toBeGreaterThan(0); + + const creditTransactions = transactions.filter(t => t.TRNTYPE === 'CREDIT'); + expect(creditTransactions.length).toBeGreaterThan(0); + + // All debit amounts should be negative (charges) + debitTransactions.forEach(t => { + expect(parseFloat(t.TRNAMT)).toBeLessThan(0); + }); + + // All credit amounts should be positive (payments) + creditTransactions.forEach(t => { + expect(parseFloat(t.TRNAMT)).toBeGreaterThan(0); + }); + + done(); + }); + }); + + it('should handle zero balance scenarios', done => { + const zeroBalanceResponse = discoverCardStatementResponse.replace('-35.66', '0.00'); + + nock('https://ofx.discovercard.com').post('/').reply(200, zeroBalanceResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res.body.OFX.CREDITCARDMSGSRSV1.CCSTMTTRNRS.CCSTMTRS.LEDGERBAL.BALAMT).toBe('0.00'); + done(); + }); + }); + + it('should validate request format for credit card accounts', done => { + const scope = nock('https://ofx.discovercard.com') + .post('/', body => { + // Verify credit card-specific request structure + expect(body).toContain('OFXHEADER:100'); + expect(body).toContain(''); // Credit card statement request + expect(body).toContain(''); + expect(body).toContain('6011123456789012'); + expect(body).not.toContain(''); // Should not contain bank account structure + expect(body).not.toContain(''); // Credit cards don't use routing numbers + return true; + }) + .reply(200, discoverCardStatementResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + }); + + describe('Date Range Handling', () => { + it('should handle different date formats', done => { + nock('https://ofx.discovercard.com').post('/').reply(200, discoverCardStatementResponse); + + // Test with string dates + banking.getStatement({ start: '20241101', end: '20241201' }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + + it('should handle year-long date ranges', done => { + nock('https://ofx.discovercard.com').post('/').reply(200, discoverCardStatementResponse); + + banking.getStatement({ start: 20240101, end: 20241231 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + }); +}); diff --git a/test/integration/edge-cases.test.js b/test/integration/edge-cases.test.js new file mode 100644 index 0000000..4ea08f5 --- /dev/null +++ b/test/integration/edge-cases.test.js @@ -0,0 +1,658 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; +import { wellsFargoStatementResponse, discoverCardStatementResponse, bankConfigs } from '../fixtures/responses.js'; + +describe('Edge Cases and Boundary Tests', () => { + let banking; + + beforeEach(() => { + banking = new Banking(bankConfigs.wellsFargo); + }); + + describe('Date Range Edge Cases', () => { + it('should handle same start and end date', done => { + const singleDayResponse = wellsFargoStatementResponse + .replace('20241101120000.000', '20241115120000.000') + .replace('20241201120000.000', '20241115120000.000'); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, singleDayResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241115, end: 20241115 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.DTSTART).toBe('20241115120000.000'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.DTEND).toBe('20241115120000.000'); + done(); + }); + }); + + it('should handle maximum allowed date range', done => { + // Most banks limit to 2-3 years of data + const maxRangeResponse = wellsFargoStatementResponse.replace('20241101120000.000', '20220101120000.000'); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, maxRangeResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20220101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + + it('should handle future dates', done => { + const futureDateResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + future-date-error + + 2019 + ERROR + INVALID DATE - FUTURE DATES NOT ALLOWED + + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, futureDateResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20250101, end: 20251231 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('2019'); + done(); + }); + }); + + it('should handle very old dates', done => { + const oldDateResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + old-date-error + + 2018 + ERROR + DATA NOT AVAILABLE FOR REQUESTED DATE RANGE + + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, oldDateResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 19900101, end: 19901231 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('2018'); + done(); + }); + }); + + it('should handle leap year dates', done => { + const leapYearResponse = wellsFargoStatementResponse + .replace('20241101120000.000', '20240228120000.000') + .replace('20241201120000.000', '20240229120000.000'); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, leapYearResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20240228, end: 20240229 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + }); + + describe('Account Configuration Edge Cases', () => { + it('should handle extremely long account numbers', done => { + const longAcctBanking = new Banking({ + ...bankConfigs.wellsFargo, + accId: '1234567890123456789012345678901234567890' + }); + + const scope = nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp', body => { + expect(body).toContain('1234567890123456789012345678901234567890'); + return true; + }) + .reply(200, wellsFargoStatementResponse.replace('1234567890', '1234567890123456789012345678901234567890')); + + longAcctBanking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + + it('should handle special characters in account IDs', done => { + const specialCharBanking = new Banking({ + ...bankConfigs.wellsFargo, + accId: 'ACCT-123_456.789', + user: 'test.user+123', + password: 'p@ssw0rd!' + }); + + const scope = nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp', body => { + expect(body).toContain('ACCT-123_456.789'); + expect(body).toContain('test.user+123'); + expect(body).toContain('p@ssw0rd!'); + return true; + }) + .reply(200, wellsFargoStatementResponse.replace('1234567890', 'ACCT-123_456.789')); + + specialCharBanking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + + it('should handle empty or null account fields', done => { + const emptyFieldBanking = new Banking({ + ...bankConfigs.wellsFargo, + bankId: '', // Empty bank ID + brokerId: null, // Null broker ID + clientId: undefined // Undefined client ID + }); + + const scope = nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp', body => { + expect(body).toContain(''); + expect(body).not.toContain(''); + expect(body).not.toContain(''); + return true; + }) + .reply(200, wellsFargoStatementResponse); + + emptyFieldBanking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + + it('should handle very long passwords', done => { + const longPasswordBanking = new Banking({ + ...bankConfigs.wellsFargo, + password: 'a'.repeat(256) // 256 character password + }); + + const scope = nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp', body => { + expect(body).toContain('' + 'a'.repeat(256)); + return true; + }) + .reply(200, wellsFargoStatementResponse); + + longPasswordBanking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + }); + + describe('Response Size and Content Edge Cases', () => { + it('should handle responses with no transactions', done => { + const noTransactionsResponse = wellsFargoStatementResponse + .replace(/[\s\S]*?<\/STMTTRN>/g, '') + .replace('', ''); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, noTransactionsResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST).toBeDefined(); + done(); + }); + }); + + it('should handle responses with single transaction', done => { + const singleTransactionResponse = wellsFargoStatementResponse.replace( + /[\s\S]*?<\/STMTTRN>/g, + ` + DEBIT + 20241115120000.000 + -50.00 + WF202411150001 + SINGLE TRANSACTION + ONLY TRANSACTION IN PERIOD + ` + ); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, singleTransactionResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + // Could be an array with one element or a single object + if (Array.isArray(transactions)) { + expect(transactions.length).toBe(1); + expect(transactions[0].NAME).toBe('SINGLE TRANSACTION'); + } else { + expect(transactions.NAME).toBe('SINGLE TRANSACTION'); + } + done(); + }); + }); + + it('should handle responses with extremely large transaction lists', done => { + // Generate 1000 transactions + let transactionList = ''; + for (let i = 1; i <= 1000; i++) { + transactionList += ` + + DEBIT + 20241115120000.000 + -${i}.00 + WF20241115${i.toString().padStart(4, '0')} + TRANSACTION ${i} + GENERATED TRANSACTION ${i} + `; + } + + const largeTransactionResponse = wellsFargoStatementResponse.replace(/[\s\S]*?<\/STMTTRN>/g, transactionList); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, largeTransactionResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + expect(Array.isArray(transactions)).toBe(true); + expect(transactions.length).toBe(1000); + expect(transactions[0].NAME).toBe('TRANSACTION 1'); + expect(transactions[999].NAME).toBe('TRANSACTION 1000'); + done(); + }); + }); + + it('should handle transactions with missing or empty fields', done => { + const incompleteTransactionResponse = wellsFargoStatementResponse.replace( + /[\s\S]*?<\/STMTTRN>/g, + ` + DEBIT + 20241115120000.000 + -100.00 + WF202411150001 + + + + + CREDIT + 20241116120000.000 + 200.00 + WF202411160001 + DEPOSIT + ` + ); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, incompleteTransactionResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + expect(Array.isArray(transactions)).toBe(true); + expect(transactions.length).toBe(2); + + // First transaction has empty name and memo + expect(transactions[0].NAME).toBe(''); + expect(transactions[0].MEMO).toBe(''); + + // Second transaction missing memo field entirely + expect(transactions[1].NAME).toBe('DEPOSIT'); + expect(transactions[1].MEMO).toBeUndefined(); + + done(); + }); + }); + }); + + describe('Currency and Amount Edge Cases', () => { + it('should handle zero-amount transactions', done => { + const zeroAmountResponse = wellsFargoStatementResponse.replace('-150.00', '0.00'); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, zeroAmountResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + const zeroTransaction = transactions.find(t => t.TRNAMT === '0.00'); + expect(zeroTransaction).toBeDefined(); + done(); + }); + }); + + it('should handle very large monetary amounts', done => { + const largeAmountResponse = wellsFargoStatementResponse + .replace('2500.00', '999999999.99') + .replace('2338.00', '999999999.99'); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, largeAmountResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + + const stmtrs = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS; + expect(stmtrs.LEDGERBAL.BALAMT).toBe('999999999.99'); + + const transactions = stmtrs.BANKTRANLIST.STMTTRN; + const largeTransaction = transactions.find(t => t.TRNAMT === '999999999.99'); + expect(largeTransaction).toBeDefined(); + done(); + }); + }); + + it('should handle fractional cent amounts', done => { + const fractionalAmountResponse = wellsFargoStatementResponse + .replace('-150.00', '-150.001') + .replace('2500.00', '2500.999'); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, fractionalAmountResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + const fractionalDebit = transactions.find(t => t.TRNAMT === '-150.001'); + const fractionalCredit = transactions.find(t => t.TRNAMT === '2500.999'); + expect(fractionalDebit).toBeDefined(); + expect(fractionalCredit).toBeDefined(); + done(); + }); + }); + + it('should handle non-USD currencies', done => { + const euroCurrencyResponse = wellsFargoStatementResponse.replace('USD', 'EUR'); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, euroCurrencyResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.CURDEF).toBe('EUR'); + done(); + }); + }); + }); + + describe('Character Encoding and Special Characters', () => { + it('should handle Unicode characters in transaction descriptions', done => { + const unicodeResponse = wellsFargoStatementResponse + .replace('GROCERY STORE PURCHASE', 'CafĆ© München - €50.00 Purchase') + .replace('WHOLE FOODS MARKET', 'Straße ā„–123, München, Deutschland'); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, unicodeResponse, { + 'Content-Type': 'application/x-ofx; charset=utf-8' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + const unicodeTransaction = transactions.find(t => t.NAME.includes('CafĆ©')); + expect(unicodeTransaction).toBeDefined(); + expect(unicodeTransaction.NAME).toBe('CafĆ© München - €50.00 Purchase'); + expect(unicodeTransaction.MEMO).toBe('Straße ā„–123, München, Deutschland'); + done(); + }); + }); + + it('should handle HTML entities in responses', done => { + const htmlEntityResponse = wellsFargoStatementResponse + .replace('GROCERY STORE PURCHASE', 'AT&T Payment <Auto>') + .replace('WHOLE FOODS MARKET', '"Monthly Service" & Fees'); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, htmlEntityResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + const entityTransaction = transactions.find(t => t.NAME.includes('AT&T')); + expect(entityTransaction).toBeDefined(); + done(); + }); + }); + + it('should handle very long transaction descriptions', done => { + const longDescription = 'A'.repeat(1000); + const longDescriptionResponse = wellsFargoStatementResponse + .replace('GROCERY STORE PURCHASE', `${longDescription}`) + .replace('WHOLE FOODS MARKET', `${longDescription}`); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, longDescriptionResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + const longTransaction = transactions.find(t => t.NAME === longDescription); + expect(longTransaction).toBeDefined(); + expect(longTransaction.MEMO).toBe(longDescription); + done(); + }); + }); + }); + + describe('Time Zone and Date Format Edge Cases', () => { + it('should handle different time zone formats', done => { + const timeZoneResponse = wellsFargoStatementResponse + .replace('20241201120000.000[-8:PST]', '20241201120000.000[+5:EST]') + .replace('20241115120000.000', '20241115120000.000[-8:PST]'); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, timeZoneResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.DTSERVER).toBe('20241201120000.000[+5:EST]'); + done(); + }); + }); + + it('should handle dates without time components', done => { + const noTimeResponse = wellsFargoStatementResponse.replace(/\d{8}120000\.000/g, match => match.substring(0, 8)); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, noTimeResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + }); + + describe('Network Behavior Edge Cases', () => { + it('should handle partial response chunks', done => { + const responseChunks = [ + 'OFXHEADER:100\nDATA:OFXSGML\nVERSION:102\n', + 'SECURITY:NONE\nENCODING:USASCII\n', + wellsFargoStatementResponse.substring(100) + ]; + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .reply(function (uri, requestBody) { + const response = responseChunks.join(''); + return [200, response, { 'Content-Type': 'application/x-ofx' }]; + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + if (!err) { + expect(res).toBeDefined(); + } + done(); + }); + }); + + it('should handle slow response streams', done => { + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .delay(1500) // Slow but not timeout + .reply(200, wellsFargoStatementResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + + it('should handle responses with incorrect content-type', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, wellsFargoStatementResponse, { + 'Content-Type': 'text/plain' // Wrong content type + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + // Should still parse OFX regardless of content-type + if (!err) { + expect(res).toBeDefined(); + } + done(); + }); + }); + + it('should handle responses with additional headers', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, wellsFargoStatementResponse, { + 'Content-Type': 'application/x-ofx', + 'X-Custom-Header': 'BankSpecificValue', + 'Cache-Control': 'no-cache', + 'Set-Cookie': 'SessionId=abc123; HttpOnly' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.header).toBeDefined(); + done(); + }); + }); + }); + + describe('Concurrent Request Handling', () => { + it('should handle multiple simultaneous requests', done => { + // Set up multiple responses + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').times(3).reply(200, wellsFargoStatementResponse, { + 'Content-Type': 'application/x-ofx' + }); + + let completedRequests = 0; + const checkCompletion = () => { + completedRequests++; + if (completedRequests === 3) { + done(); + } + }; + + // Make three simultaneous requests + banking.getStatement({ start: 20241101, end: 20241110 }, (err, res) => { + expect(err).toBe(false); + checkCompletion(); + }); + + banking.getStatement({ start: 20241111, end: 20241120 }, (err, res) => { + expect(err).toBe(false); + checkCompletion(); + }); + + banking.getStatement({ start: 20241121, end: 20241130 }, (err, res) => { + expect(err).toBe(false); + checkCompletion(); + }); + }); + }); +}); diff --git a/test/integration/error-handling.test.js b/test/integration/error-handling.test.js new file mode 100644 index 0000000..946ddf6 --- /dev/null +++ b/test/integration/error-handling.test.js @@ -0,0 +1,700 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; +import { invalidCredentialsResponse, accountNotFoundResponse, malformedOFXResponse, bankConfigs } from '../fixtures/responses.js'; + +describe('Comprehensive Error Handling Tests', () => { + let banking; + + beforeEach(() => { + banking = new Banking(bankConfigs.wellsFargo); + }); + + describe('Network and Connection Errors', () => { + it('should handle DNS resolution failures', done => { + const badUrlBanking = new Banking({ + ...bankConfigs.wellsFargo, + url: 'https://nonexistent-bank-domain.invalid' + }); + + nock('https://nonexistent-bank-domain.invalid').post('/').replyWithError({ code: 'ENOTFOUND', message: 'getaddrinfo ENOTFOUND' }); + + badUrlBanking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + expect(err.code).toBe('ENOTFOUND'); + done(); + }); + }); + + it('should handle connection refused errors', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').replyWithError({ code: 'ECONNREFUSED', message: 'Connection refused' }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + expect(err.code).toBe('ECONNREFUSED'); + done(); + }); + }); + + it('should handle connection reset errors', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').replyWithError({ code: 'ECONNRESET', message: 'Connection reset by peer' }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + expect(err.code).toBe('ECONNRESET'); + done(); + }); + }); + + it('should handle network unreachable errors', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').replyWithError({ code: 'ENETUNREACH', message: 'Network is unreachable' }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + expect(err.code).toBe('ENETUNREACH'); + done(); + }); + }); + + it('should handle socket timeout errors', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').delay(30000).reply(200, 'Should never reach here'); + + const startTime = Date.now(); + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + const elapsedTime = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(elapsedTime).toBeGreaterThan(2000); + done(); + }); + }); + }); + + describe('HTTP Status Code Errors', () => { + it('should handle 400 Bad Request', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(400, 'Bad Request - Invalid OFX Format'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle 401 Unauthorized', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(401, 'Unauthorized - Invalid Credentials'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle 403 Forbidden', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(403, 'Forbidden - Access Denied'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle 404 Not Found', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(404, 'Not Found - OFX Endpoint Not Available'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle 429 Too Many Requests', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(429, 'Too Many Requests', { + 'Retry-After': '300' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle 500 Internal Server Error', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(500, 'Internal Server Error'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle 502 Bad Gateway', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(502, 'Bad Gateway'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle 503 Service Unavailable', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(503, 'Service Unavailable - Maintenance Mode'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle 504 Gateway Timeout', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(504, 'Gateway Timeout'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + }); + + describe('OFX Protocol Errors', () => { + it('should handle general OFX errors', done => { + const generalErrorResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 2000 + ERROR + GENERAL ERROR + + 20241201120000.000 + ENG + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, generalErrorResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('2000'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.SEVERITY).toBe('ERROR'); + done(); + }); + }); + + it('should handle OFX version unsupported errors', done => { + const versionErrorResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 10015 + ERROR + UNSUPPORTED OFX VERSION + + 20241201120000.000 + ENG + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, versionErrorResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('10015'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.MESSAGE).toBe('UNSUPPORTED OFX VERSION'); + done(); + }); + }); + + it('should handle invalid user credentials', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, invalidCredentialsResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15500'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.MESSAGE).toBe('INVALID SIGNON'); + done(); + }); + }); + + it('should handle locked account errors', done => { + const lockedAccountResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 15501 + ERROR + CUSTOMER ACCOUNT ALREADY IN USE + + 20241201120000.000 + ENG + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, lockedAccountResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15501'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.MESSAGE).toBe('CUSTOMER ACCOUNT ALREADY IN USE'); + done(); + }); + }); + + it('should handle expired password errors', done => { + const expiredPasswordResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 15505 + ERROR + PASSWORD EXPIRED + + 20241201120000.000 + ENG + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, expiredPasswordResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15505'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.MESSAGE).toBe('PASSWORD EXPIRED'); + done(); + }); + }); + }); + + describe('Account and Transaction Errors', () => { + it('should handle invalid account number', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, accountNotFoundResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('10500'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('INVALID ACCOUNT NUMBER'); + done(); + }); + }); + + it('should handle account restrictions', done => { + const accountRestrictedResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + restricted-account-error + + 10401 + ERROR + ACCOUNT RESTRICTED - CONTACT BANK + + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, accountRestrictedResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('10401'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('ACCOUNT RESTRICTED - CONTACT BANK'); + done(); + }); + }); + + it('should handle invalid date range errors', done => { + const invalidDateRangeResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + invalid-date-range-error + + 2020 + ERROR + INVALID DATE RANGE + + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, invalidDateRangeResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20251201, end: 20241101 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('2020'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('INVALID DATE RANGE'); + done(); + }); + }); + + it('should handle request too large errors', done => { + const requestTooLargeResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + request-too-large-error + + 13000 + ERROR + REQUEST TOO LARGE - REDUCE DATE RANGE + + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, requestTooLargeResponse, { + 'Content-Type': 'application/x-ofx' + }); + + // Request 10 years of data + banking.getStatement({ start: 20140101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('13000'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('REQUEST TOO LARGE - REDUCE DATE RANGE'); + done(); + }); + }); + }); + + describe('Data Parsing and Format Errors', () => { + it('should handle completely malformed OFX', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, malformedOFXResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle missing OFX header', done => { + const noHeaderResponse = ` + + + + 0 + INFO + + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, noHeaderResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + // Should handle gracefully or throw appropriate error + if (err) { + expect(err).toBeTruthy(); + } else { + expect(res).toBeDefined(); + } + done(); + }); + }); + + it('should handle corrupted XML structure', done => { + const corruptedXMLResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + corrupted-xml + + 0 + INFO + SUCCESS + + + USD + + 123456789 + 987654321 + CHECKING + + + 20241101 + 20241201 + + DEBIT + 20241115 + -100.00 + corrupt123 + BROKEN TRANSACTION + + TEST + + + + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, corruptedXMLResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + // Should either handle gracefully or throw parsing error + if (err) { + expect(err).toBeTruthy(); + } else { + expect(res).toBeDefined(); + } + done(); + }); + }); + + it('should handle empty response body', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, '', { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle non-OFX response (HTML error page)', done => { + const htmlErrorPage = ` + + + Service Unavailable + + +

503 Service Unavailable

+

The OFX service is temporarily unavailable. Please try again later.

+ +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, htmlErrorPage, { + 'Content-Type': 'text/html' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + }); + + describe('SSL and Security Errors', () => { + it('should handle SSL certificate verification errors', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').replyWithError({ + code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + message: 'Unable to verify the first certificate' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + expect(err.code).toBe('UNABLE_TO_VERIFY_LEAF_SIGNATURE'); + done(); + }); + }); + + it('should handle SSL handshake failures', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').replyWithError({ + code: 'EPROTO', + message: 'SSL handshake failed' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + expect(err.code).toBe('EPROTO'); + done(); + }); + }); + + it('should handle self-signed certificate errors', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').replyWithError({ + code: 'DEPTH_ZERO_SELF_SIGNED_CERT', + message: 'Self signed certificate' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + expect(err.code).toBe('DEPTH_ZERO_SELF_SIGNED_CERT'); + done(); + }); + }); + }); + + describe('Resource and System Errors', () => { + it('should handle out of memory errors', done => { + // Simulate a very large response that could cause memory issues + const hugeResponse = 'OFXHEADER:100\nDATA:OFXSGML\nVERSION:102\n' + 'X'.repeat(50000); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, hugeResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle file system errors (if applicable)', () => { + // This would test scenarios where temporary files can't be written + // For now, just ensure the error handling exists + expect(() => { + const testBanking = new Banking({ + ...bankConfigs.wellsFargo, + // Invalid characters in filename if temp files are used + user: '../../../etc/passwd', + password: 'test' + }); + testBanking.getStatement; + }).not.toThrow(); + }); + }); +}); diff --git a/test/integration/structured-error-handling.test.js b/test/integration/structured-error-handling.test.js new file mode 100644 index 0000000..8cb4f31 --- /dev/null +++ b/test/integration/structured-error-handling.test.js @@ -0,0 +1,472 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; +import { bankConfigs } from '../fixtures/responses.js'; + +describe('Structured Error Handling System', () => { + let banking; + + beforeEach(() => { + // Reset any existing nock interceptors + nock.cleanAll(); + }); + + describe('Error Class Inheritance and Structure', () => { + it('should have proper error class hierarchy', () => { + expect(Banking.BankingError).toBeDefined(); + expect(Banking.NetworkError).toBeDefined(); + expect(Banking.ConnectionError).toBeDefined(); + expect(Banking.TimeoutError).toBeDefined(); + expect(Banking.AuthenticationError).toBeDefined(); + expect(Banking.InvalidCredentialsError).toBeDefined(); + expect(Banking.BankingBusinessError).toBeDefined(); + expect(Banking.AccountNotFoundError).toBeDefined(); + expect(Banking.OFXProtocolError).toBeDefined(); + expect(Banking.MalformedResponseError).toBeDefined(); + expect(Banking.ConfigurationError).toBeDefined(); + expect(Banking.InvalidConfigurationError).toBeDefined(); + }); + + it('should create errors with proper inheritance', () => { + const connectionError = new Banking.ConnectionError('Test connection error'); + + expect(connectionError).toBeInstanceOf(Banking.ConnectionError); + expect(connectionError).toBeInstanceOf(Banking.NetworkError); + expect(connectionError).toBeInstanceOf(Banking.BankingError); + expect(connectionError).toBeInstanceOf(Error); + }); + + it('should include correlation IDs and structured information', () => { + const error = new Banking.BankingError('Test error', { + fid: 12345, + fidOrg: 'Test Bank', + operationType: 'statement' + }); + + expect(error.correlationId).toBeDefined(); + expect(error.correlationId).toMatch(/^[A-Za-z0-9]{16}$/); + expect(error.timestamp).toBeDefined(); + expect(error.category).toBe('UNKNOWN'); + expect(error.bankingContext.fid).toBe(12345); + expect(error.bankingContext.fidOrg).toBe('Test Bank'); + expect(error.bankingContext.operationType).toBe('statement'); + }); + + it('should provide PCI-compliant logging', () => { + const error = new Banking.BankingError('Test error', { + fid: 12345, + url: 'https://username:password@bank.com/ofx?secret=123' + }); + + const logObj = error.toLogObject(); + + // Should sanitize URLs + expect(logObj.bankingContext.url).not.toContain('username'); + expect(logObj.bankingContext.url).not.toContain('password'); + expect(logObj.bankingContext.url).not.toContain('secret'); + + // Should include safe information + expect(logObj.correlationId).toBeDefined(); + expect(logObj.timestamp).toBeDefined(); + expect(logObj.bankingContext.fid).toBe(12345); + }); + }); + + describe('Configuration Validation', () => { + it('should throw InvalidConfigurationError for missing config', () => { + expect(() => new Banking()).toThrow(Banking.InvalidConfigurationError); + expect(() => new Banking()).toThrow('Configuration object is required'); + }); + + it('should throw MissingParameterError for missing required fields', () => { + expect(() => new Banking({})).toThrow(Banking.MissingParameterError); + expect(() => new Banking({})).toThrow("Required parameter 'fid' is missing"); + }); + + it('should throw InvalidConfigurationError for invalid FID', () => { + expect( + () => + new Banking({ + fid: 'invalid', + url: 'https://bank.com', + user: 'user', + password: 'pass', + accId: '123', + accType: 'CHECKING' + }) + ).toThrow(Banking.InvalidConfigurationError); + expect( + () => + new Banking({ + fid: 'invalid', + url: 'https://bank.com', + user: 'user', + password: 'pass', + accId: '123', + accType: 'CHECKING' + }) + ).toThrow('FID must be a positive number'); + }); + + it('should throw InvalidConfigurationError for invalid URL', () => { + expect( + () => + new Banking({ + fid: 12345, + url: 'invalid-url', + user: 'user', + password: 'pass', + accId: '123', + accType: 'CHECKING' + }) + ).toThrow(Banking.InvalidConfigurationError); + expect( + () => + new Banking({ + fid: 12345, + url: 'invalid-url', + user: 'user', + password: 'pass', + accId: '123', + accType: 'CHECKING' + }) + ).toThrow('Invalid URL format'); + }); + + it('should throw InvalidConfigurationError for invalid account type', () => { + expect( + () => + new Banking({ + fid: 12345, + url: 'https://bank.com', + user: 'user', + password: 'pass', + accId: '123', + accType: 'INVALID' + }) + ).toThrow(Banking.InvalidConfigurationError); + expect( + () => + new Banking({ + fid: 12345, + url: 'https://bank.com', + user: 'user', + password: 'pass', + accId: '123', + accType: 'INVALID' + }) + ).toThrow('Invalid account type'); + }); + }); + + describe('Network Error Classification', () => { + beforeEach(() => { + banking = new Banking(bankConfigs.wellsFargo); + }); + + it('should classify DNS errors correctly', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').replyWithError({ code: 'ENOTFOUND', message: 'getaddrinfo ENOTFOUND' }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeInstanceOf(Banking.DNSError); + expect(err).toBeInstanceOf(Banking.NetworkError); + expect(err.code).toBe('DNS_ERROR'); + expect(err.retryable).toBe(false); + expect(err.recommendations).toContain('Verify the banking server URL is correct'); + done(); + }); + }); + + it('should classify connection errors correctly', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').replyWithError({ code: 'ECONNREFUSED', message: 'Connection refused' }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeInstanceOf(Banking.ConnectionError); + expect(err).toBeInstanceOf(Banking.NetworkError); + expect(err.code).toBe('CONNECTION_ERROR'); + expect(err.retryable).toBe(true); + expect(err.maxRetries).toBe(3); + expect(err.recommendations).toContain('Check network connectivity'); + done(); + }); + }); + + it('should classify certificate errors correctly', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').replyWithError({ code: 'CERT_HAS_EXPIRED', message: 'Certificate has expired' }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeInstanceOf(Banking.CertificateError); + expect(err).toBeInstanceOf(Banking.NetworkError); + expect(err.code).toBe('CERTIFICATE_ERROR'); + expect(err.retryable).toBe(false); + expect(err.recommendations).toContain('Verify SSL certificate configuration'); + done(); + }); + }); + }); + + describe('HTTP Status Code Classification', () => { + beforeEach(() => { + banking = new Banking(bankConfigs.wellsFargo); + }); + + it('should classify 401 as InvalidCredentialsError', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(401, 'Unauthorized'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeInstanceOf(Banking.InvalidCredentialsError); + expect(err).toBeInstanceOf(Banking.AuthenticationError); + expect(err.technicalDetails.httpStatus).toBe(401); + expect(err.retryable).toBe(false); + expect(err.recommendations).toContain('Verify username and password are correct'); + done(); + }); + }); + + it('should classify 404 as AccountNotFoundError', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(404, 'Not Found'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeInstanceOf(Banking.AccountNotFoundError); + expect(err).toBeInstanceOf(Banking.BankingBusinessError); + expect(err.technicalDetails.httpStatus).toBe(404); + expect(err.recommendations).toContain('Verify the account ID/number is correct'); + done(); + }); + }); + + it('should classify 429 as TooManyRequestsError', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(429, 'Too Many Requests'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeInstanceOf(Banking.TooManyRequestsError); + expect(err).toBeInstanceOf(Banking.RateLimitError); + expect(err.technicalDetails.httpStatus).toBe(429); + expect(err.retryable).toBe(true); + expect(err.retryAfter).toBe(300); // 5 minutes + expect(err.recommendations).toContain('Wait before making additional requests'); + done(); + }); + }); + + it('should classify 503 as MaintenanceModeError', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(503, 'Service Unavailable'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeInstanceOf(Banking.MaintenanceModeError); + expect(err).toBeInstanceOf(Banking.BankingBusinessError); + expect(err.technicalDetails.httpStatus).toBe(503); + expect(err.retryable).toBe(true); + expect(err.retryAfter).toBe(3600); // 1 hour + expect(err.recommendations).toContain('Wait for maintenance window to complete'); + done(); + }); + }); + }); + + describe('Date Validation', () => { + beforeEach(() => { + banking = new Banking(bankConfigs.wellsFargo); + }); + + it('should validate missing start date', () => { + return new Promise(resolve => { + banking.getStatement({}, (err, res) => { + expect(err).toBeInstanceOf(Banking.InvalidDateRangeError); + expect(err.code).toBe('INVALID_DATE_RANGE'); + expect(err.message).toContain('Start date is required'); + resolve(); + }); + }); + }); + + it('should validate invalid date format', () => { + return new Promise(resolve => { + banking.getStatement({ start: 'invalid-date' }, (err, res) => { + expect(err).toBeInstanceOf(Banking.InvalidDateRangeError); + expect(err.message).toContain('Start date must be in YYYYMMDD or YYYYMMDDHHMMSS format'); + resolve(); + }); + }); + }); + + it('should validate date range order', () => { + return new Promise(resolve => { + banking.getStatement({ start: 20241201, end: 20241101 }, (err, res) => { + expect(err).toBeInstanceOf(Banking.InvalidDateRangeError); + expect(err.message).toContain('Start date must be before end date'); + resolve(); + }); + }); + }); + + it('should accept valid date formats', done => { + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .reply( + 200, + `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + +0 +INFO + +20241101000000 +ENG + +Wells Fargo +4000 + + + + + +1 + +0 +INFO + + +USD + +121000248 +123456789 +CHECKING + + +20241101 +20241201 + + +1000.00 +20241201 + + + + +` + ); + + // Test YYYYMMDD format + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeFalsy(); + expect(res).toBeDefined(); + done(); + }); + }); + }); + + describe('Error Factory Function', () => { + it('should create appropriate error types from factory', () => { + const dnsError = Banking.createBankingError({ + message: 'DNS lookup failed', + originalError: { code: 'ENOTFOUND' } + }); + expect(dnsError).toBeInstanceOf(Banking.DNSError); + + const authError = Banking.createBankingError({ + message: 'Invalid credentials', + httpStatus: 401 + }); + expect(authError).toBeInstanceOf(Banking.InvalidCredentialsError); + + const rateError = Banking.createBankingError({ + message: 'Rate limited', + code: 'RATE_LIMITED' + }); + expect(rateError).toBeInstanceOf(Banking.RateLimitError); + }); + + it('should preserve banking context in factory-created errors', () => { + const error = Banking.createBankingError( + { + message: 'Test error', + httpStatus: 500 + }, + { + fid: 12345, + operationType: 'statement', + url: 'https://bank.com/ofx' + } + ); + + expect(error.bankingContext.fid).toBe(12345); + expect(error.bankingContext.operationType).toBe('statement'); + expect(error.bankingContext.url).toBe('https://bank.com/ofx'); + }); + }); + + describe('Error Serialization', () => { + it('should serialize to JSON correctly', () => { + const error = new Banking.TimeoutError('Connection timeout', { + fid: 12345, + operationType: 'statement', + metadata: { timeoutValue: 30000 } + }); + + const json = JSON.stringify(error); + const parsed = JSON.parse(json); + + expect(parsed.name).toBe('TimeoutError'); + expect(parsed.code).toBe('TIMEOUT_ERROR'); + expect(parsed.category).toBe('NETWORK'); + expect(parsed.retryable).toBe(true); + expect(parsed.bankingContext.fid).toBe(12345); + expect(parsed.bankingContext.operationType).toBe('statement'); + }); + + it('should create log objects without sensitive data', () => { + const error = new Banking.BankingError('Test error', { + url: 'https://user:pass@bank.com/ofx?token=secret', + metadata: { sensitiveData: 'should not appear in logs' } + }); + + const logObj = error.toLogObject(); + + expect(logObj.bankingContext.url).not.toContain('user'); + expect(logObj.bankingContext.url).not.toContain('pass'); + expect(logObj.bankingContext.url).not.toContain('token'); + expect(logObj.bankingContext.url).not.toContain('secret'); + }); + }); + + describe('Retry Recommendations', () => { + it('should provide correct retry information for different error types', () => { + const timeoutError = new Banking.TimeoutError('Timeout occurred'); + expect(timeoutError.retryable).toBe(true); + expect(timeoutError.maxRetries).toBe(2); + + const dnsError = new Banking.DNSError('DNS failed'); + expect(dnsError.retryable).toBe(false); + expect(dnsError.maxRetries).toBe(0); + + const authError = new Banking.InvalidCredentialsError('Bad credentials'); + expect(authError.retryable).toBe(false); + + const maintenanceError = new Banking.MaintenanceModeError('Under maintenance'); + expect(maintenanceError.retryable).toBe(true); + expect(maintenanceError.retryAfter).toBe(3600); + }); + + it('should include actionable recommendations', () => { + const connectionError = new Banking.ConnectionError('Connection failed'); + expect(connectionError.recommendations).toContain('Check network connectivity'); + expect(connectionError.recommendations).toContain('Verify firewall settings'); + + const credentialsError = new Banking.InvalidCredentialsError('Invalid login'); + expect(credentialsError.recommendations).toContain('Verify username and password are correct'); + expect(credentialsError.recommendations).toContain('Check if account is locked or suspended'); + }); + }); +}); diff --git a/test/integration/timeout-retry.test.js b/test/integration/timeout-retry.test.js new file mode 100644 index 0000000..3f2e25e --- /dev/null +++ b/test/integration/timeout-retry.test.js @@ -0,0 +1,734 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; +import { bankConfigs } from '../fixtures/responses.js'; + +describe('Comprehensive Timeout and Retry Tests', () => { + let banking; + + beforeEach(() => { + banking = new Banking(bankConfigs.wellsFargo); + + // Configure aggressive timeouts for testing + Banking.configurePool({ + timeouts: { + quick: { + connection: 1000, // 1 second + request: 2000, // 2 seconds + socket: 1500, // 1.5 seconds + idle: 5000 + }, + standard: { + connection: 2000, // 2 seconds + request: 5000, // 5 seconds + socket: 3000, // 3 seconds + idle: 10000 + }, + heavy: { + connection: 3000, // 3 seconds + request: 10000, // 10 seconds + socket: 5000, // 5 seconds + idle: 15000 + } + }, + retry: { + maxRetries: { + quick: 2, + standard: 3, + heavy: 2 + }, + baseDelay: 100, // Fast retries for testing + maxDelay: 2000, + backoffStrategy: 'exponential', + jitter: { + enabled: true, + type: 'equal', + factor: 0.1 + } + } + }); + }); + + afterEach(() => { + nock.cleanAll(); + Banking.destroyPool(); + }); + + describe('Operation Type Classification', () => { + it('should classify account list requests as quick operations', done => { + const startTime = Date.now(); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .delay(1800) // Just under quick timeout limit + .reply( + 200, + `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + account-list-success + + 0 + INFO + + + 20241201 + + Checking Account + 555-1234 + + 123456789 + 987654321 + CHECKING + + + + + +` + ); + + banking.getAccounts((err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(elapsed).toBeGreaterThan(1800); + expect(elapsed).toBeLessThan(2500); // Should complete before standard timeout + done(); + }); + }); + + it('should classify 30-day statement requests as quick operations', done => { + const startTime = Date.now(); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .delay(1800) + .reply( + 200, + `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + quick-statement + + 0 + INFO + + + USD + + 123456789 + 987654321 + CHECKING + + + 20241101 + 20241201 + + DEBIT + 20241115 + -50.00 + quick123 + Quick Transaction + + + + + +` + ); + + // Request 30 days (should be classified as quick) + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(elapsed).toBeGreaterThan(1800); + expect(elapsed).toBeLessThan(2500); + done(); + }); + }); + + it('should classify 6-month statement requests as standard operations', done => { + const startTime = Date.now(); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .delay(4000) // Under standard timeout + .reply( + 200, + `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + standard-statement + + 0 + INFO + + + USD + + 123456789 + 987654321 + CHECKING + + + 20240601 + 20241201 + + + + +` + ); + + // Request 6 months (should be classified as standard) + banking.getStatement({ start: 20240601, end: 20241201 }, (err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(elapsed).toBeGreaterThan(4000); + expect(elapsed).toBeLessThan(6000); + done(); + }); + }); + + it('should classify 2-year statement requests as heavy operations', done => { + const startTime = Date.now(); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .delay(8000) // Under heavy timeout + .reply( + 200, + `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + heavy-statement + + 0 + INFO + + + USD + + 123456789 + 987654321 + CHECKING + + + 20221201 + 20241201 + + + + +` + ); + + // Request 2 years (should be classified as heavy) + banking.getStatement({ start: 20221201, end: 20241201 }, (err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(elapsed).toBeGreaterThan(8000); + expect(elapsed).toBeLessThan(11000); + done(); + }); + }); + }); + + describe('Timeout Handling', () => { + it('should timeout quick operations appropriately', done => { + const startTime = Date.now(); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .delay(3000) // Longer than quick timeout + .reply(200, 'Should not reach here'); + + banking.getAccounts((err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(err.code).toMatch(/ETIMEDOUT|ESOCKETTIMEDOUT|ECONNTIMEDOUT/); + expect(elapsed).toBeGreaterThan(2000); + expect(elapsed).toBeLessThan(4000); + done(); + }); + }); + + it('should timeout standard operations appropriately', done => { + const startTime = Date.now(); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .delay(7000) // Longer than standard timeout + .reply(200, 'Should not reach here'); + + banking.getStatement({ start: 20240601, end: 20241201 }, (err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(err.code).toMatch(/ETIMEDOUT|ESOCKETTIMEDOUT|ECONNTIMEDOUT/); + expect(elapsed).toBeGreaterThan(5000); + expect(elapsed).toBeLessThan(8000); + done(); + }); + }); + + it('should timeout heavy operations appropriately', done => { + const startTime = Date.now(); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .delay(12000) // Longer than heavy timeout + .reply(200, 'Should not reach here'); + + banking.getStatement({ start: 20221201, end: 20241201 }, (err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(err.code).toMatch(/ETIMEDOUT|ESOCKETTIMEDOUT|ECONNTIMEDOUT/); + expect(elapsed).toBeGreaterThan(10000); + expect(elapsed).toBeLessThan(15000); + done(); + }); + }, 20000); // Increase test timeout + }); + + describe('Retry Logic', () => { + it('should retry on connection reset errors', done => { + let attemptCount = 0; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').times(2).replyWithError({ code: 'ECONNRESET', message: 'Connection reset by peer' }); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .reply( + 200, + `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + +` + ); + + const startTime = Date.now(); + banking.getAccounts((err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(elapsed).toBeGreaterThan(200); // Should have some retry delay + done(); + }); + }); + + it('should retry on 500 server errors', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').times(2).reply(500, 'Internal Server Error'); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .reply( + 200, + `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + +` + ); + + banking.getAccounts((err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + + it('should NOT retry on 401 authentication errors', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(401, 'Unauthorized - Invalid Credentials'); + + const startTime = Date.now(); + banking.getAccounts((err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(err.statusCode).toBe(401); + expect(elapsed).toBeLessThan(1000); // Should fail quickly without retries + done(); + }); + }); + + it('should NOT retry on OFX invalid credentials errors', done => { + const invalidCredentialsResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 15500 + ERROR + INVALID SIGNON + + 20241201120000.000 + ENG + + +`; + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, invalidCredentialsResponse); + + const startTime = Date.now(); + banking.getAccounts((err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBe(false); // OFX errors are not HTTP errors + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15500'); + expect(elapsed).toBeLessThan(1000); // Should complete quickly + done(); + }); + }); + + it('should respect maximum retry limits', done => { + // Mock 4 failures (more than max retries for quick operations) + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .times(3) // 2 retries + 1 initial = 3 total attempts + .replyWithError({ code: 'ECONNRESET', message: 'Connection reset by peer' }); + + const startTime = Date.now(); + banking.getAccounts((err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(err.code).toBe('ECONNRESET'); + expect(elapsed).toBeGreaterThan(200); // Should have some retry delays + done(); + }); + }); + }); + + describe('Exponential Backoff', () => { + it('should increase delay between retries exponentially', done => { + const delays = []; + let startTime = Date.now(); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').times(3).replyWithError({ code: 'ETIMEDOUT', message: 'Request timeout' }); + + banking.getAccounts((err, res) => { + const totalTime = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(err.code).toBe('ETIMEDOUT'); + // With exponential backoff (base 100ms): attempt 1 -> wait ~100ms -> attempt 2 -> wait ~200ms -> attempt 3 + expect(totalTime).toBeGreaterThan(300); // At least base delays + done(); + }); + }); + }); + + describe('Rate Limiting', () => { + it('should enforce rate limiting between requests', done => { + // Configure more aggressive rate limiting for testing + Banking.configurePool({ + retry: { + rateLimiting: { + enabled: true, + maxConcurrent: 1, + requestInterval: 200 // 200ms between requests + } + } + }); + + const startTime = Date.now(); + let firstCompleted = false; + let firstTime = 0; + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .times(2) + .reply( + 200, + `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + +` + ); + + // Make first request + banking.getAccounts((err, res) => { + expect(err).toBe(false); + firstCompleted = true; + firstTime = Date.now() - startTime; + }); + + // Make second request immediately + banking.getAccounts((err, res) => { + expect(err).toBe(false); + expect(firstCompleted).toBe(true); + + const secondTime = Date.now() - startTime; + const timeDiff = secondTime - firstTime; + + // Second request should be delayed by rate limiting + expect(timeDiff).toBeGreaterThan(200); + done(); + }); + }); + }); + + describe('Metrics Collection', () => { + it('should collect comprehensive metrics', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(500, 'Server Error'); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .reply( + 200, + `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + +` + ); + + banking.getAccounts((err, res) => { + expect(err).toBe(false); + + const metrics = Banking.getPoolMetrics(); + expect(metrics).toBeDefined(); + expect(metrics.totalRequests).toBeGreaterThan(0); + expect(metrics.operationTypes.quick).toBeGreaterThan(0); + + const retryMetrics = Banking.getRetryMetrics(); + expect(retryMetrics).toBeDefined(); + expect(retryMetrics.httpErrors).toBeGreaterThan(0); + expect(retryMetrics.successfulRetries).toBeGreaterThan(0); + + done(); + }); + }); + + it('should reset retry metrics when requested', () => { + const initialMetrics = Banking.getRetryMetrics(); + Banking.resetRetryMetrics(); + const resetMetrics = Banking.getRetryMetrics(); + + expect(resetMetrics.totalAttempts).toBe(0); + expect(resetMetrics.successfulRetries).toBe(0); + expect(resetMetrics.failedRetries).toBe(0); + }); + }); + + describe('Configuration Flexibility', () => { + it('should allow custom timeout configuration', done => { + Banking.configureTimeouts({ + quick: { + connection: 500, + request: 1000, + socket: 750 + } + }); + + const startTime = Date.now(); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .delay(1200) // Longer than custom quick timeout + .reply(200, 'Should not reach here'); + + banking.getAccounts((err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(err.code).toMatch(/ETIMEDOUT|ESOCKETTIMEDOUT|ECONNTIMEDOUT/); + expect(elapsed).toBeGreaterThan(1000); + expect(elapsed).toBeLessThan(1500); + done(); + }); + }); + + it('should allow custom retry configuration', done => { + Banking.configureRetry({ + maxRetries: { + quick: 1 // Only 1 retry + }, + baseDelay: 50, + backoffStrategy: 'fixed' + }); + + nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp') + .times(2) // Should only make 2 attempts total (1 retry) + .replyWithError({ code: 'ECONNRESET', message: 'Connection reset' }); + + const startTime = Date.now(); + banking.getAccounts((err, res) => { + const elapsed = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(err.code).toBe('ECONNRESET'); + expect(elapsed).toBeGreaterThan(50); // At least one retry delay + expect(elapsed).toBeLessThan(200); // But not too many retries + done(); + }); + }); + }); +}); diff --git a/test/integration/us-bank.test.js b/test/integration/us-bank.test.js new file mode 100644 index 0000000..764cd72 --- /dev/null +++ b/test/integration/us-bank.test.js @@ -0,0 +1,538 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; +import { + usBankStatementResponse, + invalidCredentialsResponse, + accountNotFoundResponse, + malformedOFXResponse, + bankConfigs +} from '../fixtures/responses.js'; + +describe('US Bank Integration Tests', () => { + let banking; + + beforeEach(() => { + banking = new Banking(bankConfigs.usBank); + }); + + describe('getStatement', () => { + it('should successfully retrieve US Bank statement', done => { + // Mock the OFX server response + nock('https://www.usbank.com').post('/ofxroot').reply(200, usBankStatementResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body).toBeDefined(); + expect(res.body.OFX).toBeDefined(); + + // Verify OFX structure + const ofx = res.body.OFX; + expect(ofx.SIGNONMSGSRSV1).toBeDefined(); + expect(ofx.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('0'); + expect(ofx.SIGNONMSGSRSV1.SONRS.FI.ORG).toBe('U.S. Bank'); + expect(ofx.SIGNONMSGSRSV1.SONRS.FI.FID).toBe('1001'); + + // Verify bank statement data + expect(ofx.BANKMSGSRSV1).toBeDefined(); + const stmtrs = ofx.BANKMSGSRSV1.STMTTRNRS.STMTRS; + expect(stmtrs.CURDEF).toBe('USD'); + expect(stmtrs.BANKACCTFROM.BANKID).toBe('091000022'); + expect(stmtrs.BANKACCTFROM.ACCTID).toBe('7777888899'); + expect(stmtrs.BANKACCTFROM.ACCTTYPE).toBe('SAVINGS'); + + // Verify transactions + const transactions = stmtrs.BANKTRANLIST.STMTTRN; + expect(Array.isArray(transactions)).toBe(true); + expect(transactions.length).toBeGreaterThan(0); + + // Verify specific US Bank transactions + const transferTransaction = transactions.find(t => t.NAME === 'TRANSFER FROM CHECKING'); + expect(transferTransaction).toBeDefined(); + expect(transferTransaction.TRNTYPE).toBe('CREDIT'); + expect(transferTransaction.TRNAMT).toBe('5000.00'); + expect(transferTransaction.MEMO).toBe('INTERNAL TRANSFER'); + + const interestTransaction = transactions.find(t => t.NAME === 'INTEREST PAYMENT'); + expect(interestTransaction).toBeDefined(); + expect(interestTransaction.TRNTYPE).toBe('CREDIT'); + expect(interestTransaction.TRNAMT).toBe('15.25'); + expect(interestTransaction.MEMO).toBe('MONTHLY INTEREST'); + + // Verify balance information + expect(stmtrs.LEDGERBAL).toBeDefined(); + expect(stmtrs.LEDGERBAL.BALAMT).toBe('15015.25'); + expect(stmtrs.AVAILBAL).toBeDefined(); + expect(stmtrs.AVAILBAL.BALAMT).toBe('15015.25'); + + done(); + }); + }); + + it('should handle US Bank authentication errors', done => { + nock('https://www.usbank.com').post('/ofxroot').reply(200, invalidCredentialsResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15500'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.MESSAGE).toBe('INVALID SIGNON'); + done(); + }); + }); + + it('should send properly formatted US Bank OFX request', done => { + const scope = nock('https://www.usbank.com') + .post('/ofxroot', body => { + // Verify US Bank-specific OFX request format + expect(body).toContain('OFXHEADER:100'); + expect(body).toContain('DATA:OFXSGML'); + expect(body).toContain('VERSION:102'); + expect(body).toContain('testuser'); + expect(body).toContain('testpass'); + expect(body).toContain('1001'); + expect(body).toContain('091000022'); + expect(body).toContain('7777888899'); + expect(body).toContain('SAVINGS'); + expect(body).toContain('20241101'); + expect(body).toContain('20241201'); + return true; + }) + .reply(200, usBankStatementResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + + it('should handle US Bank server errors', done => { + nock('https://www.usbank.com').post('/ofxroot').reply(500, 'Internal Server Error'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle US Bank connection timeouts', done => { + nock('https://www.usbank.com').post('/ofxroot').delay(8000).reply(200, usBankStatementResponse); + + const startTime = Date.now(); + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + const elapsedTime = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(elapsedTime).toBeGreaterThan(1000); + done(); + }); + }); + + it('should handle US Bank account not found error', done => { + nock('https://www.usbank.com').post('/ofxroot').reply(200, accountNotFoundResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('10500'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('INVALID ACCOUNT NUMBER'); + done(); + }); + }); + }); + + describe('getAccounts', () => { + it('should successfully retrieve US Bank account list', done => { + const usBankAccountListResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:test-uid-usbank-accounts + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000[-6:CST] + ENG + + U.S. Bank + 1001 + + + + + + usbank-accounts-uid-202 + + 0 + INFO + SUCCESS + + + 20241201120000.000 + + + + 091000022 + 7777888899 + SAVINGS + + Y + Y + Y + ACTIVE + + + + + + 091000022 + 1111222233 + CHECKING + + Y + Y + Y + ACTIVE + + + + + + 091000022 + 9999888877 + MONEYMRKT + + Y + Y + Y + ACTIVE + + + + + +`; + + nock('https://www.usbank.com').post('/ofxroot').reply(200, usBankAccountListResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getAccounts((err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX).toBeDefined(); + + const acctInfo = res.body.OFX.SIGNUPMSGSRSV1.ACCTINFOTRNRS.ACCTINFORS.ACCTINFO; + expect(Array.isArray(acctInfo)).toBe(true); + expect(acctInfo.length).toBe(3); // Savings, checking, and money market + + // Verify savings account + const savingsAccount = acctInfo.find(acc => acc.BANKACCTINFO && acc.BANKACCTINFO.BANKACCTFROM.ACCTTYPE === 'SAVINGS'); + expect(savingsAccount).toBeDefined(); + expect(savingsAccount.BANKACCTINFO.BANKACCTFROM.ACCTID).toBe('7777888899'); + expect(savingsAccount.BANKACCTINFO.BANKACCTFROM.BANKID).toBe('091000022'); + expect(savingsAccount.BANKACCTINFO.SVCSTATUS).toBe('ACTIVE'); + + // Verify checking account + const checkingAccount = acctInfo.find(acc => acc.BANKACCTINFO && acc.BANKACCTINFO.BANKACCTFROM.ACCTTYPE === 'CHECKING'); + expect(checkingAccount).toBeDefined(); + expect(checkingAccount.BANKACCTINFO.BANKACCTFROM.ACCTID).toBe('1111222233'); + + // Verify money market account + const moneyMarketAccount = acctInfo.find(acc => acc.BANKACCTINFO && acc.BANKACCTINFO.BANKACCTFROM.ACCTTYPE === 'MONEYMRKT'); + expect(moneyMarketAccount).toBeDefined(); + expect(moneyMarketAccount.BANKACCTINFO.BANKACCTFROM.ACCTID).toBe('9999888877'); + expect(moneyMarketAccount.BANKACCTINFO.SVCSTATUS).toBe('ACTIVE'); + + done(); + }); + }); + }); + + describe('US Bank Specific Features', () => { + it('should handle US Bank interest calculations correctly', done => { + nock('https://www.usbank.com').post('/ofxroot').reply(200, usBankStatementResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + + // Verify interest transaction handling + const interestTransactions = transactions.filter(t => t.NAME === 'INTEREST PAYMENT' || t.MEMO === 'MONTHLY INTEREST'); + expect(interestTransactions.length).toBeGreaterThan(0); + + const interestTransaction = interestTransactions[0]; + expect(interestTransaction.TRNTYPE).toBe('CREDIT'); + expect(parseFloat(interestTransaction.TRNAMT)).toBeGreaterThan(0); + + done(); + }); + }); + + it('should handle US Bank internal transfers', done => { + nock('https://www.usbank.com').post('/ofxroot').reply(200, usBankStatementResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + + const transactions = res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN; + + // Verify internal transfer transaction + const transferTransactions = transactions.filter(t => t.MEMO === 'INTERNAL TRANSFER' || t.NAME.includes('TRANSFER')); + expect(transferTransactions.length).toBeGreaterThan(0); + + const transferTransaction = transferTransactions[0]; + expect(transferTransaction.NAME).toBe('TRANSFER FROM CHECKING'); + expect(transferTransaction.TRNTYPE).toBe('CREDIT'); + expect(parseFloat(transferTransaction.TRNAMT)).toBeGreaterThan(0); + + done(); + }); + }); + + it('should handle US Bank routing number validation', done => { + const invalidRoutingBanking = new Banking({ + ...bankConfigs.usBank, + bankId: '999999999' // Invalid routing number + }); + + const invalidRoutingResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + invalid-routing-error + + 10400 + ERROR + INVALID BANK ROUTING NUMBER + + + +`; + + nock('https://www.usbank.com').post('/ofxroot').reply(200, invalidRoutingResponse, { + 'Content-Type': 'application/x-ofx' + }); + + invalidRoutingBanking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('10400'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('INVALID BANK ROUTING NUMBER'); + done(); + }); + }); + + it('should handle US Bank account closure scenarios', done => { + const closedAccountResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + SUCCESS + + 20241201120000.000 + ENG + + + + + closed-account-error + + 10404 + ERROR + ACCOUNT CLOSED + + + +`; + + nock('https://www.usbank.com').post('/ofxroot').reply(200, closedAccountResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('10404'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('ACCOUNT CLOSED'); + done(); + }); + }); + + it('should handle different savings account types', done => { + // Test with a different savings account configuration + const premiumSavingsBanking = new Banking({ + ...bankConfigs.usBank, + accId: '5555666677', + accType: 'SAVINGS' + }); + + const premiumSavingsResponse = usBankStatementResponse.replace('7777888899', '5555666677'); + + const scope = nock('https://www.usbank.com') + .post('/ofxroot', body => { + expect(body).toContain('5555666677'); + expect(body).toContain('SAVINGS'); + return true; + }) + .reply(200, premiumSavingsResponse); + + premiumSavingsBanking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKACCTFROM.ACCTID).toBe('5555666677'); + done(); + }); + }); + }); + + describe('US Bank Error Scenarios', () => { + it('should handle US Bank maintenance windows', done => { + const maintenanceResponse = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 2025 + ERROR + SYSTEM UNAVAILABLE - MAINTENANCE + + 20241201120000.000 + ENG + + +`; + + nock('https://www.usbank.com').post('/ofxroot').reply(503, maintenanceResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle malformed OFX responses', done => { + nock('https://www.usbank.com').post('/ofxroot').reply(200, malformedOFXResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle network connectivity issues', done => { + nock('https://www.usbank.com').post('/ofxroot').replyWithError('ENETUNREACH'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + expect(err.code).toBe('ENETUNREACH'); + done(); + }); + }); + + it('should handle US Bank rate limiting', done => { + nock('https://www.usbank.com').post('/ofxroot').reply(429, 'Too Many Requests', { + 'Retry-After': '60' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + }); + + describe('Date and Transaction Handling', () => { + it('should handle empty transaction periods', done => { + const emptyTransactionResponse = usBankStatementResponse.replace(/[\s\S]*?<\/STMTTRN>/g, ''); + + nock('https://www.usbank.com').post('/ofxroot').reply(200, emptyTransactionResponse); + + banking.getStatement({ start: 20241101, end: 20241102 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST).toBeDefined(); + done(); + }); + }); + + it('should validate date range parameters', done => { + const scope = nock('https://www.usbank.com') + .post('/ofxroot', body => { + expect(body).toContain('20241101'); + expect(body).toContain('20241201'); + return true; + }) + .reply(200, usBankStatementResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + }); +}); diff --git a/test/integration/wells-fargo.test.js b/test/integration/wells-fargo.test.js new file mode 100644 index 0000000..5f6c065 --- /dev/null +++ b/test/integration/wells-fargo.test.js @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import nock from 'nock'; +import Banking from '../../index.js'; +import { + wellsFargoStatementResponse, + wellsFargoAccountListResponse, + invalidCredentialsResponse, + accountNotFoundResponse, + malformedOFXResponse, + bankConfigs +} from '../fixtures/responses.js'; + +describe('Wells Fargo Integration Tests', () => { + let banking; + + beforeEach(() => { + banking = new Banking(bankConfigs.wellsFargo); + }); + + describe('getStatement', () => { + it('should successfully retrieve transaction statement', done => { + // Mock the OFX server response + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, wellsFargoStatementResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body).toBeDefined(); + expect(res.body.OFX).toBeDefined(); + + // Verify OFX structure + const ofx = res.body.OFX; + expect(ofx.SIGNONMSGSRSV1).toBeDefined(); + expect(ofx.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('0'); + expect(ofx.SIGNONMSGSRSV1.SONRS.FI.ORG).toBe('Wells Fargo'); + expect(ofx.SIGNONMSGSRSV1.SONRS.FI.FID).toBe('3001'); + + // Verify bank statement data + expect(ofx.BANKMSGSRSV1).toBeDefined(); + const stmtrs = ofx.BANKMSGSRSV1.STMTTRNRS.STMTRS; + expect(stmtrs.CURDEF).toBe('USD'); + expect(stmtrs.BANKACCTFROM.BANKID).toBe('123006800'); + expect(stmtrs.BANKACCTFROM.ACCTID).toBe('1234567890'); + expect(stmtrs.BANKACCTFROM.ACCTTYPE).toBe('CHECKING'); + + // Verify transactions + const transactions = stmtrs.BANKTRANLIST.STMTTRN; + expect(Array.isArray(transactions)).toBe(true); + expect(transactions.length).toBeGreaterThan(0); + + // Verify specific transaction data + const debitTransaction = transactions.find(t => t.TRNTYPE === 'DEBIT'); + expect(debitTransaction).toBeDefined(); + expect(debitTransaction.TRNAMT).toBe('-150.00'); + expect(debitTransaction.NAME).toBe('GROCERY STORE PURCHASE'); + + const creditTransaction = transactions.find(t => t.TRNTYPE === 'CREDIT'); + expect(creditTransaction).toBeDefined(); + expect(creditTransaction.TRNAMT).toBe('2500.00'); + expect(creditTransaction.NAME).toBe('DIRECT DEPOSIT'); + + // Verify balance information + expect(stmtrs.LEDGERBAL).toBeDefined(); + expect(stmtrs.LEDGERBAL.BALAMT).toBe('2338.00'); + expect(stmtrs.AVAILBAL).toBeDefined(); + expect(stmtrs.AVAILBAL.BALAMT).toBe('2338.00'); + + done(); + }); + }); + + it('should handle invalid credentials error', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, invalidCredentialsResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15500'); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.MESSAGE).toBe('INVALID SIGNON'); + done(); + }); + }); + + it('should handle account not found error', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, accountNotFoundResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.CODE).toBe('10500'); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STATUS.MESSAGE).toBe('INVALID ACCOUNT NUMBER'); + done(); + }); + }); + + it('should handle network timeout', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').delay(5000).reply(200, wellsFargoStatementResponse); + + const startTime = Date.now(); + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + const elapsedTime = Date.now() - startTime; + expect(err).toBeTruthy(); + expect(elapsedTime).toBeGreaterThan(1000); // Should timeout + done(); + }); + }); + + it('should handle malformed OFX response', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, malformedOFXResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + // Should handle parsing errors gracefully + expect(err).toBeTruthy(); + done(); + }); + }); + + it('should handle HTTP 500 server error', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(500, 'Internal Server Error'); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBeTruthy(); + done(); + }); + }); + }); + + describe('getAccounts', () => { + it('should successfully retrieve account list', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, wellsFargoAccountListResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getAccounts((err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body).toBeDefined(); + expect(res.body.OFX).toBeDefined(); + + // Verify OFX structure + const ofx = res.body.OFX; + expect(ofx.SIGNONMSGSRSV1).toBeDefined(); + expect(ofx.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('0'); + + // Verify account information + expect(ofx.SIGNUPMSGSRSV1).toBeDefined(); + const acctInfo = ofx.SIGNUPMSGSRSV1.ACCTINFOTRNRS.ACCTINFORS.ACCTINFO; + expect(Array.isArray(acctInfo)).toBe(true); + expect(acctInfo.length).toBeGreaterThan(0); + + // Verify checking account + const checkingAccount = acctInfo.find(acc => acc.BANKACCTINFO && acc.BANKACCTINFO.BANKACCTFROM.ACCTTYPE === 'CHECKING'); + expect(checkingAccount).toBeDefined(); + expect(checkingAccount.BANKACCTINFO.BANKACCTFROM.ACCTID).toBe('1234567890'); + expect(checkingAccount.BANKACCTINFO.BANKACCTFROM.BANKID).toBe('123006800'); + expect(checkingAccount.BANKACCTINFO.SVCSTATUS).toBe('ACTIVE'); + + // Verify savings account + const savingsAccount = acctInfo.find(acc => acc.BANKACCTINFO && acc.BANKACCTINFO.BANKACCTFROM.ACCTTYPE === 'SAVINGS'); + expect(savingsAccount).toBeDefined(); + expect(savingsAccount.BANKACCTINFO.BANKACCTFROM.ACCTID).toBe('9876543210'); + expect(savingsAccount.BANKACCTINFO.SVCSTATUS).toBe('ACTIVE'); + + done(); + }); + }); + + it('should handle authentication error for account list', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, invalidCredentialsResponse, { + 'Content-Type': 'application/x-ofx' + }); + + banking.getAccounts((err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.SIGNONMSGSRSV1.SONRS.STATUS.CODE).toBe('15500'); + done(); + }); + }); + }); + + describe('OFX Request Validation', () => { + it('should send properly formatted OFX request for statement', done => { + const scope = nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp', body => { + // Verify OFX request format + expect(body).toContain('OFXHEADER:100'); + expect(body).toContain('DATA:OFXSGML'); + expect(body).toContain('VERSION:'); + expect(body).toContain('testuser'); + expect(body).toContain('testpass'); + expect(body).toContain('3001'); + expect(body).toContain('123006800'); + expect(body).toContain('1234567890'); + expect(body).toContain('CHECKING'); + expect(body).toContain('20241101'); + expect(body).toContain('20241201'); + return true; + }) + .reply(200, wellsFargoStatementResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + + it('should send properly formatted OFX request for account list', done => { + const scope = nock('https://www.oasis.cfree.com') + .post('/3001.ofxgp', body => { + // Verify OFX account list request format + expect(body).toContain('OFXHEADER:100'); + expect(body).toContain('testuser'); + expect(body).toContain('testpass'); + expect(body).toContain('3001'); + expect(body).toContain(''); + return true; + }) + .reply(200, wellsFargoAccountListResponse); + + banking.getAccounts((err, res) => { + expect(scope.isDone()).toBe(true); + expect(err).toBe(false); + done(); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty transaction list', done => { + const emptyTransactionResponse = wellsFargoStatementResponse.replace(/[\s\S]*?<\/STMTTRN>/g, ''); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, emptyTransactionResponse); + + banking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + expect(res.body.OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST).toBeDefined(); + done(); + }); + }); + + it('should handle different date ranges', done => { + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, wellsFargoStatementResponse); + + banking.getStatement({ start: 20240101, end: 20241231 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + + it('should handle custom headers', done => { + const customBanking = new Banking({ + ...bankConfigs.wellsFargo, + headers: ['Content-Type', 'Host', 'Content-Length', 'Connection'] + }); + + nock('https://www.oasis.cfree.com').post('/3001.ofxgp').reply(200, wellsFargoStatementResponse); + + customBanking.getStatement({ start: 20241101, end: 20241201 }, (err, res) => { + expect(err).toBe(false); + expect(res).toBeDefined(); + done(); + }); + }); + }); +}); diff --git a/test/parsing.js b/test/parsing.js index 9915732..66f732b 100644 --- a/test/parsing.js +++ b/test/parsing.js @@ -1,13 +1,11 @@ -var Banking = require('..') - , data = require('./fixtures/data') - , mocha = require('mocha'); +const Banking = require('..'), + data = require('./fixtures/data'), + mocha = require('mocha'); -describe('Banking', function(){ - - describe('banking.getStatement', function() { - it('should return valid xml from the wells fargo server', function(done){ - - var banking = Banking({ +describe('Banking', () => { + describe('banking.getStatement', () => { + it('should return valid xml from the wells fargo server', done => { + const banking = Banking({ fid: 3001, fidOrg: 'Wells Fargo', accType: 'checking', @@ -19,17 +17,16 @@ describe('Banking', function(){ }); //If second param is omitted JSON will be returned by default - banking.getStatement({start:20131101, end:20131120}, function (err, res) { - if(err) done(res) + banking.getStatement({ start: 20131101, end: 20131120 }, (err, res) => { + if (err) done(res); res.body.should.be.an.instanceof(Object); res.body.should.have.property('OFX'); done(); }); }); - it('should return valid xml from the discovercard server', function(done){ - - var banking = Banking({ + it('should return valid xml from the discovercard server', done => { + const banking = Banking({ fid: 7101, fidOrg: 'Discover Financial Services', accType: 'checking', @@ -42,8 +39,8 @@ describe('Banking', function(){ }); //If second param is omitted JSON will be returned by default - banking.getStatement({start:20131101, end:20131120}, function (err, res) { - if(err) done(res) + banking.getStatement({ start: 20131101, end: 20131120 }, (err, res) => { + if (err) done(res); res.body.should.be.an.instanceof(Object); res.body.should.have.property('OFX'); done(); @@ -51,16 +48,16 @@ describe('Banking', function(){ }); }); - describe('.version', function (){ - it('should output the current version', function (done){ + describe('.version', () => { + it('should output the current version', done => { Banking.version.should.equal(require('../package').version); done(); }); - }) + }); - describe('.parseFile', function(){ - it('should read the provided file and return JSON', function(done){ - Banking.parseFile(__dirname +'/fixtures/sample.ofx', function (res) { + describe('.parseFile', () => { + it('should read the provided file and return JSON', done => { + Banking.parseFile(`${__dirname}/fixtures/sample.ofx`, res => { res.body.should.be.an.instanceof(Object); res.body.should.have.property('OFX'); res.body.OFX.should.have.property('SIGNONMSGSRSV1'); @@ -70,8 +67,8 @@ describe('Banking', function(){ }); }); - it('should read a OFX file with end-tags in elements and return JSON', function(done){ - Banking.parseFile(__dirname +'/fixtures/sample-with-end-tags.ofx', function (res) { + it('should read a OFX file with end-tags in elements and return JSON', done => { + Banking.parseFile(`${__dirname}/fixtures/sample-with-end-tags.ofx`, res => { res.body.should.be.an.instanceof(Object); res.body.should.have.property('OFX'); res.body.should.have.property('OFX'); @@ -83,10 +80,9 @@ describe('Banking', function(){ }); }); - describe('.parse', function(){ - it('should read the provided string and return JSON', function(done){ - - Banking.parse(data.ofxString, function (res) { + describe('.parse', () => { + it('should read the provided string and return JSON', done => { + Banking.parse(data.ofxString, res => { res.body.should.be.an.instanceof(Object); res.body.should.have.property('OFX'); done(); diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..224d6af --- /dev/null +++ b/test/setup.js @@ -0,0 +1,33 @@ +import { beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; + +// Global test setup +beforeAll(() => { + // Disable real HTTP requests during tests + nock.disableNetConnect(); + + // Allow localhost for local testing + nock.enableNetConnect('127.0.0.1'); + nock.enableNetConnect('localhost'); +}); + +beforeEach(() => { + // Clear any existing nock interceptors + nock.cleanAll(); +}); + +afterEach(() => { + // Clean up nock interceptors after each test + nock.cleanAll(); +}); + +afterAll(() => { + // Restore HTTP connections after all tests + nock.enableNetConnect(); + nock.restore(); +}); + +// Global error handler for unhandled rejections +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); +}); diff --git a/test/test-runner.js b/test/test-runner.js new file mode 100755 index 0000000..faba70f --- /dev/null +++ b/test/test-runner.js @@ -0,0 +1,312 @@ +#!/usr/bin/env node +/** + * Comprehensive Banking.js Test Runner + * + * This script runs all integration tests for the banking.js library, + * supporting both mock mode (default) and sandbox mode with real bank connections. + * + * Usage: + * npm run test:integration # Run all tests in mock mode + * node test/test-runner.js --sandbox # Run tests against sandbox environments + * node test/test-runner.js --bank wells # Run tests for specific bank only + * node test/test-runner.js --verbose # Run with detailed logging + * node test/test-runner.js --help # Show help + */ + +import { spawn } from 'child_process'; +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')); + +// Configuration +const config = { + timeout: 30000, + retries: 2, + parallel: true, + coverage: false, + ui: false, + watch: false, + sandbox: false, + verbose: false, + bank: null, + environment: 'test' +}; + +// Supported banks +const supportedBanks = ['wells-fargo', 'discover', 'chase', 'bank-of-america', 'us-bank']; + +// Parse command line arguments +function parseArgs() { + const args = process.argv.slice(2); + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + switch (arg) { + case '--help': + case '-h': + showHelp(); + process.exit(0); + break; + + case '--sandbox': + config.sandbox = true; + console.log('šŸ–ļø Running in SANDBOX mode - will attempt real bank connections'); + break; + + case '--mock': + config.sandbox = false; + console.log('šŸŽ­ Running in MOCK mode - using mocked responses'); + break; + + case '--verbose': + case '-v': + config.verbose = true; + break; + + case '--coverage': + config.coverage = true; + break; + + case '--ui': + config.ui = true; + break; + + case '--watch': + config.watch = true; + break; + + case '--parallel': + config.parallel = true; + break; + + case '--serial': + config.parallel = false; + break; + + case '--bank': + if (i + 1 < args.length) { + const bankName = args[i + 1]; + if (supportedBanks.includes(bankName)) { + config.bank = bankName; + i++; // Skip next argument + } else { + console.error(`āŒ Unknown bank: ${bankName}`); + console.log(`Supported banks: ${supportedBanks.join(', ')}`); + process.exit(1); + } + } + break; + + case '--timeout': + if (i + 1 < args.length) { + config.timeout = parseInt(args[i + 1]); + i++; + } + break; + + case '--retries': + if (i + 1 < args.length) { + config.retries = parseInt(args[i + 1]); + i++; + } + break; + + default: + if (arg.startsWith('--')) { + console.warn(`āš ļø Unknown option: ${arg}`); + } + } + } +} + +function showHelp() { + console.log(` +Banking.js Integration Test Runner v${packageJson.version} + +USAGE: + npm run test:integration Run all integration tests in mock mode + node test/test-runner.js [OPTIONS] Run with specific options + +OPTIONS: + --help, -h Show this help message + --sandbox Run tests against sandbox/demo bank environments + --mock Run tests with mocked responses (default) + --verbose, -v Enable verbose logging + --coverage Generate coverage reports + --ui Open Vitest UI + --watch Watch mode for development + --parallel Run tests in parallel (default) + --serial Run tests serially + --bank Run tests for specific bank only + --timeout Set test timeout in milliseconds (default: 30000) + --retries Set number of retries for failed tests (default: 2) + +SUPPORTED BANKS: + ${supportedBanks.map(bank => ` ${bank}`).join('\n')} + +EXAMPLES: + node test/test-runner.js --bank wells-fargo --verbose + node test/test-runner.js --sandbox --coverage + node test/test-runner.js --watch --ui + +ENVIRONMENT VARIABLES: + NODE_ENV Set to 'test' for testing environment + DEBUG Enable debug logging for banking module + CI Continuous Integration mode (affects output format) + +TESTING MODES: + Mock Mode (default): Uses nock to mock HTTP responses, runs offline + Sandbox Mode: Attempts to connect to bank sandbox environments + (requires valid sandbox credentials) + +For more information, see: https://github.com/euforic/banking.js +`); +} + +function buildVitestArgs() { + const args = []; + + // Basic command + if (config.watch) { + args.push('watch'); + } else { + args.push('run'); + } + + // Test pattern + if (config.bank) { + args.push(`test/integration/${config.bank}.test.js`); + } else { + args.push('test/integration/'); + } + + // Coverage + if (config.coverage) { + args.push('--coverage'); + } + + // UI mode + if (config.ui) { + args.push('--ui'); + } + + // Parallel execution + if (!config.parallel) { + args.push('--pool', 'forks', '--poolOptions.forks.singleFork'); + } + + // Timeout + args.push('--testTimeout', config.timeout.toString()); + + // Retries + if (config.retries > 0) { + args.push('--retry', config.retries.toString()); + } + + // Reporter + if (process.env.CI) { + args.push('--reporter', 'json', '--reporter', 'verbose'); + } else if (config.verbose) { + args.push('--reporter', 'verbose'); + } else { + args.push('--reporter', 'default'); + } + + return args; +} + +function setEnvironmentVariables() { + // Set test environment + process.env.NODE_ENV = config.environment; + + // Set sandbox mode flag for tests to read + if (config.sandbox) { + process.env.BANKING_TEST_MODE = 'sandbox'; + console.log('āš ļø WARNING: Sandbox mode requires valid bank credentials'); + console.log(' Set environment variables for bank credentials if testing live connections'); + } else { + process.env.BANKING_TEST_MODE = 'mock'; + } + + // Enable debug logging if verbose + if (config.verbose) { + process.env.DEBUG = 'banking:*'; + } + + // Set timeout for HTTP requests + process.env.BANKING_REQUEST_TIMEOUT = config.timeout.toString(); +} + +function runTests() { + return new Promise((resolve, reject) => { + console.log(`\nšŸ¦ Banking.js Integration Test Suite v${packageJson.version}`); + console.log(`šŸ“Š Running ${config.bank || 'all banks'} tests in ${config.sandbox ? 'SANDBOX' : 'MOCK'} mode\n`); + + const vitestArgs = buildVitestArgs(); + + if (config.verbose) { + console.log('šŸ”§ Vitest command:', 'npx vitest', vitestArgs.join(' ')); + } + + const child = spawn('npx', ['vitest', ...vitestArgs], { + stdio: 'inherit', + env: process.env + }); + + child.on('close', code => { + if (code === 0) { + console.log('\nāœ… All tests passed!'); + if (config.sandbox) { + console.log('šŸ–ļø Sandbox tests completed successfully'); + } + resolve(code); + } else { + console.log(`\nāŒ Tests failed with exit code ${code}`); + if (config.sandbox) { + console.log('šŸ–ļø Note: Sandbox failures may be due to network or credential issues'); + } + reject(new Error(`Tests failed with exit code ${code}`)); + } + }); + + child.on('error', error => { + console.error('āŒ Failed to run tests:', error.message); + reject(error); + }); + }); +} + +// Main execution +async function main() { + try { + parseArgs(); + setEnvironmentVariables(); + await runTests(); + process.exit(0); + } catch (error) { + console.error('āŒ Test runner failed:', error.message); + process.exit(1); + } +} + +// Handle graceful shutdown +process.on('SIGINT', () => { + console.log('\nā¹ļø Test runner interrupted'); + process.exit(130); +}); + +process.on('SIGTERM', () => { + console.log('\nā¹ļø Test runner terminated'); + process.exit(143); +}); + +// Only run if this file is executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} + +export { config, supportedBanks, buildVitestArgs }; diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..5de8294 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,36 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Test environment + environment: 'node', + + // Global test settings + globals: true, + + // Test file patterns + include: ['test/**/*.{test,spec}.{js,ts}'], + exclude: ['node_modules', 'dist'], + + // Test timeout + testTimeout: 30000, + + // Setup files + setupFiles: ['./test/setup.js'], + + // Coverage configuration + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'test/', '*.config.js', 'index.d.ts'] + }, + + // Concurrent testing + pool: 'threads', + poolOptions: { + threads: { + singleThread: false + } + } + } +});