diff --git a/chat/src/tests/account-management.test.js b/chat/src/tests/account-management.test.js new file mode 100644 index 0000000..b2c3c52 --- /dev/null +++ b/chat/src/tests/account-management.test.js @@ -0,0 +1,540 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') +const bcrypt = require('bcrypt') + +const PASSWORD_SALT_ROUNDS = 12 +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +describe('Account Management', () => { + describe('User Registration', () => { + function validateEmail(email) { + return EMAIL_REGEX.test(email) + } + + function validatePassword(password) { + const errors = [] + if (!password || password.length < 12) errors.push('Minimum 12 characters') + if (!/[A-Z]/.test(password)) errors.push('Uppercase letter required') + if (!/[a-z]/.test(password)) errors.push('Lowercase letter required') + if (!/[0-9]/.test(password)) errors.push('Number required') + if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) errors.push('Special character required') + return { valid: errors.length === 0, errors } + } + + async function hashPassword(password) { + return bcrypt.hash(password, PASSWORD_SALT_ROUNDS) + } + + test('validates email format', () => { + expect(validateEmail('test@example.com')).toBe(true) + expect(validateEmail('user.name@domain.co.uk')).toBe(true) + expect(validateEmail('invalid')).toBe(false) + expect(validateEmail('@nodomain.com')).toBe(false) + }) + + test('validates strong password', () => { + const result = validatePassword('StrongP@ss123') + expect(result.valid).toBe(true) + }) + + test('rejects weak passwords', () => { + expect(validatePassword('short').errors.length).toBeGreaterThan(0) + expect(validatePassword('alllowercase123!').errors).toContain('Uppercase letter required') + expect(validatePassword('ALLUPPERCASE123!').errors).toContain('Lowercase letter required') + expect(validatePassword('NoNumbers!').errors).toContain('Number required') + expect(validatePassword('NoSpecial123').errors).toContain('Special character required') + }) + + test('requires 12 character minimum', () => { + const result = validatePassword('Short1!') + expect(result.errors).toContain('Minimum 12 characters') + }) + + test('hashes password securely', async () => { + const password = 'TestPassword123!' + const hash = await hashPassword(password) + + expect(hash).toBeDefined() + expect(hash).not.toBe(password) + expect(await bcrypt.compare(password, hash)).toBe(true) + }) + + test('generates unique hashes', async () => { + const password = 'TestPassword123!' + const hash1 = await hashPassword(password) + const hash2 = await hashPassword(password) + + expect(hash1).not.toBe(hash2) + }) + }) + + describe('Email Change', () => { + function initiateEmailChange(user, newEmail) { + if (!validateEmail(newEmail)) { + return { error: 'Invalid email format' } + } + if (newEmail.toLowerCase() === user.email.toLowerCase()) { + return { error: 'New email must be different' } + } + + const token = crypto.randomBytes(32).toString('hex') + return { + success: true, + pendingEmail: newEmail, + verificationToken: token, + expiresAt: Date.now() + 24 * 60 * 60 * 1000, + } + } + + function confirmEmailChange(user, token) { + if (user.verificationToken !== token) { + return { error: 'Invalid token' } + } + if (Date.now() > user.expiresAt) { + return { error: 'Token expired' } + } + + return { + success: true, + newEmail: user.pendingEmail, + } + } + + function validateEmail(email) { + return EMAIL_REGEX.test(email) + } + + test('initiates email change', () => { + const user = { email: 'old@example.com' } + const result = initiateEmailChange(user, 'new@example.com') + + expect(result.success).toBe(true) + expect(result.pendingEmail).toBe('new@example.com') + expect(result.verificationToken).toBeDefined() + }) + + test('rejects invalid email', () => { + const user = { email: 'old@example.com' } + const result = initiateEmailChange(user, 'invalid-email') + + expect(result.error).toBe('Invalid email format') + }) + + test('rejects same email', () => { + const user = { email: 'same@example.com' } + const result = initiateEmailChange(user, 'same@example.com') + + expect(result.error).toBe('New email must be different') + }) + + test('confirms email change with valid token', () => { + const user = { + email: 'old@example.com', + pendingEmail: 'new@example.com', + verificationToken: 'valid-token', + expiresAt: Date.now() + 3600000, + } + const result = confirmEmailChange(user, 'valid-token') + + expect(result.success).toBe(true) + expect(result.newEmail).toBe('new@example.com') + }) + + test('rejects invalid token', () => { + const user = { + email: 'old@example.com', + verificationToken: 'valid-token', + expiresAt: Date.now() + 3600000, + } + const result = confirmEmailChange(user, 'wrong-token') + + expect(result.error).toBe('Invalid token') + }) + + test('rejects expired token', () => { + const user = { + email: 'old@example.com', + verificationToken: 'valid-token', + expiresAt: Date.now() - 1000, + } + const result = confirmEmailChange(user, 'valid-token') + + expect(result.error).toBe('Token expired') + }) + }) + + describe('Password Change', () => { + async function changePassword(user, currentPassword, newPassword) { + const validCurrent = await bcrypt.compare(currentPassword, user.passwordHash) + if (!validCurrent) { + return { error: 'Current password is incorrect' } + } + + const validation = validatePassword(newPassword) + if (!validation.valid) { + return { error: validation.errors.join(', ') } + } + + const newHash = await bcrypt.hash(newPassword, PASSWORD_SALT_ROUNDS) + return { + success: true, + newPasswordHash: newHash, + } + } + + function validatePassword(password) { + const errors = [] + if (!password || password.length < 12) errors.push('Minimum 12 characters') + if (!/[A-Z]/.test(password)) errors.push('Uppercase letter required') + if (!/[a-z]/.test(password)) errors.push('Lowercase letter required') + if (!/[0-9]/.test(password)) errors.push('Number required') + if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) errors.push('Special character required') + return { valid: errors.length === 0, errors } + } + + test('changes password with valid current password', async () => { + const passwordHash = await bcrypt.hash('OldPassword123!', PASSWORD_SALT_ROUNDS) + const user = { passwordHash } + + const result = await changePassword(user, 'OldPassword123!', 'NewPassword456!') + + expect(result.success).toBe(true) + expect(await bcrypt.compare('NewPassword456!', result.newPasswordHash)).toBe(true) + }) + + test('rejects wrong current password', async () => { + const passwordHash = await bcrypt.hash('CorrectPassword123!', PASSWORD_SALT_ROUNDS) + const user = { passwordHash } + + const result = await changePassword(user, 'WrongPassword123!', 'NewPassword456!') + + expect(result.error).toBe('Current password is incorrect') + }) + + test('validates new password strength', async () => { + const passwordHash = await bcrypt.hash('OldPassword123!', PASSWORD_SALT_ROUNDS) + const user = { passwordHash } + + const result = await changePassword(user, 'OldPassword123!', 'weak') + + expect(result.error).toBeDefined() + }) + }) + + describe('Two-Factor Authentication', () => { + function generateSecret() { + return crypto.randomBytes(20).toString('base64') + } + + function generateBackupCodes(count = 10) { + const codes = [] + for (let i = 0; i < count; i++) { + codes.push(crypto.randomBytes(4).toString('hex').toUpperCase()) + } + return codes + } + + function verifyTotp(secret, code) { + return code.length === 6 && /^\d+$/.test(code) + } + + function enable2FA(user, secret, backupCodes) { + return { + ...user, + twoFactorSecret: secret, + twoFactorBackupCodes: backupCodes, + twoFactorEnabled: true, + twoFactorEnabledAt: new Date().toISOString(), + } + } + + function disable2FA(user) { + const updated = { ...user } + delete updated.twoFactorSecret + delete updated.twoFactorBackupCodes + updated.twoFactorEnabled = false + return updated + } + + function useBackupCode(user, code) { + if (!user.twoFactorBackupCodes?.includes(code)) { + return { success: false, error: 'Invalid backup code' } + } + const updated = { ...user } + updated.twoFactorBackupCodes = updated.twoFactorBackupCodes.filter(c => c !== code) + return { success: true, remainingCodes: updated.twoFactorBackupCodes.length } + } + + test('generates 2FA secret', () => { + const secret = generateSecret() + expect(secret).toBeDefined() + expect(secret.length).toBeGreaterThan(0) + }) + + test('generates backup codes', () => { + const codes = generateBackupCodes(10) + expect(codes.length).toBe(10) + codes.forEach(code => { + expect(code.length).toBe(8) + expect(/^[A-F0-9]+$/.test(code)).toBe(true) + }) + }) + + test('enables 2FA', () => { + const user = { id: 'user-1' } + const secret = generateSecret() + const backupCodes = generateBackupCodes() + + const updated = enable2FA(user, secret, backupCodes) + + expect(updated.twoFactorEnabled).toBe(true) + expect(updated.twoFactorSecret).toBe(secret) + expect(updated.twoFactorBackupCodes.length).toBe(10) + }) + + test('disables 2FA', () => { + const user = { + id: 'user-1', + twoFactorEnabled: true, + twoFactorSecret: 'secret', + twoFactorBackupCodes: ['CODE1', 'CODE2'], + } + + const updated = disable2FA(user) + + expect(updated.twoFactorEnabled).toBe(false) + expect(updated.twoFactorSecret).toBeUndefined() + expect(updated.twoFactorBackupCodes).toBeUndefined() + }) + + test('verifies TOTP code format', () => { + expect(verifyTotp('secret', '123456')).toBe(true) + expect(verifyTotp('secret', '12345')).toBe(false) + expect(verifyTotp('secret', 'abcdef')).toBe(false) + }) + + test('uses backup code', () => { + const user = { + twoFactorBackupCodes: ['CODE1234', 'CODE5678'], + } + + const result = useBackupCode(user, 'CODE1234') + expect(result.success).toBe(true) + expect(result.remainingCodes).toBe(1) + }) + + test('rejects invalid backup code', () => { + const user = { + twoFactorBackupCodes: ['CODE1234'], + } + + const result = useBackupCode(user, 'INVALID') + expect(result.success).toBe(false) + }) + }) + + describe('Session Management', () => { + const sessions = new Map() + + function createSession(userId, deviceInfo) { + const token = crypto.randomBytes(32).toString('hex') + const session = { + id: crypto.randomUUID(), + userId, + token, + device: deviceInfo.device || 'Unknown', + ip: deviceInfo.ip || '0.0.0.0', + userAgent: deviceInfo.userAgent || '', + createdAt: Date.now(), + lastAccessedAt: Date.now(), + expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, + } + sessions.set(token, session) + return session + } + + function getSessions(userId) { + return Array.from(sessions.values()) + .filter(s => s.userId === userId) + .map(s => ({ + id: s.id, + device: s.device, + ip: s.ip, + lastAccessedAt: s.lastAccessedAt, + createdAt: s.createdAt, + })) + } + + function revokeSession(token) { + return sessions.delete(token) + } + + function revokeAllSessions(userId, exceptToken) { + let revoked = 0 + for (const [token, session] of sessions.entries()) { + if (session.userId === userId && token !== exceptToken) { + sessions.delete(token) + revoked++ + } + } + return revoked + } + + beforeEach(() => { + sessions.clear() + }) + + test('creates session', () => { + const session = createSession('user-1', { device: 'Chrome on Windows', ip: '192.168.1.1' }) + + expect(session.id).toBeDefined() + expect(session.token).toBeDefined() + expect(session.device).toBe('Chrome on Windows') + }) + + test('lists user sessions', () => { + createSession('user-1', { device: 'Device 1' }) + createSession('user-1', { device: 'Device 2' }) + createSession('user-2', { device: 'Device 3' }) + + const userSessions = getSessions('user-1') + expect(userSessions.length).toBe(2) + }) + + test('excludes sensitive data from session list', () => { + createSession('user-1', { device: 'Test' }) + const userSessions = getSessions('user-1') + + expect(userSessions[0].token).toBeUndefined() + }) + + test('revokes session', () => { + const session = createSession('user-1', {}) + const result = revokeSession(session.token) + + expect(result).toBe(true) + expect(sessions.size).toBe(0) + }) + + test('revokes all sessions except current', () => { + const s1 = createSession('user-1', {}) + createSession('user-1', {}) + createSession('user-1', {}) + + const revoked = revokeAllSessions('user-1', s1.token) + expect(revoked).toBe(2) + expect(sessions.size).toBe(1) + }) + }) + + describe('Account Deletion', () => { + function validateDeletionRequest(user, password) { + if (!user) return { error: 'User not found' } + if (!user.passwordHash) return { error: 'Cannot delete account' } + return { valid: true } + } + + async function confirmDeletion(user, password) { + const validPassword = await bcrypt.compare(password, user.passwordHash) + if (!validPassword) return { error: 'Password is incorrect' } + return { success: true, deletedAt: new Date().toISOString() } + } + + function scheduleDeletion(userId, delayDays = 30) { + return { + scheduledDeletion: true, + deletionDate: new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000).toISOString(), + gracePeriodDays: delayDays, + } + } + + test('validates deletion request', () => { + const user = { id: 'user-1', passwordHash: 'hash' } + const result = validateDeletionRequest(user, 'password') + + expect(result.valid).toBe(true) + }) + + test('confirms deletion with correct password', async () => { + const passwordHash = await bcrypt.hash('Password123!', PASSWORD_SALT_ROUNDS) + const user = { passwordHash } + + const result = await confirmDeletion(user, 'Password123!') + expect(result.success).toBe(true) + }) + + test('rejects deletion with wrong password', async () => { + const passwordHash = await bcrypt.hash('CorrectPassword!', PASSWORD_SALT_ROUNDS) + const user = { passwordHash } + + const result = await confirmDeletion(user, 'WrongPassword!') + expect(result.error).toBe('Password is incorrect') + }) + + test('schedules deletion with grace period', () => { + const result = scheduleDeletion('user-1', 30) + + expect(result.scheduledDeletion).toBe(true) + expect(result.gracePeriodDays).toBe(30) + }) + }) + + describe('Account Security Logging', () => { + const securityLog = [] + + function logSecurityEvent(userId, event, details) { + securityLog.push({ + id: crypto.randomUUID(), + userId, + event, + details, + ip: details.ip || '0.0.0.0', + userAgent: details.userAgent || '', + timestamp: new Date().toISOString(), + }) + } + + function getSecurityEvents(userId, limit = 50) { + return securityLog + .filter(e => e.userId === userId) + .slice(-limit) + } + + beforeEach(() => { + securityLog.length = 0 + }) + + test('logs security event', () => { + logSecurityEvent('user-1', 'login', { ip: '192.168.1.1', success: true }) + + expect(securityLog.length).toBe(1) + expect(securityLog[0].event).toBe('login') + }) + + test('logs multiple event types', () => { + logSecurityEvent('user-1', 'login', {}) + logSecurityEvent('user-1', 'password_change', {}) + logSecurityEvent('user-1', '2fa_enabled', {}) + + const events = getSecurityEvents('user-1') + expect(events.length).toBe(3) + }) + + test('filters by user', () => { + logSecurityEvent('user-1', 'login', {}) + logSecurityEvent('user-2', 'login', {}) + + const events = getSecurityEvents('user-1') + expect(events.length).toBe(1) + }) + + test('limits returned events', () => { + for (let i = 0; i < 100; i++) { + logSecurityEvent('user-1', 'login', {}) + } + + const events = getSecurityEvents('user-1', 10) + expect(events.length).toBe(10) + }) + }) +}) diff --git a/chat/src/tests/affiliate-system.test.js b/chat/src/tests/affiliate-system.test.js new file mode 100644 index 0000000..4776002 --- /dev/null +++ b/chat/src/tests/affiliate-system.test.js @@ -0,0 +1,427 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') + +const AFFILIATE_COMMISSION_RATE = 0.075 +const AFFILIATE_COOKIE_TTL_MS = 30 * 24 * 60 * 60 * 1000 + +describe('Affiliate System', () => { + describe('Affiliate Code Generation', () => { + function generateTrackingCode() { + return crypto.randomBytes(6).toString('hex').toUpperCase() + } + + function sanitizeAffiliateCode(code) { + return String(code || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9\-]/g, '') + .slice(0, 32) + } + + function validateAffiliateCode(code) { + if (!code || typeof code !== 'string') return { valid: false, error: 'Code is required' } + const sanitized = sanitizeAffiliateCode(code) + if (sanitized.length < 3) return { valid: false, error: 'Code must be at least 3 characters' } + if (sanitized.length > 32) return { valid: false, error: 'Code must be 32 characters or less' } + return { valid: true, sanitized } + } + + test('generates tracking code', () => { + const code = generateTrackingCode() + expect(code.length).toBe(12) + expect(/^[A-F0-9]+$/.test(code)).toBe(true) + }) + + test('generates unique codes', () => { + const codes = new Set() + for (let i = 0; i < 100; i++) { + codes.add(generateTrackingCode()) + } + expect(codes.size).toBe(100) + }) + + test('sanitizes affiliate codes', () => { + expect(sanitizeAffiliateCode('TESTCODE')).toBe('testcode') + expect(sanitizeAffiliateCode('Test-Code-123')).toBe('test-code-123') + expect(sanitizeAffiliateCode('Test@Code!#$%')).toBe('testcode') + }) + + test('truncates long codes', () => { + const longCode = 'a'.repeat(50) + expect(sanitizeAffiliateCode(longCode).length).toBe(32) + }) + + test('validates affiliate code', () => { + expect(validateAffiliateCode('valid-code').valid).toBe(true) + expect(validateAffiliateCode('ab').valid).toBe(false) + expect(validateAffiliateCode('').valid).toBe(false) + }) + }) + + describe('Affiliate Registration', () => { + const affiliatesDb = [] + + function createAffiliate(data) { + const code = data.code || crypto.randomBytes(6).toString('hex').toUpperCase() + return { + id: crypto.randomUUID(), + userId: data.userId, + codes: [{ + code: code.toLowerCase(), + label: data.label || 'Default link', + createdAt: new Date().toISOString(), + }], + email: data.email, + name: data.name, + commissionRate: data.commissionRate || AFFILIATE_COMMISSION_RATE, + earnings: [], + totalEarnings: 0, + paidOut: 0, + pendingPayout: 0, + status: 'active', + createdAt: new Date().toISOString(), + } + } + + function findByCode(code) { + const normalizedCode = code.toLowerCase() + return affiliatesDb.find(a => + a.codes.some(c => c.code === normalizedCode) + ) + } + + beforeEach(() => { + affiliatesDb.length = 0 + }) + + test('creates affiliate with default code', () => { + const affiliate = createAffiliate({ userId: 'user-1', email: 'test@example.com' }) + + expect(affiliate.codes.length).toBe(1) + expect(affiliate.commissionRate).toBe(0.075) + expect(affiliate.status).toBe('active') + }) + + test('creates affiliate with custom code', () => { + const affiliate = createAffiliate({ userId: 'user-1', code: 'CUSTOM123' }) + + expect(affiliate.codes[0].code).toBe('custom123') + }) + + test('finds affiliate by code', () => { + affiliatesDb.push(createAffiliate({ userId: 'user-1', code: 'FINDME' })) + + const found = findByCode('findme') + expect(found).toBeDefined() + expect(found.codes[0].code).toBe('findme') + }) + + test('returns undefined for non-existent code', () => { + const found = findByCode('notexist') + expect(found).toBeUndefined() + }) + }) + + describe('Attribution Tracking', () => { + const attributionStore = new Map() + + function recordAttribution(userId, affiliateCode, eventData) { + const key = `attr_${userId}` + attributionStore.set(key, { + userId, + affiliateCode, + attributedAt: Date.now(), + events: [eventData], + }) + } + + function getAttribution(userId) { + return attributionStore.get(`attr_${userId}`) + } + + function isAttributionValid(attribution) { + if (!attribution) return false + const age = Date.now() - attribution.attributedAt + return age < AFFILIATE_COOKIE_TTL_MS + } + + beforeEach(() => { + attributionStore.clear() + }) + + test('records attribution', () => { + recordAttribution('user-1', 'affiliate123', { type: 'signup' }) + + const attr = getAttribution('user-1') + expect(attr).toBeDefined() + expect(attr.affiliateCode).toBe('affiliate123') + }) + + test('checks attribution validity', () => { + recordAttribution('user-1', 'test', { type: 'signup' }) + const attr = getAttribution('user-1') + + expect(isAttributionValid(attr)).toBe(true) + }) + + test('detects expired attribution', () => { + attributionStore.set('attr_user-2', { + userId: 'user-2', + affiliateCode: 'old', + attributedAt: Date.now() - AFFILIATE_COOKIE_TTL_MS - 1000, + events: [], + }) + + const attr = getAttribution('user-2') + expect(isAttributionValid(attr)).toBe(false) + }) + + test('affiliated cookie TTL is 30 days', () => { + expect(AFFILIATE_COOKIE_TTL_MS).toBe(30 * 24 * 60 * 60 * 1000) + }) + }) + + describe('Commission Calculation', () => { + function calculateCommission(amount, rate = AFFILIATE_COMMISSION_RATE) { + return Math.round(amount * rate * 100) / 100 + } + + function calculateTieredCommission(amount, tier) { + const rates = { + bronze: 0.05, + silver: 0.075, + gold: 0.10, + platinum: 0.15, + } + return calculateCommission(amount, rates[tier] || rates.bronze) + } + + test('calculates standard commission', () => { + expect(calculateCommission(100)).toBe(7.5) + expect(calculateCommission(50)).toBe(3.75) + expect(calculateCommission(1000)).toBe(75) + }) + + test('calculates tiered commissions', () => { + expect(calculateTieredCommission(100, 'bronze')).toBe(5) + expect(calculateTieredCommission(100, 'silver')).toBe(7.5) + expect(calculateTieredCommission(100, 'gold')).toBe(10) + expect(calculateTieredCommission(100, 'platinum')).toBe(15) + }) + + test('defaults to bronze for unknown tier', () => { + expect(calculateTieredCommission(100, 'unknown')).toBe(5) + }) + + test('handles zero amount', () => { + expect(calculateCommission(0)).toBe(0) + }) + }) + + describe('Earnings Tracking', () => { + function addEarning(affiliate, amount, source) { + const earning = { + id: crypto.randomUUID(), + amount, + source, + status: 'pending', + createdAt: new Date().toISOString(), + } + + affiliate.earnings.push(earning) + affiliate.totalEarnings = (affiliate.totalEarnings || 0) + amount + affiliate.pendingPayout = (affiliate.pendingPayout || 0) + amount + + return earning + } + + function markAsPaid(affiliate, earningIds) { + let paidAmount = 0 + for (const id of earningIds) { + const earning = affiliate.earnings.find(e => e.id === id) + if (earning && earning.status === 'pending') { + earning.status = 'paid' + earning.paidAt = new Date().toISOString() + paidAmount += earning.amount + } + } + affiliate.pendingPayout = Math.max(0, (affiliate.pendingPayout || 0) - paidAmount) + affiliate.paidOut = (affiliate.paidOut || 0) + paidAmount + return paidAmount + } + + test('adds earning to affiliate', () => { + const affiliate = { earnings: [], totalEarnings: 0, pendingPayout: 0, paidOut: 0 } + addEarning(affiliate, 10, 'subscription') + + expect(affiliate.earnings.length).toBe(1) + expect(affiliate.totalEarnings).toBe(10) + expect(affiliate.pendingPayout).toBe(10) + }) + + test('marks earnings as paid', () => { + const affiliate = { earnings: [], totalEarnings: 0, pendingPayout: 0, paidOut: 0 } + const e1 = addEarning(affiliate, 10, 'sub1') + const e2 = addEarning(affiliate, 20, 'sub2') + + const paid = markAsPaid(affiliate, [e1.id]) + + expect(paid).toBe(10) + expect(affiliate.paidOut).toBe(10) + expect(affiliate.pendingPayout).toBe(20) + }) + + test('does not double pay earnings', () => { + const affiliate = { earnings: [], totalEarnings: 0, pendingPayout: 0, paidOut: 0 } + const earning = addEarning(affiliate, 10, 'sub') + + markAsPaid(affiliate, [earning.id]) + const paidAgain = markAsPaid(affiliate, [earning.id]) + + expect(paidAgain).toBe(0) + expect(affiliate.paidOut).toBe(10) + }) + }) + + describe('Withdrawal Processing', () => { + const withdrawalsDb = [] + const MIN_WITHDRAWAL_AMOUNT = 10 + + function createWithdrawal(affiliateId, amount, paymentMethod) { + if (amount < MIN_WITHDRAWAL_AMOUNT) { + return { error: `Minimum withdrawal is $${MIN_WITHDRAWAL_AMOUNT}` } + } + + return { + id: crypto.randomUUID(), + affiliateId, + amount, + paymentMethod, + status: 'pending', + createdAt: new Date().toISOString(), + processedAt: null, + } + } + + function processWithdrawal(withdrawal, status) { + withdrawal.status = status + withdrawal.processedAt = new Date().toISOString() + return withdrawal + } + + beforeEach(() => { + withdrawalsDb.length = 0 + }) + + test('creates withdrawal request', () => { + const withdrawal = createWithdrawal('aff-1', 50, 'paypal') + + expect(withdrawal.id).toBeDefined() + expect(withdrawal.status).toBe('pending') + expect(withdrawal.amount).toBe(50) + }) + + test('rejects withdrawal below minimum', () => { + const result = createWithdrawal('aff-1', 5, 'paypal') + + expect(result.error).toBeDefined() + }) + + test('processes withdrawal', () => { + const withdrawal = createWithdrawal('aff-1', 50, 'paypal') + processWithdrawal(withdrawal, 'completed') + + expect(withdrawal.status).toBe('completed') + expect(withdrawal.processedAt).toBeDefined() + }) + }) + + describe('Affiliate Links', () => { + function generateAffiliateLink(baseUrl, code, params = {}) { + const url = new URL(baseUrl) + url.searchParams.set('ref', code) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + return url.toString() + } + + function parseAffiliateLink(urlString) { + try { + const url = new URL(urlString) + const ref = url.searchParams.get('ref') + return ref ? { code: ref.toLowerCase() } : null + } catch { + return null + } + } + + test('generates affiliate link', () => { + const link = generateAffiliateLink('https://example.com/pricing', 'MYCODE') + + expect(link).toContain('ref=MYCODE') + expect(link).toContain('example.com/pricing') + }) + + test('includes additional parameters', () => { + const link = generateAffiliateLink('https://example.com', 'CODE', { utm_source: 'email' }) + + expect(link).toContain('utm_source=email') + }) + + test('parses affiliate link', () => { + const parsed = parseAffiliateLink('https://example.com?ref=TESTCODE') + + expect(parsed.code).toBe('testcode') + }) + + test('returns null for link without ref', () => { + const parsed = parseAffiliateLink('https://example.com?page=home') + + expect(parsed).toBe(null) + }) + }) + + describe('Affiliate Dashboard Stats', () => { + function calculateStats(affiliate) { + const totalClicks = affiliate.clicks || 0 + const totalSignups = affiliate.signups || 0 + const totalConversions = affiliate.conversions || 0 + const conversionRate = totalClicks > 0 ? (totalConversions / totalClicks) * 100 : 0 + + return { + clicks: totalClicks, + signups: totalSignups, + conversions: totalConversions, + conversionRate: Math.round(conversionRate * 100) / 100, + totalEarnings: affiliate.totalEarnings || 0, + pendingPayout: affiliate.pendingPayout || 0, + paidOut: affiliate.paidOut || 0, + } + } + + test('calculates affiliate stats', () => { + const affiliate = { + clicks: 1000, + signups: 50, + conversions: 25, + totalEarnings: 100, + pendingPayout: 50, + paidOut: 50, + } + + const stats = calculateStats(affiliate) + + expect(stats.clicks).toBe(1000) + expect(stats.conversions).toBe(25) + expect(stats.conversionRate).toBe(2.5) + expect(stats.totalEarnings).toBe(100) + }) + + test('handles zero clicks', () => { + const stats = calculateStats({ clicks: 0, conversions: 0 }) + + expect(stats.conversionRate).toBe(0) + }) + }) +}) diff --git a/chat/src/tests/blog-system.test.js b/chat/src/tests/blog-system.test.js new file mode 100644 index 0000000..52b4626 --- /dev/null +++ b/chat/src/tests/blog-system.test.js @@ -0,0 +1,444 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') +const path = require('path') + +describe('Blog System', () => { + describe('Post Management', () => { + const blogPosts = [] + + function createPost(data) { + const now = new Date().toISOString() + return { + id: crypto.randomUUID(), + title: data.title || 'Untitled', + slug: data.slug || data.title?.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 100) || crypto.randomBytes(4).toString('hex'), + content: data.content || '', + excerpt: data.excerpt || data.content?.slice(0, 200) || '', + author: data.author || 'Admin', + category: data.category || 'general', + tags: data.tags || [], + status: data.status || 'draft', + publishedAt: data.status === 'published' ? now : null, + createdAt: now, + updatedAt: now, + featuredImage: data.featuredImage || null, + metaTitle: data.metaTitle || data.title?.slice(0, 60) || '', + metaDescription: data.metaDescription || data.excerpt?.slice(0, 160) || '', + } + } + + function validatePost(post) { + const errors = [] + if (!post.title || post.title.trim().length === 0) errors.push('Title is required') + if (!post.content || post.content.trim().length === 0) errors.push('Content is required') + if (post.title && post.title.length > 200) errors.push('Title must be 200 characters or less') + if (post.metaTitle && post.metaTitle.length > 60) errors.push('Meta title must be 60 characters or less') + if (post.metaDescription && post.metaDescription.length > 160) errors.push('Meta description must be 160 characters or less') + return { valid: errors.length === 0, errors } + } + + beforeEach(() => { + blogPosts.length = 0 + }) + + test('creates a blog post with defaults', () => { + const post = createPost({ title: 'Test Post' }) + + expect(post.id).toBeDefined() + expect(post.title).toBe('Test Post') + expect(post.status).toBe('draft') + expect(post.category).toBe('general') + }) + + test('creates a published post', () => { + const post = createPost({ title: 'Published Post', status: 'published' }) + + expect(post.status).toBe('published') + expect(post.publishedAt).toBeDefined() + }) + + test('generates slug from title', () => { + const post = createPost({ title: 'Hello World Test!' }) + + expect(post.slug).toBe('hello-world-test-') + }) + + test('validates required fields', () => { + const post = createPost({}) + const result = validatePost(post) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Content is required') + }) + + test('validates title length', () => { + const post = createPost({ title: 'a'.repeat(201), content: 'test' }) + const result = validatePost(post) + + expect(result.errors).toContain('Title must be 200 characters or less') + }) + + test('validates meta title length', () => { + const post = createPost({ + title: 'Test', + content: 'test', + metaTitle: 'a'.repeat(61), + }) + const result = validatePost(post) + + expect(result.errors).toContain('Meta title must be 60 characters or less') + }) + + test('generates excerpt from content', () => { + const post = createPost({ + title: 'Test', + content: 'This is a long content that should be truncated for the excerpt.', + }) + + expect(post.excerpt.length).toBeLessThanOrEqual(200) + }) + }) + + describe('Category Management', () => { + const defaultCategories = [ + { id: 'general', name: 'General', slug: 'general' }, + { id: 'updates', name: 'Updates', slug: 'updates' }, + { id: 'tutorials', name: 'Tutorials', slug: 'tutorials' }, + { id: 'wordpress', name: 'WordPress', slug: 'wordpress' }, + ] + + function createCategory(data) { + return { + id: data.id || crypto.randomUUID(), + name: data.name || 'New Category', + slug: data.slug || data.name?.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'new-category', + description: data.description || '', + createdAt: new Date().toISOString(), + } + } + + function validateCategory(category) { + const errors = [] + if (!category.name || category.name.trim().length === 0) errors.push('Name is required') + if (category.name && category.name.length > 50) errors.push('Name must be 50 characters or less') + return { valid: errors.length === 0, errors } + } + + test('has default categories', () => { + expect(defaultCategories.length).toBe(4) + expect(defaultCategories.find(c => c.id === 'general')).toBeDefined() + expect(defaultCategories.find(c => c.id === 'tutorials')).toBeDefined() + }) + + test('creates new category', () => { + const category = createCategory({ name: 'New Topic' }) + + expect(category.name).toBe('New Topic') + expect(category.slug).toBe('new-topic') + }) + + test('validates category name', () => { + const category = createCategory({ name: '' }) + const result = validateCategory(category) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Name is required') + }) + + test('validates category name length', () => { + const category = createCategory({ name: 'a'.repeat(51) }) + const result = validateCategory(category) + + expect(result.errors).toContain('Name must be 50 characters or less') + }) + }) + + describe('Blog Caching', () => { + let cache = { + posts: null, + categories: null, + lastUpdate: 0, + ttl: 60000, + } + + function getCacheAge() { + return Date.now() - cache.lastUpdate + } + + function isCacheValid() { + return cache.posts !== null && getCacheAge() < cache.ttl + } + + function invalidateCache() { + cache.posts = null + cache.categories = null + cache.lastUpdate = 0 + } + + function updateCache(posts, categories) { + cache.posts = posts + cache.categories = categories + cache.lastUpdate = Date.now() + } + + beforeEach(() => { + invalidateCache() + }) + + test('starts with invalid cache', () => { + expect(isCacheValid()).toBe(false) + }) + + test('cache becomes valid after update', () => { + updateCache([{ id: '1' }], [{ id: 'general' }]) + + expect(isCacheValid()).toBe(true) + expect(cache.posts.length).toBe(1) + }) + + test('cache expires after TTL', () => { + cache.posts = [] + cache.lastUpdate = Date.now() - 70000 + cache.categories = [] + + expect(isCacheValid()).toBe(false) + }) + + test('invalidation clears cache', () => { + updateCache([{ id: '1' }], []) + invalidateCache() + + expect(cache.posts).toBe(null) + expect(isCacheValid()).toBe(false) + }) + + test('tracks cache age', () => { + cache.lastUpdate = Date.now() - 30000 + + expect(getCacheAge()).toBeGreaterThanOrEqual(30000) + }) + }) + + describe('Blog Search and Filtering', () => { + const posts = [ + { id: '1', title: 'WordPress Tutorial', category: 'tutorials', status: 'published', tags: ['wordpress', 'beginner'] }, + { id: '2', title: 'Platform Updates', category: 'updates', status: 'published', tags: ['news'] }, + { id: '3', title: 'Advanced WordPress Tips', category: 'tutorials', status: 'published', tags: ['wordpress', 'advanced'] }, + { id: '4', title: 'Draft Post', category: 'general', status: 'draft', tags: [] }, + ] + + function filterPosts(filters) { + let result = [...posts] + + if (filters.status) { + result = result.filter(p => p.status === filters.status) + } + if (filters.category) { + result = result.filter(p => p.category === filters.category) + } + if (filters.tag) { + result = result.filter(p => p.tags.includes(filters.tag)) + } + if (filters.search) { + const search = filters.search.toLowerCase() + result = result.filter(p => p.title.toLowerCase().includes(search)) + } + + return result + } + + test('filters by status', () => { + const published = filterPosts({ status: 'published' }) + const drafts = filterPosts({ status: 'draft' }) + + expect(published.length).toBe(3) + expect(drafts.length).toBe(1) + }) + + test('filters by category', () => { + const tutorials = filterPosts({ category: 'tutorials' }) + + expect(tutorials.length).toBe(2) + expect(tutorials.every(p => p.category === 'tutorials')).toBe(true) + }) + + test('filters by tag', () => { + const wordpress = filterPosts({ tag: 'wordpress' }) + + expect(wordpress.length).toBe(2) + }) + + test('searches by title', () => { + const results = filterPosts({ search: 'wordpress' }) + + expect(results.length).toBe(2) + }) + + test('combines filters', () => { + const results = filterPosts({ status: 'published', category: 'tutorials' }) + + expect(results.length).toBe(2) + }) + }) + + describe('Slug Generation', () => { + function generateSlug(title) { + return String(title || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 100) + } + + function ensureUniqueSlug(slug, existingSlugs) { + if (!existingSlugs.includes(slug)) return slug + + let counter = 1 + let newSlug = `${slug}-${counter}` + while (existingSlugs.includes(newSlug)) { + counter++ + newSlug = `${slug}-${counter}` + } + return newSlug + } + + test('generates slug from title', () => { + expect(generateSlug('Hello World')).toBe('hello-world') + expect(generateSlug('Test Post!')).toBe('test-post') + expect(generateSlug('Multiple Spaces')).toBe('multiple-spaces') + }) + + test('handles special characters', () => { + expect(generateSlug('Test @#$%^& Post')).toBe('test-post') + expect(generateSlug('--Test--')).toBe('test') + }) + + test('truncates long slugs', () => { + const longTitle = 'a'.repeat(200) + const slug = generateSlug(longTitle) + + expect(slug.length).toBe(100) + }) + + test('ensures unique slug', () => { + const existing = ['test-post', 'test-post-1'] + + expect(ensureUniqueSlug('test-post', existing)).toBe('test-post-2') + expect(ensureUniqueSlug('unique-slug', existing)).toBe('unique-slug') + }) + }) + + describe('RSS Feed Generation', () => { + function generateRssItem(post, baseUrl) { + return { + title: post.title, + link: `${baseUrl}/blog/${post.slug}`, + description: post.excerpt || post.content?.slice(0, 200) || '', + pubDate: new Date(post.publishedAt).toUTCString(), + guid: `${baseUrl}/blog/${post.slug}`, + } + } + + function generateRssFeed(posts, options) { + const { title, description, baseUrl } = options + + return { + version: '2.0', + channel: { + title, + description, + link: `${baseUrl}/blog`, + lastBuildDate: new Date().toUTCString(), + items: posts.map(p => generateRssItem(p, baseUrl)), + }, + } + } + + test('generates RSS item from post', () => { + const post = { + title: 'Test Post', + slug: 'test-post', + excerpt: 'Test excerpt', + publishedAt: '2024-01-01T00:00:00Z', + } + const item = generateRssItem(post, 'https://example.com') + + expect(item.title).toBe('Test Post') + expect(item.link).toBe('https://example.com/blog/test-post') + expect(item.description).toBe('Test excerpt') + }) + + test('generates RSS feed', () => { + const posts = [ + { title: 'Post 1', slug: 'post-1', excerpt: 'Excerpt 1', publishedAt: new Date().toISOString() }, + { title: 'Post 2', slug: 'post-2', excerpt: 'Excerpt 2', publishedAt: new Date().toISOString() }, + ] + const feed = generateRssFeed(posts, { + title: 'My Blog', + description: 'Blog description', + baseUrl: 'https://example.com', + }) + + expect(feed.version).toBe('2.0') + expect(feed.channel.title).toBe('My Blog') + expect(feed.channel.items.length).toBe(2) + }) + }) + + describe('Image Handling', () => { + function validateBlogImage(image) { + const errors = [] + const maxSize = 5 * 1024 * 1024 + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] + + if (!image) { + errors.push('Image is required') + } else { + if (image.size > maxSize) errors.push('Image must be 5MB or less') + if (!allowedTypes.includes(image.type)) errors.push('Invalid image type') + } + + return { valid: errors.length === 0, errors } + } + + function generateImageFilename(originalName, postId) { + const ext = path.extname(originalName || 'image.jpg').toLowerCase() + const timestamp = Date.now() + return `blog-${postId}-${timestamp}${ext}` + } + + test('validates image size', () => { + const image = { size: 6 * 1024 * 1024, type: 'image/jpeg' } + const result = validateBlogImage(image) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Image must be 5MB or less') + }) + + test('validates image type', () => { + const image = { size: 1000, type: 'image/bmp' } + const result = validateBlogImage(image) + + expect(result.errors).toContain('Invalid image type') + }) + + test('accepts valid image', () => { + const image = { size: 1000, type: 'image/jpeg' } + const result = validateBlogImage(image) + + expect(result.valid).toBe(true) + }) + + test('generates image filename', () => { + const filename = generateImageFilename('photo.jpg', 'post-123') + + expect(filename).toMatch(/^blog-post-123-\d+\.jpg$/) + }) + + test('extracts extension from original name', () => { + const filename = generateImageFilename('image.PNG', 'post-1') + + expect(filename.endsWith('.png')).toBe(true) + }) + }) +}) diff --git a/chat/src/tests/dodo-payments.test.js b/chat/src/tests/dodo-payments.test.js new file mode 100644 index 0000000..bebe294 --- /dev/null +++ b/chat/src/tests/dodo-payments.test.js @@ -0,0 +1,590 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') + +const DODO_BASE_URL_TEST = 'https://test.dodopayments.com' +const DODO_BASE_URL_LIVE = 'https://live.dodopayments.com' + +const SUBSCRIPTION_PRICES = { + starter_monthly_usd: 750, + starter_yearly_usd: 7500, + professional_monthly_usd: 2500, + professional_yearly_usd: 25000, + enterprise_monthly_usd: 7500, + enterprise_yearly_usd: 75000, +} + +const TOPUP_PRICES = { + topup_1_usd: 750, + topup_2_usd: 2500, + topup_3_usd: 7500, + topup_4_usd: 12500, +} + +const TOPUP_TOKENS = { + topup_1: 100_000, + topup_2: 5_000_000, + topup_3: 20_000_000, + topup_4: 50_000_000, +} + +const BUSINESS_TOPUP_DISCOUNT = 0.025 +const ENTERPRISE_TOPUP_DISCOUNT = 0.05 +const MIN_PAYMENT_AMOUNT = 50 + +function getProductKey(plan, cycle, currency) { + return `${plan}_${cycle}_${currency}` +} + +function getTopupProductKey(option, currency) { + return `topup_${option}_${currency}` +} + +function formatPrice(cents, currency) { + const symbols = { usd: '$', gbp: '£', eur: '€' } + return `${symbols[currency] || '$'}${(cents / 100).toFixed(2)}` +} + +function applyDiscount(price, discountRate) { + return Math.round(price * (1 - discountRate)) +} + +function calculateProratedRefund(subscriptionPrice, daysRemaining, daysInPeriod) { + return Math.round((subscriptionPrice * daysRemaining) / daysInPeriod) +} + +describe('Dodo Payments Integration', () => { + describe('Environment Configuration', () => { + test('selects correct base URL for test environment', () => { + const DODO_ENVIRONMENT = 'test' + const baseUrl = DODO_ENVIRONMENT.includes('live') ? DODO_BASE_URL_LIVE : DODO_BASE_URL_TEST + expect(baseUrl).toBe(DODO_BASE_URL_TEST) + }) + + test('selects correct base URL for live environment', () => { + const DODO_ENVIRONMENT = 'live' + const baseUrl = DODO_ENVIRONMENT.includes('live') ? DODO_BASE_URL_LIVE : DODO_BASE_URL_TEST + expect(baseUrl).toBe(DODO_BASE_URL_LIVE) + }) + + test('defaults to test environment', () => { + const DODO_ENVIRONMENT = '' + const baseUrl = DODO_ENVIRONMENT.includes('live') ? DODO_BASE_URL_LIVE : DODO_BASE_URL_TEST + expect(baseUrl).toBe(DODO_BASE_URL_TEST) + }) + }) + + describe('Checkout Flow', () => { + function createCheckoutSession(options) { + const { + productId, + customerId, + successUrl, + cancelUrl, + metadata = {}, + } = options + + return { + id: `cs_${crypto.randomBytes(16).toString('hex')}`, + productId, + customerId, + successUrl, + cancelUrl, + metadata, + status: 'open', + createdAt: Date.now(), + } + } + + test('creates checkout session with required fields', () => { + const session = createCheckoutSession({ + productId: 'prod_starter_monthly_usd', + customerId: 'user-123', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + }) + + expect(session.id).toMatch(/^cs_[a-f0-9]{32}$/) + expect(session.productId).toBe('prod_starter_monthly_usd') + expect(session.status).toBe('open') + }) + + test('includes metadata in checkout session', () => { + const session = createCheckoutSession({ + productId: 'prod_topup_1_usd', + customerId: 'user-456', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + metadata: { + tokens: TOPUP_TOKENS.topup_1, + userId: 'user-456', + }, + }) + + expect(session.metadata.tokens).toBe(100_000) + expect(session.metadata.userId).toBe('user-456') + }) + + test('validates minimum payment amount', () => { + expect(MIN_PAYMENT_AMOUNT).toBe(50) + expect(TOPUP_PRICES.topup_1_usd).toBeGreaterThanOrEqual(MIN_PAYMENT_AMOUNT) + }) + + test('generates unique checkout session IDs', () => { + const ids = new Set() + for (let i = 0; i < 100; i++) { + const session = createCheckoutSession({ + productId: 'prod_test', + customerId: 'user-1', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + }) + ids.add(session.id) + } + expect(ids.size).toBe(100) + }) + }) + + describe('Webhook Processing', () => { + function verifyWebhookSignature(payload, signature, secret) { + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex') + return signature === `sha256=${expectedSignature}` + } + + const testSecret = 'dodo_webhook_secret_key' + + function processWebhook(payload) { + const { event, data } = payload + + switch (event) { + case 'payment.completed': + return { processed: true, type: 'payment', paymentId: data.payment_id } + case 'subscription.created': + return { processed: true, type: 'subscription', subscriptionId: data.subscription_id } + case 'subscription.cancelled': + return { processed: true, type: 'cancellation', subscriptionId: data.subscription_id } + case 'subscription.renewed': + return { processed: true, type: 'renewal', subscriptionId: data.subscription_id } + default: + return { processed: false, reason: 'Unknown event type' } + } + } + + test('verifies valid webhook signature', () => { + const payload = JSON.stringify({ event: 'payment.completed', data: {} }) + const signature = `sha256=${crypto.createHmac('sha256', testSecret).update(payload).digest('hex')}` + + expect(verifyWebhookSignature(payload, signature, testSecret)).toBe(true) + }) + + test('rejects invalid webhook signature', () => { + const payload = JSON.stringify({ event: 'payment.completed', data: {} }) + const signature = 'sha256=invalid_signature' + + expect(verifyWebhookSignature(payload, signature, testSecret)).toBe(false) + }) + + test('detects tampered payload', () => { + const payload = JSON.stringify({ event: 'payment.completed', data: { amount: 100 } }) + const signature = `sha256=${crypto.createHmac('sha256', testSecret).update(payload).digest('hex')}` + const tamperedPayload = JSON.stringify({ event: 'payment.completed', data: { amount: 999 } }) + + expect(verifyWebhookSignature(tamperedPayload, signature, testSecret)).toBe(false) + }) + + test('processes payment.completed webhook', () => { + const payload = { + event: 'payment.completed', + data: { + payment_id: 'pay_123', + amount: 750, + currency: 'usd', + customer_id: 'user_456', + product_id: 'prod_topup_1_usd', + }, + } + + const result = processWebhook(payload) + expect(result.processed).toBe(true) + expect(result.type).toBe('payment') + expect(result.paymentId).toBe('pay_123') + }) + + test('processes subscription.created webhook', () => { + const payload = { + event: 'subscription.created', + data: { + subscription_id: 'sub_123', + customer_id: 'user_456', + product_id: 'prod_starter_monthly_usd', + status: 'active', + current_period_start: Date.now(), + current_period_end: Date.now() + 30 * 24 * 60 * 60 * 1000, + }, + } + + const result = processWebhook(payload) + expect(result.processed).toBe(true) + expect(result.type).toBe('subscription') + }) + + test('processes subscription.cancelled webhook', () => { + const payload = { + event: 'subscription.cancelled', + data: { + subscription_id: 'sub_123', + customer_id: 'user_456', + cancelled_at: Date.now(), + }, + } + + const result = processWebhook(payload) + expect(result.processed).toBe(true) + expect(result.type).toBe('cancellation') + }) + + test('handles unknown webhook event types', () => { + const payload = { + event: 'unknown.event', + data: {}, + } + + const result = processWebhook(payload) + expect(result.processed).toBe(false) + expect(result.reason).toBe('Unknown event type') + }) + }) + + describe('Subscription Lifecycle', () => { + const subscriptionStates = ['active', 'past_due', 'canceled', 'incomplete', 'trialing', 'unpaid'] + + function isActiveSubscription(status) { + return status === 'active' || status === 'trialing' + } + + function requiresAttention(status) { + return ['past_due', 'incomplete', 'unpaid'].includes(status) + } + + function getSubscriptionAction(status) { + if (status === 'active') return 'grant_access' + if (status === 'trialing') return 'grant_access' + if (status === 'past_due') return 'notify_user' + if (status === 'canceled') return 'revoke_access' + if (status === 'unpaid') return 'revoke_access' + return 'review' + } + + test('identifies active subscriptions', () => { + expect(isActiveSubscription('active')).toBe(true) + expect(isActiveSubscription('trialing')).toBe(true) + expect(isActiveSubscription('past_due')).toBe(false) + expect(isActiveSubscription('canceled')).toBe(false) + }) + + test('identifies subscriptions requiring attention', () => { + expect(requiresAttention('past_due')).toBe(true) + expect(requiresAttention('incomplete')).toBe(true) + expect(requiresAttention('unpaid')).toBe(true) + expect(requiresAttention('active')).toBe(false) + }) + + test('determines correct subscription action', () => { + expect(getSubscriptionAction('active')).toBe('grant_access') + expect(getSubscriptionAction('trialing')).toBe('grant_access') + expect(getSubscriptionAction('past_due')).toBe('notify_user') + expect(getSubscriptionAction('canceled')).toBe('revoke_access') + expect(getSubscriptionAction('unpaid')).toBe('revoke_access') + }) + + test('calculates subscription renewal date', () => { + const now = Date.now() + const billingCycle = 'monthly' + const renewalDate = billingCycle === 'monthly' + ? now + 30 * 24 * 60 * 60 * 1000 + : now + 365 * 24 * 60 * 60 * 1000 + + expect(renewalDate).toBeGreaterThan(now) + }) + }) + + describe('Top-up Processing', () => { + function processTopup(userId, topupOption, currency = 'usd') { + const productKey = getTopupProductKey(topupOption, currency) + const price = TOPUP_PRICES[productKey] + const tokens = TOPUP_TOKENS[`topup_${topupOption}`] + + return { + userId, + productId: productKey, + price, + tokens, + currency, + status: 'pending', + createdAt: Date.now(), + } + } + + test('processes top-up option 1 correctly', () => { + const result = processTopup('user-1', '1', 'usd') + + expect(result.price).toBe(750) + expect(result.tokens).toBe(100_000) + expect(result.productId).toBe('topup_1_usd') + }) + + test('processes top-up option 4 correctly', () => { + const result = processTopup('user-1', '4', 'usd') + + expect(result.price).toBe(12500) + expect(result.tokens).toBe(50_000_000) + }) + + test('applies business discount', () => { + const originalPrice = TOPUP_PRICES.topup_2_usd + const discountedPrice = applyDiscount(originalPrice, BUSINESS_TOPUP_DISCOUNT) + + expect(discountedPrice).toBe(2438) + }) + + test('applies enterprise discount', () => { + const originalPrice = TOPUP_PRICES.topup_2_usd + const discountedPrice = applyDiscount(originalPrice, ENTERPRISE_TOPUP_DISCOUNT) + + expect(discountedPrice).toBe(2375) + }) + + test('enterprise discount is higher than business', () => { + expect(ENTERPRISE_TOPUP_DISCOUNT).toBeGreaterThan(BUSINESS_TOPUP_DISCOUNT) + }) + + test('calculates price per token', () => { + const pricePerToken1 = TOPUP_PRICES.topup_1_usd / TOPUP_TOKENS.topup_1 + const pricePerToken4 = TOPUP_PRICES.topup_4_usd / TOPUP_TOKENS.topup_4 + + expect(pricePerToken1).toBeCloseTo(0.0075, 4) + expect(pricePerToken4).toBeCloseTo(0.00025, 4) + expect(pricePerToken4).toBeLessThan(pricePerToken1) + }) + }) + + describe('Pay-as-you-go Billing', () => { + const PAYG_PRICES = { + usd: 250, + gbp: 200, + eur: 250, + } + const PAYG_UNIT_TOKENS = 1_000_000 + + function calculatePaygCost(tokens, currency = 'usd') { + const rate = PAYG_PRICES[currency] || PAYG_PRICES.usd + return (tokens / PAYG_UNIT_TOKENS) * rate + } + + test('calculates PAYG cost for USD', () => { + const cost = calculatePaygCost(500_000, 'usd') + expect(cost).toBe(125) + }) + + test('calculates PAYG cost for GBP', () => { + const cost = calculatePaygCost(1_000_000, 'gbp') + expect(cost).toBe(200) + }) + + test('handles zero tokens', () => { + const cost = calculatePaygCost(0, 'usd') + expect(cost).toBe(0) + }) + + test('defaults to USD for unknown currency', () => { + const cost = calculatePaygCost(1_000_000, 'jpy') + expect(cost).toBe(250) + }) + }) + + describe('Usage Events', () => { + const DODO_USAGE_EVENT_NAME = 'token.usage' + const DODO_USAGE_EVENT_COST_FIELD = 'costCents' + const DODO_USAGE_EVENT_TOKENS_FIELD = 'billableTokens' + + function createUsageEvent(userId, tokens, costCents) { + return { + event: DODO_USAGE_EVENT_NAME, + customerId: userId, + properties: { + [DODO_USAGE_EVENT_TOKENS_FIELD]: tokens, + [DODO_USAGE_EVENT_COST_FIELD]: costCents, + }, + timestamp: Date.now(), + } + } + + test('creates usage event with correct structure', () => { + const event = createUsageEvent('user-123', 50_000, 12.5) + + expect(event.event).toBe('token.usage') + expect(event.customerId).toBe('user-123') + expect(event.properties.billableTokens).toBe(50_000) + expect(event.properties.costCents).toBe(12.5) + expect(event.timestamp).toBeDefined() + }) + + test('event name matches configuration', () => { + expect(DODO_USAGE_EVENT_NAME).toBe('token.usage') + }) + + test('usage event fields are correct', () => { + expect(DODO_USAGE_EVENT_COST_FIELD).toBe('costCents') + expect(DODO_USAGE_EVENT_TOKENS_FIELD).toBe('billableTokens') + }) + }) + + describe('Invoice Generation', () => { + function generateInvoiceNumber() { + const date = new Date() + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const random = crypto.randomBytes(4).toString('hex').toUpperCase() + return `INV-${year}${month}-${random}` + } + + function createInvoice(options) { + const { + customerId, + items, + currency = 'usd', + status = 'draft', + } = options + + const total = items.reduce((sum, item) => sum + item.amount, 0) + + return { + id: generateInvoiceNumber(), + customerId, + items, + currency, + total, + status, + createdAt: Date.now(), + dueAt: Date.now() + 30 * 24 * 60 * 60 * 1000, + } + } + + test('generates invoice numbers with correct format', () => { + const invoice = generateInvoiceNumber() + expect(invoice).toMatch(/^INV-\d{6}-[A-F0-9]{8}$/) + }) + + test('generates unique invoice numbers', () => { + const invoices = new Set() + for (let i = 0; i < 100; i++) { + invoices.add(generateInvoiceNumber()) + } + expect(invoices.size).toBe(100) + }) + + test('creates invoice with items', () => { + const invoice = createInvoice({ + customerId: 'user-123', + items: [ + { description: 'Starter Plan - Monthly', amount: 750 }, + { description: 'Token Top-up', amount: 2500 }, + ], + }) + + expect(invoice.items.length).toBe(2) + expect(invoice.total).toBe(3250) + expect(invoice.status).toBe('draft') + }) + + test('calculates invoice total correctly', () => { + const invoice = createInvoice({ + customerId: 'user-456', + items: [ + { description: 'Professional Plan', amount: 2500 }, + { description: 'Add-on', amount: 500 }, + ], + }) + + expect(invoice.total).toBe(3000) + }) + }) + + describe('Refund Calculations', () => { + test('calculates prorated refund for mid-cycle cancellation', () => { + const refund = calculateProratedRefund(750, 15, 30) + expect(refund).toBe(375) + }) + + test('no refund at end of cycle', () => { + const refund = calculateProratedRefund(750, 0, 30) + expect(refund).toBe(0) + }) + + test('full refund at start of cycle', () => { + const refund = calculateProratedRefund(750, 30, 30) + expect(refund).toBe(750) + }) + + test('handles yearly subscription refund', () => { + const refund = calculateProratedRefund(7500, 180, 365) + expect(refund).toBeCloseTo(3699, 0) + }) + }) + + describe('Product ID Resolution', () => { + const SUBSCRIPTION_PRODUCT_IDS = { + starter_monthly_usd: 'prod_starter_monthly_usd', + starter_yearly_usd: 'prod_starter_yearly_usd', + professional_monthly_usd: 'prod_professional_monthly_usd', + enterprise_monthly_usd: 'prod_enterprise_monthly_usd', + } + + test('resolves subscription product ID', () => { + const key = getProductKey('starter', 'monthly', 'usd') + expect(SUBSCRIPTION_PRODUCT_IDS[key]).toBe('prod_starter_monthly_usd') + }) + + test('resolves top-up product ID', () => { + const key = getTopupProductKey('1', 'usd') + expect(key).toBe('topup_1_usd') + }) + + test('generates product key for all combinations', () => { + const plans = ['starter', 'professional', 'enterprise'] + const cycles = ['monthly', 'yearly'] + const currencies = ['usd', 'gbp', 'eur'] + + plans.forEach(plan => { + cycles.forEach(cycle => { + currencies.forEach(currency => { + const key = getProductKey(plan, cycle, currency) + expect(key).toBe(`${plan}_${cycle}_${currency}`) + }) + }) + }) + }) + }) + + describe('Currency Handling', () => { + const SUPPORTED_CURRENCIES = ['usd', 'gbp', 'eur'] + + test('supports all required currencies', () => { + expect(SUPPORTED_CURRENCIES).toContain('usd') + expect(SUPPORTED_CURRENCIES).toContain('gbp') + expect(SUPPORTED_CURRENCIES).toContain('eur') + }) + + test('formats prices with correct symbols', () => { + expect(formatPrice(750, 'usd')).toBe('$7.50') + expect(formatPrice(625, 'gbp')).toBe('£6.25') + expect(formatPrice(750, 'eur')).toBe('€7.50') + }) + + test('defaults to $ for unknown currency', () => { + expect(formatPrice(100, 'jpy')).toBe('$1.00') + }) + }) +}) diff --git a/chat/src/tests/external-wp-testing.test.js b/chat/src/tests/external-wp-testing.test.js new file mode 100644 index 0000000..8e938b7 --- /dev/null +++ b/chat/src/tests/external-wp-testing.test.js @@ -0,0 +1,449 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') +const path = require('path') +const os = require('os') + +const DEFAULT_CONFIG = { + wpHost: 'test.example.com', + wpSshUser: 'wordpress', + wpSshKey: '/home/user/.ssh/id_rsa', + wpPath: '/var/www/html', + wpBaseUrl: 'https://test.example.com', + enableMultisite: true, + subsitePrefix: 'test', + subsiteDomain: '', + subsiteMode: 'subdirectory', + maxConcurrentTests: 20, + queueTimeoutMs: 300000, + testTimeoutMs: 600000, + autoCleanup: true, + cleanupDelayMs: 3600000, +} + +function expandHome(p) { + if (!p || typeof p !== 'string') return p + if (p.startsWith('~')) { + return path.join(os.homedir(), p.slice(1)) + } + return p +} + +function getExternalTestingConfig(overrides = {}) { + const config = { ...DEFAULT_CONFIG, ...(overrides || {}) } + config.wpSshKey = expandHome(config.wpSshKey) + config.wpBaseUrl = (config.wpBaseUrl || '').trim() + if (!config.wpBaseUrl && config.wpHost) { + config.wpBaseUrl = `https://${config.wpHost}` + } + config.maxConcurrentTests = Number.isFinite(config.maxConcurrentTests) && config.maxConcurrentTests > 0 + ? Math.floor(config.maxConcurrentTests) + : 1 + config.queueTimeoutMs = Number.isFinite(config.queueTimeoutMs) && config.queueTimeoutMs > 0 + ? Math.floor(config.queueTimeoutMs) + : 300000 + config.testTimeoutMs = Number.isFinite(config.testTimeoutMs) && config.testTimeoutMs > 0 + ? Math.floor(config.testTimeoutMs) + : 600000 + config.cleanupDelayMs = Number.isFinite(config.cleanupDelayMs) && config.cleanupDelayMs >= 0 + ? Math.floor(config.cleanupDelayMs) + : 3600000 + config.subsiteMode = config.subsiteMode === 'subdomain' ? 'subdomain' : 'subdirectory' + return config +} + +describe('External WordPress Testing', () => { + describe('Configuration Management', () => { + test('provides default configuration', () => { + const config = getExternalTestingConfig() + + expect(config.wpHost).toBe('test.example.com') + expect(config.wpSshUser).toBe('wordpress') + expect(config.enableMultisite).toBe(true) + expect(config.maxConcurrentTests).toBe(20) + }) + + test('overrides configuration values', () => { + const config = getExternalTestingConfig({ + wpHost: 'custom.example.com', + maxConcurrentTests: 10, + }) + + expect(config.wpHost).toBe('custom.example.com') + expect(config.maxConcurrentTests).toBe(10) + expect(config.wpSshUser).toBe('wordpress') + }) + + test('expands home directory in SSH key path', () => { + const config = getExternalTestingConfig({ + wpSshKey: '~/.ssh/custom_key', + }) + + expect(config.wpSshKey).toContain('.ssh') + expect(config.wpSshKey).toContain('custom_key') + }) + + test('generates base URL from host if not provided', () => { + const config = getExternalTestingConfig({ + wpHost: 'mytest.com', + wpBaseUrl: '', + }) + + expect(config.wpBaseUrl).toBe('https://mytest.com') + }) + + test('normalizes subsite mode', () => { + expect(getExternalTestingConfig({ subsiteMode: 'subdomain' }).subsiteMode).toBe('subdomain') + expect(getExternalTestingConfig({ subsiteMode: 'subdirectory' }).subsiteMode).toBe('subdirectory') + expect(getExternalTestingConfig({ subsiteMode: 'invalid' }).subsiteMode).toBe('subdirectory') + }) + + test('enforces minimum maxConcurrentTests', () => { + expect(getExternalTestingConfig({ maxConcurrentTests: 0 }).maxConcurrentTests).toBe(1) + expect(getExternalTestingConfig({ maxConcurrentTests: -5 }).maxConcurrentTests).toBe(1) + expect(getExternalTestingConfig({ maxConcurrentTests: 'invalid' }).maxConcurrentTests).toBe(1) + }) + + test('enforces minimum timeouts', () => { + expect(getExternalTestingConfig({ queueTimeoutMs: 0 }).queueTimeoutMs).toBe(300000) + expect(getExternalTestingConfig({ testTimeoutMs: -100 }).testTimeoutMs).toBe(600000) + }) + }) + + describe('SSH Command Building', () => { + function buildSshBaseArgs(config) { + const args = [] + if (config.wpSshKey) { + args.push('-i', config.wpSshKey) + } + if (!config.strictHostKeyChecking) { + args.push('-o', 'StrictHostKeyChecking=no') + } + args.push('-o', 'BatchMode=yes') + return args + } + + test('builds SSH arguments with key', () => { + const config = getExternalTestingConfig({ + wpSshKey: '/path/to/key', + strictHostKeyChecking: false, + }) + const args = buildSshBaseArgs(config) + + expect(args).toContain('-i') + expect(args).toContain('/path/to/key') + expect(args).toContain('StrictHostKeyChecking=no') + expect(args).toContain('BatchMode=yes') + }) + + test('builds SSH arguments without key', () => { + const config = getExternalTestingConfig({ + wpSshKey: null, + strictHostKeyChecking: false, + }) + const args = buildSshBaseArgs(config) + + expect(args).not.toContain('-i') + expect(args).toContain('StrictHostKeyChecking=no') + }) + + test('enables strict host key checking when configured', () => { + const config = getExternalTestingConfig({ + strictHostKeyChecking: true, + }) + const args = buildSshBaseArgs(config) + + expect(args).not.toContain('StrictHostKeyChecking=no') + }) + }) + + describe('Test Queue Management', () => { + const testQueue = { + queue: [], + active: new Map(), + completed: [], + maxConcurrent: 5, + + add(testRun) { + this.queue.push({ + ...testRun, + id: crypto.randomUUID(), + status: 'queued', + queuedAt: Date.now(), + }) + }, + + canStart() { + return this.active.size < this.maxConcurrent + }, + + start(testId) { + const index = this.queue.findIndex(t => t.id === testId) + if (index === -1) return false + if (!this.canStart()) return false + + const test = this.queue.splice(index, 1)[0] + test.status = 'running' + test.startedAt = Date.now() + this.active.set(test.id, test) + return true + }, + + complete(testId, result) { + const test = this.active.get(testId) + if (!test) return false + + test.status = 'completed' + test.completedAt = Date.now() + test.result = result + this.active.delete(testId) + this.completed.push(test) + return true + }, + + fail(testId, error) { + const test = this.active.get(testId) + if (!test) return false + + test.status = 'failed' + test.completedAt = Date.now() + test.error = error + this.active.delete(testId) + this.completed.push(test) + return true + }, + } + + beforeEach(() => { + testQueue.queue = [] + testQueue.active.clear() + testQueue.completed = [] + }) + + test('adds test runs to queue', () => { + testQueue.add({ appId: 'app-1', subsiteId: 'test-1' }) + + expect(testQueue.queue.length).toBe(1) + expect(testQueue.queue[0].status).toBe('queued') + }) + + test('generates unique test IDs', () => { + testQueue.add({ appId: 'app-1' }) + testQueue.add({ appId: 'app-2' }) + + expect(testQueue.queue[0].id).not.toBe(testQueue.queue[1].id) + }) + + test('starts queued tests', () => { + testQueue.add({ appId: 'app-1' }) + const testId = testQueue.queue[0].id + const started = testQueue.start(testId) + + expect(started).toBe(true) + expect(testQueue.queue.length).toBe(0) + expect(testQueue.active.has(testId)).toBe(true) + }) + + test('enforces maximum concurrent tests', () => { + for (let i = 0; i < 5; i++) { + testQueue.add({ appId: `app-${i}` }) + const testId = testQueue.queue.find(t => t.status === 'queued')?.id + if (testId) testQueue.start(testId) + } + + expect(testQueue.active.size).toBe(5) + expect(testQueue.canStart()).toBe(false) + }) + + test('completes running tests', () => { + testQueue.add({ appId: 'app-1' }) + const testId = testQueue.queue[0].id + testQueue.start(testId) + const completed = testQueue.complete(testId, { success: true }) + + expect(completed).toBe(true) + expect(testQueue.active.size).toBe(0) + expect(testQueue.completed[0].status).toBe('completed') + }) + + test('fails running tests', () => { + testQueue.add({ appId: 'app-1' }) + const testId = testQueue.queue[0].id + testQueue.start(testId) + const failed = testQueue.fail(testId, 'Test failed') + + expect(failed).toBe(true) + expect(testQueue.completed[0].status).toBe('failed') + expect(testQueue.completed[0].error).toBe('Test failed') + }) + }) + + describe('Subsite Management', () => { + function generateSubsiteId(prefix = 'test') { + return `${prefix}-${crypto.randomBytes(4).toString('hex')}` + } + + function buildSubsiteUrl(baseUrl, subsiteId, mode = 'subdirectory') { + if (mode === 'subdomain') { + const url = new URL(baseUrl) + url.hostname = `${subsiteId}.${url.hostname}` + return url.toString() + } + return `${baseUrl.replace(/\/$/, '')}/${subsiteId}` + } + + test('generates unique subsite IDs', () => { + const ids = new Set() + for (let i = 0; i < 100; i++) { + ids.add(generateSubsiteId()) + } + expect(ids.size).toBe(100) + }) + + test('builds subdirectory URL', () => { + const url = buildSubsiteUrl('https://example.com', 'test-abc123', 'subdirectory') + expect(url).toBe('https://example.com/test-abc123') + }) + + test('builds subdomain URL', () => { + const url = buildSubsiteUrl('https://example.com', 'test-abc123', 'subdomain') + expect(url).toBe('https://test-abc123.example.com/') + }) + + test('handles trailing slashes in base URL', () => { + const url = buildSubsiteUrl('https://example.com/', 'test-123', 'subdirectory') + expect(url).toBe('https://example.com/test-123') + }) + }) + + describe('Command Execution', () => { + function parseCommandOutput(stdout, stderr, code) { + return { + success: code === 0, + output: stdout?.trim() || '', + error: stderr?.trim() || '', + exitCode: code, + } + } + + test('parses successful command output', () => { + const result = parseCommandOutput('Command succeeded', '', 0) + + expect(result.success).toBe(true) + expect(result.output).toBe('Command succeeded') + expect(result.error).toBe('') + }) + + test('parses failed command output', () => { + const result = parseCommandOutput('', 'Error: command failed', 1) + + expect(result.success).toBe(false) + expect(result.error).toBe('Error: command failed') + }) + + test('handles empty output', () => { + const result = parseCommandOutput(null, null, 0) + + expect(result.output).toBe('') + expect(result.error).toBe('') + }) + }) + + describe('Cleanup Operations', () => { + const cleanupQueue = { + items: [], + + schedule(subsiteId, delayMs) { + this.items.push({ + subsiteId, + scheduledAt: Date.now(), + cleanupAt: Date.now() + delayMs, + }) + }, + + getDue() { + const now = Date.now() + return this.items.filter(item => item.cleanupAt <= now) + }, + + remove(subsiteId) { + this.items = this.items.filter(item => item.subsiteId !== subsiteId) + }, + } + + beforeEach(() => { + cleanupQueue.items = [] + }) + + test('schedules cleanup', () => { + cleanupQueue.schedule('test-123', 3600000) + + expect(cleanupQueue.items.length).toBe(1) + expect(cleanupQueue.items[0].subsiteId).toBe('test-123') + }) + + test('identifies due cleanups', () => { + cleanupQueue.schedule('old-site', -1000) + cleanupQueue.schedule('new-site', 3600000) + + const due = cleanupQueue.getDue() + expect(due.length).toBe(1) + expect(due[0].subsiteId).toBe('old-site') + }) + + test('removes items from cleanup queue', () => { + cleanupQueue.schedule('test-123', 3600000) + cleanupQueue.remove('test-123') + + expect(cleanupQueue.items.length).toBe(0) + }) + }) + + describe('Test Limits by Plan', () => { + const EXTERNAL_TEST_LIMITS = { + hobby: 3, + starter: 50, + professional: Infinity, + enterprise: Infinity, + } + + function getExternalTestLimit(plan) { + const normalized = String(plan || '').toLowerCase() + return EXTERNAL_TEST_LIMITS[normalized] || EXTERNAL_TEST_LIMITS.hobby + } + + function canRunExternalTest(plan, usedTests) { + const limit = getExternalTestLimit(plan) + if (limit === Infinity) return true + return usedTests < limit + } + + test('hobby plan has 3 test limit', () => { + expect(getExternalTestLimit('hobby')).toBe(3) + }) + + test('starter plan has 50 test limit', () => { + expect(getExternalTestLimit('starter')).toBe(50) + }) + + test('professional plan has unlimited tests', () => { + expect(getExternalTestLimit('professional')).toBe(Infinity) + }) + + test('enterprise plan has unlimited tests', () => { + expect(getExternalTestLimit('enterprise')).toBe(Infinity) + }) + + test('blocks test for hobby plan at limit', () => { + expect(canRunExternalTest('hobby', 2)).toBe(true) + expect(canRunExternalTest('hobby', 3)).toBe(false) + }) + + test('allows unlimited tests for professional', () => { + expect(canRunExternalTest('professional', 1000)).toBe(true) + }) + + test('defaults to hobby limit for unknown plan', () => { + expect(getExternalTestLimit('unknown')).toBe(3) + }) + }) +}) diff --git a/chat/src/tests/file-operations.test.js b/chat/src/tests/file-operations.test.js new file mode 100644 index 0000000..3947ef0 --- /dev/null +++ b/chat/src/tests/file-operations.test.js @@ -0,0 +1,448 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') +const path = require('path') + +const MAX_UPLOAD_ZIP_SIZE = 25_000_000 +const MAX_EXPORT_ZIP_SIZE = 100_000_000 +const MAX_EXPORT_FILE_COUNT = 10000 +const MAX_ATTACHMENT_SIZE = 5_000_000 +const MAX_JSON_BODY_SIZE = 6_000_000 +const BASE64_OVERHEAD_MULTIPLIER = 1.34 + +const ZIP_LOCAL_HEADER_SIG = [0x50, 0x4b, 0x03, 0x04] +const ZIP_EOCD_EMPTY_SIG = [0x50, 0x4b, 0x05, 0x06] + +const BLOCKED_PATH_PATTERN = /^(?:[a-zA-Z]:|\\\\|\/\/|con:|prn:|aux:|nul:|com[1-9]:|lpt[1-9]:)/i + +const TOPUP_TOKENS = { + topup_4: 50_000_000, +} + +const TOPUP_PRICES = { + topup_4_usd: 12500, +} + +describe('File Operations', () => { + describe('ZIP File Handling', () => { + function isValidZipSignature(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 4) return false + return ( + buffer[0] === 0x50 && + buffer[1] === 0x4b && + (buffer[2] === 0x03 || buffer[2] === 0x05 || buffer[2] === 0x07) && + (buffer[3] === 0x04 || buffer[3] === 0x06 || buffer[3] === 0x08) + ) + } + + test('validates ZIP file signature', () => { + const validZipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]) + const invalidBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]) + + expect(isValidZipSignature(validZipBuffer)).toBe(true) + expect(isValidZipSignature(invalidBuffer)).toBe(false) + }) + + test('rejects buffer that is too small', () => { + const smallBuffer = Buffer.from([0x50, 0x4b]) + expect(isValidZipSignature(smallBuffer)).toBe(false) + }) + + test('enforces maximum upload ZIP size', () => { + expect(MAX_UPLOAD_ZIP_SIZE).toBe(25_000_000) + }) + + test('enforces maximum export ZIP size', () => { + expect(MAX_EXPORT_ZIP_SIZE).toBe(100_000_000) + }) + + test('enforces maximum file count in export', () => { + expect(MAX_EXPORT_FILE_COUNT).toBe(10000) + }) + + test('calculates base64 overhead for size validation', () => { + const originalSize = 1_000_000 + const base64Size = Math.ceil(originalSize * BASE64_OVERHEAD_MULTIPLIER) + + expect(base64Size).toBeGreaterThanOrEqual(originalSize) + expect(base64Size).toBe(1340000) + }) + + test('validates token price per dollar calculation', () => { + const tokensPerDollar = TOPUP_TOKENS.topup_4 / (TOPUP_PRICES.topup_4_usd / 100) + expect(tokensPerDollar).toBe(400000) + }) + }) + + describe('File Path Validation', () => { + function sanitizePathSegment(segment) { + return String(segment || '') + .replace(/[^a-zA-Z0-9\-_\.]/g, '_') + .replace(/\.{2,}/g, '_') + .slice(0, 255) + } + + function isPathAllowed(requestedPath, allowedRoot) { + const normalized = path.normalize(requestedPath) + const resolved = path.resolve(allowedRoot, normalized) + return resolved.startsWith(path.resolve(allowedRoot)) + } + + function isBlockedPath(pathStr) { + return BLOCKED_PATH_PATTERN.test(pathStr) + } + + test('sanitizes file path segments', () => { + expect(sanitizePathSegment('normal-file.txt')).toBe('normal-file.txt') + expect(sanitizePathSegment('../../../etc/passwd')).toBe('______etc_passwd') + expect(sanitizePathSegment('file