UNPKG

mailgun-optin-cli

Version:

CLI tool for sending opt-in confirmation emails via Mailgun

575 lines (461 loc) 16.3 kB
import { expect } from 'chai'; import { promises as fs } from 'fs'; import { join } from 'path'; import { parseArguments, validateArguments, showHelp, showVersion, loadConfiguration, validateConfiguration, createProgressIndicator, } from '../src/cli.js'; describe('CLI Module', () => { const testDataDir = join(process.cwd(), 'test', 'fixtures'); before(async () => { // Create test fixtures directory await fs.mkdir(testDataDir, { recursive: true }); }); after(async () => { // Clean up test fixtures try { await fs.rm(testDataDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } }); describe('parseArguments', () => { it('should parse basic command line arguments', () => { const argv = [ 'node', 'index.js', '--csv=subscribers.csv', '--template=template.json', '--output=results.csv', ]; const args = parseArguments(argv); expect(args).to.deep.equal({ csv: 'subscribers.csv', template: 'template.json', output: 'results.csv', }); }); it('should parse arguments with equals sign', () => { const argv = [ 'node', 'index.js', '--csv=data/subscribers.csv', '--template=config/template.json', ]; const args = parseArguments(argv); expect(args.csv).to.equal('data/subscribers.csv'); expect(args.template).to.equal('config/template.json'); }); it('should parse arguments with space separation', () => { const argv = ['node', 'index.js', '--csv', 'subscribers.csv', '--template', 'template.json']; const args = parseArguments(argv); expect(args.csv).to.equal('subscribers.csv'); expect(args.template).to.equal('template.json'); }); it('should handle boolean flags', () => { const argv = [ 'node', 'index.js', '--csv=test.csv', '--template=test.json', '--dry-run', '--verbose', ]; const args = parseArguments(argv); expect(args['dry-run']).to.be.true; expect(args.verbose).to.be.true; }); it('should handle short flags', () => { const argv = [ 'node', 'index.js', '-c', 'subscribers.csv', '-t', 'template.json', '-o', 'output.csv', '-v', ]; const args = parseArguments(argv); expect(args.c).to.equal('subscribers.csv'); expect(args.t).to.equal('template.json'); expect(args.o).to.equal('output.csv'); expect(args.v).to.be.true; }); it('should handle help and version flags', () => { const helpArgs = parseArguments(['node', 'index.js', '--help']); const versionArgs = parseArguments(['node', 'index.js', '--version']); expect(helpArgs.help).to.be.true; expect(versionArgs.version).to.be.true; }); it('should handle mixed argument styles', () => { const argv = [ 'node', 'index.js', '--csv=subscribers.csv', '-t', 'template.json', '--output', 'results.csv', '--verbose', ]; const args = parseArguments(argv); expect(args.csv).to.equal('subscribers.csv'); expect(args.t).to.equal('template.json'); expect(args.output).to.equal('results.csv'); expect(args.verbose).to.be.true; }); }); describe('validateArguments', () => { it('should validate complete required arguments', () => { const args = { csv: 'subscribers.csv', template: 'template.json', output: 'results.csv', }; const result = validateArguments(args); expect(result.valid).to.be.true; expect(result.errors).to.be.empty; }); it('should require CSV file argument', () => { const args = { template: 'template.json', output: 'results.csv', }; const result = validateArguments(args); expect(result.valid).to.be.false; expect(result.errors).to.include('CSV file path is required (--csv)'); }); it('should require template file argument', () => { const args = { csv: 'subscribers.csv', output: 'results.csv', }; const result = validateArguments(args); expect(result.valid).to.be.false; expect(result.errors).to.include('Template file path is required (--template)'); }); it('should require output file argument', () => { const args = { csv: 'subscribers.csv', template: 'template.json', }; const result = validateArguments(args); expect(result.valid).to.be.false; expect(result.errors).to.include('Output file path is required (--output)'); }); it('should validate file extensions', () => { const args = { csv: 'subscribers.txt', template: 'template.xml', output: 'results.log', }; const result = validateArguments(args); expect(result.valid).to.be.false; expect(result.errors).to.include('CSV file must have .csv extension'); expect(result.errors).to.include('Template file must have .json extension'); expect(result.errors).to.include('Output file must have .csv extension'); }); it('should handle help and version flags without validation', () => { const helpArgs = { help: true }; const versionArgs = { version: true }; expect(validateArguments(helpArgs).valid).to.be.true; expect(validateArguments(versionArgs).valid).to.be.true; }); it('should validate rate limit parameter', () => { const args = { csv: 'subscribers.csv', template: 'template.json', output: 'results.csv', 'rate-limit': 'invalid', }; const result = validateArguments(args); expect(result.valid).to.be.false; expect(result.errors).to.include('Rate limit must be a positive number'); }); it('should accept valid rate limit parameter', () => { const args = { csv: 'subscribers.csv', template: 'template.json', output: 'results.csv', 'rate-limit': '5', }; const result = validateArguments(args); expect(result.valid).to.be.true; }); }); describe('showHelp', () => { it('should return help text', () => { const helpText = showHelp(); expect(helpText).to.be.a('string'); expect(helpText).to.include('Usage:'); expect(helpText).to.include('--csv'); expect(helpText).to.include('--template'); expect(helpText).to.include('--output'); expect(helpText).to.include('Examples:'); }); it('should include all available options', () => { const helpText = showHelp(); expect(helpText).to.include('--dry-run'); expect(helpText).to.include('--rate-limit'); expect(helpText).to.include('--verbose'); expect(helpText).to.include('--help'); expect(helpText).to.include('--version'); }); }); describe('showVersion', () => { it('should return version information', () => { const versionText = showVersion(); expect(versionText).to.be.a('string'); expect(versionText).to.include('mailgun-optin-cli'); expect(versionText).to.match(/\d+\.\d+\.\d+/); // Version pattern }); }); describe('loadConfiguration', () => { beforeEach(async () => { // Create test .env file const envContent = `MAILGUN_API_KEY=test-api-key MAILGUN_DOMAIN=test.mailgun.org FROM_EMAIL=noreply@test.com FROM_NAME=Test Company CONFIRMATION_BASE_URL=https://test.com/confirm RATE_LIMIT=10`; await fs.writeFile(join(testDataDir, '.env'), envContent); }); it('should load configuration from environment variables', () => { // Set environment variables process.env.MAILGUN_API_KEY = 'env-api-key'; process.env.MAILGUN_DOMAIN = 'env.mailgun.org'; process.env.FROM_EMAIL = 'env@test.com'; const config = loadConfiguration(); expect(config.mailgun.apiKey).to.equal('env-api-key'); expect(config.mailgun.domain).to.equal('env.mailgun.org'); expect(config.mailgun.fromEmail).to.equal('env@test.com'); // Clean up delete process.env.MAILGUN_API_KEY; delete process.env.MAILGUN_DOMAIN; delete process.env.FROM_EMAIL; }); it('should use default values for missing configuration', () => { // Clear environment variables delete process.env.MAILGUN_API_KEY; delete process.env.FROM_NAME; delete process.env.RATE_LIMIT; const config = loadConfiguration(); expect(config.mailgun.fromName).to.equal('Mailer'); expect(config.rateLimit).to.equal(10); }); it('should override configuration with command line arguments', () => { const args = { 'rate-limit': '5', 'from-email': 'cli@test.com', }; const config = loadConfiguration(args); expect(config.rateLimit).to.equal(5); expect(config.mailgun.fromEmail).to.equal('cli@test.com'); }); }); describe('validateConfiguration', () => { it('should validate complete configuration', () => { const config = { mailgun: { apiKey: 'test-api-key', domain: 'test.mailgun.org', fromEmail: 'noreply@test.com', fromName: 'Test Company', }, confirmationBaseUrl: 'https://test.com/confirm', rateLimit: 10, }; const result = validateConfiguration(config); expect(result.valid).to.be.true; expect(result.errors).to.be.empty; }); it('should require Mailgun API key', () => { const config = { mailgun: { domain: 'test.mailgun.org', fromEmail: 'noreply@test.com', }, confirmationBaseUrl: 'https://test.com/confirm', }; const result = validateConfiguration(config); expect(result.valid).to.be.false; expect(result.errors).to.include('Mailgun API key is required'); }); it('should require Mailgun domain', () => { const config = { mailgun: { apiKey: 'test-api-key', fromEmail: 'noreply@test.com', }, confirmationBaseUrl: 'https://test.com/confirm', }; const result = validateConfiguration(config); expect(result.valid).to.be.false; expect(result.errors).to.include('Mailgun domain is required'); }); it('should require from email address', () => { const config = { mailgun: { apiKey: 'test-api-key', domain: 'test.mailgun.org', }, confirmationBaseUrl: 'https://test.com/confirm', }; const result = validateConfiguration(config); expect(result.valid).to.be.false; expect(result.errors).to.include('From email address is required'); }); it('should validate email format', () => { const config = { mailgun: { apiKey: 'test-api-key', domain: 'test.mailgun.org', fromEmail: 'invalid-email', }, confirmationBaseUrl: 'https://test.com/confirm', }; const result = validateConfiguration(config); expect(result.valid).to.be.false; expect(result.errors).to.include('From email address is invalid'); }); it('should require confirmation base URL', () => { const config = { mailgun: { apiKey: 'test-api-key', domain: 'test.mailgun.org', fromEmail: 'noreply@test.com', }, }; const result = validateConfiguration(config); expect(result.valid).to.be.false; expect(result.errors).to.include('Confirmation base URL is required'); }); it('should validate URL format', () => { const config = { mailgun: { apiKey: 'test-api-key', domain: 'test.mailgun.org', fromEmail: 'noreply@test.com', }, confirmationBaseUrl: 'invalid-url', }; const result = validateConfiguration(config); expect(result.valid).to.be.false; expect(result.errors).to.include('Confirmation base URL is invalid'); }); it('should validate rate limit', () => { const config = { mailgun: { apiKey: 'test-api-key', domain: 'test.mailgun.org', fromEmail: 'noreply@test.com', }, confirmationBaseUrl: 'https://test.com/confirm', rateLimit: -1, }; const result = validateConfiguration(config); expect(result.valid).to.be.false; expect(result.errors).to.include('Rate limit must be a positive number'); }); }); describe('Argument Mapping', () => { it('should map short flags to long flags', () => { const args = parseArguments([ 'node', 'index.js', '-c', 'test.csv', '-t', 'test.json', '-o', 'output.csv', '-v', '-h', ]); // Should map short flags appropriately expect(args.c).to.equal('test.csv'); expect(args.t).to.equal('test.json'); expect(args.o).to.equal('output.csv'); expect(args.v).to.be.true; expect(args.h).to.be.true; }); it('should handle conflicting short and long flags', () => { const args = parseArguments(['node', 'index.js', '--csv=long.csv', '-c', 'short.csv']); // Last one should win expect(args.c).to.equal('short.csv'); expect(args.csv).to.equal('long.csv'); }); }); describe('createProgressIndicator', () => { let originalStdoutWrite; let capturedOutput; beforeEach(() => { // Capture stdout.write calls capturedOutput = []; originalStdoutWrite = process.stdout.write; process.stdout.write = function (string) { capturedOutput.push(string); return true; }; }); afterEach(() => { // Restore original stdout.write process.stdout.write = originalStdoutWrite; }); it('should create progress indicator for normal list size', () => { const progress = createProgressIndicator(100); progress.update(25); expect(capturedOutput.length).to.be.greaterThan(0); expect(capturedOutput[0]).to.include('25%'); expect(capturedOutput[0]).to.include('(25/100)'); }); it('should handle small list sizes without negative repeat count', () => { const progress = createProgressIndicator(2); // This should not throw an error expect(() => progress.update(1)).to.not.throw(); expect(() => progress.update(1)).to.not.throw(); expect(capturedOutput.length).to.be.greaterThan(0); expect(capturedOutput[0]).to.include('50%'); expect(capturedOutput[1]).to.include('100%'); }); it('should handle single item list', () => { const progress = createProgressIndicator(1); expect(() => progress.update(1)).to.not.throw(); expect(capturedOutput.length).to.be.greaterThan(0); expect(capturedOutput[0]).to.include('100%'); expect(capturedOutput[0]).to.include('(1/1)'); }); it('should handle zero progress correctly', () => { const progress = createProgressIndicator(10); progress.update(0); expect(capturedOutput.length).to.be.greaterThan(0); expect(capturedOutput[0]).to.include('0%'); expect(capturedOutput[0]).to.include('(0/10)'); }); it('should complete progress bar correctly', () => { const progress = createProgressIndicator(5); progress.complete(); expect(capturedOutput.length).to.be.greaterThan(0); expect(capturedOutput[0]).to.include('100%'); expect(capturedOutput[0]).to.include('(5/5)'); expect(capturedOutput[0]).to.include('█'.repeat(50)); }); it('should handle progress over 100% gracefully', () => { const progress = createProgressIndicator(2); // Update beyond total progress.update(3); expect(capturedOutput.length).to.be.greaterThan(0); expect(capturedOutput[0]).to.include('150%'); expect(capturedOutput[0]).to.include('(3/2)'); // Should not throw error even with percentage > 100% }); }); });