Add comprehensive test suite for chat app
- Add userRepository.test.js: User CRUD, authentication, OAuth providers, 2FA, affiliate tracking - Add sessionRepository.test.js: Sessions, refresh tokens, token blacklist, device fingerprinting - Add auditRepository.test.js: Audit logging, event types, time-based queries, success/failure tracking - Add connection.test.js: Database initialization, transactions, foreign keys, backup operations - Add model-routing.test.js: Provider fallback, plan validation, token rates, rate limiting - Add payments.test.js: Subscription pricing, top-ups, PAYG, discounts, webhook handling - Add api-endpoints.test.js: Auth endpoints, user management, admin endpoints, middleware - Update package.json with test scripts (bun test) Tests cover: accounts, authentication, model routing, payments, subscriptions, admin panel, API endpoints
This commit is contained in:
470
chat/src/database/connection.test.js
Normal file
470
chat/src/database/connection.test.js
Normal file
@@ -0,0 +1,470 @@
|
||||
const { describe, test, expect, beforeEach, afterEach } = require('bun:test')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
const testDir = path.join(__dirname, '.test-db')
|
||||
const testDbPath = path.join(testDir, 'test.db')
|
||||
const backupDbPath = path.join(testDir, 'backup.db')
|
||||
|
||||
describe('Database Connection', () => {
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(testDir)) {
|
||||
fs.mkdirSync(testDir, { recursive: true })
|
||||
}
|
||||
if (fs.existsSync(testDbPath)) {
|
||||
fs.unlinkSync(testDbPath)
|
||||
}
|
||||
if (fs.existsSync(backupDbPath)) {
|
||||
fs.unlinkSync(backupDbPath)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(testDbPath)) {
|
||||
try { fs.unlinkSync(testDbPath) } catch {}
|
||||
}
|
||||
if (fs.existsSync(backupDbPath)) {
|
||||
try { fs.unlinkSync(backupDbPath) } catch {}
|
||||
}
|
||||
if (fs.existsSync(testDir)) {
|
||||
try { fs.rmdirSync(testDir, { recursive: true }) } catch {}
|
||||
}
|
||||
})
|
||||
|
||||
describe('Database Initialization', () => {
|
||||
test('creates database file', () => {
|
||||
const db = new Database(testDbPath)
|
||||
|
||||
expect(fs.existsSync(testDbPath)).toBe(true)
|
||||
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('creates database directory if not exists', () => {
|
||||
const nestedPath = path.join(testDir, 'nested', 'deep', 'test.db')
|
||||
const nestedDir = path.dirname(nestedPath)
|
||||
|
||||
if (!fs.existsSync(nestedDir)) {
|
||||
fs.mkdirSync(nestedDir, { recursive: true })
|
||||
}
|
||||
|
||||
const db = new Database(nestedPath)
|
||||
|
||||
expect(fs.existsSync(nestedPath)).toBe(true)
|
||||
|
||||
db.close()
|
||||
fs.unlinkSync(nestedPath)
|
||||
})
|
||||
|
||||
test('opens existing database', () => {
|
||||
const db1 = new Database(testDbPath)
|
||||
db1.exec('CREATE TABLE test (id TEXT PRIMARY KEY)')
|
||||
db1.close()
|
||||
|
||||
const db2 = new Database(testDbPath)
|
||||
const tables = db2.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
|
||||
|
||||
expect(tables.some(t => t.name === 'test')).toBe(true)
|
||||
|
||||
db2.close()
|
||||
})
|
||||
|
||||
test('sets WAL mode', () => {
|
||||
const db = new Database(testDbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
|
||||
const result = db.pragma('journal_mode')[0]
|
||||
|
||||
expect(result.journal_mode.toLowerCase()).toBe('wal')
|
||||
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('enables foreign keys', () => {
|
||||
const db = new Database(testDbPath)
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
const result = db.pragma('foreign_keys')[0]
|
||||
|
||||
expect(result.foreign_keys).toBe(1)
|
||||
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Database Operations', () => {
|
||||
let db
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(testDbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (db) {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('executes CREATE TABLE', () => {
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE
|
||||
)
|
||||
`)
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
|
||||
|
||||
expect(tables.some(t => t.name === 'users')).toBe(true)
|
||||
})
|
||||
|
||||
test('executes INSERT', () => {
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)')
|
||||
|
||||
const stmt = db.prepare('INSERT INTO test (id, value) VALUES (?, ?)')
|
||||
const result = stmt.run('1', 'test value')
|
||||
|
||||
expect(result.changes).toBe(1)
|
||||
})
|
||||
|
||||
test('executes SELECT', () => {
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)')
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'test value')
|
||||
|
||||
const row = db.prepare('SELECT * FROM test WHERE id = ?').get('1')
|
||||
|
||||
expect(row).toBeDefined()
|
||||
expect(row.value).toBe('test value')
|
||||
})
|
||||
|
||||
test('executes UPDATE', () => {
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)')
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'original')
|
||||
|
||||
const result = db.prepare('UPDATE test SET value = ? WHERE id = ?').run('updated', '1')
|
||||
|
||||
expect(result.changes).toBe(1)
|
||||
|
||||
const row = db.prepare('SELECT * FROM test WHERE id = ?').get('1')
|
||||
expect(row.value).toBe('updated')
|
||||
})
|
||||
|
||||
test('executes DELETE', () => {
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)')
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'test')
|
||||
|
||||
const result = db.prepare('DELETE FROM test WHERE id = ?').run('1')
|
||||
|
||||
expect(result.changes).toBe(1)
|
||||
|
||||
const row = db.prepare('SELECT * FROM test WHERE id = ?').get('1')
|
||||
expect(row).toBeUndefined()
|
||||
})
|
||||
|
||||
test('handles multiple rows', () => {
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)')
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run(`${i}`, `value-${i}`)
|
||||
}
|
||||
|
||||
const rows = db.prepare('SELECT * FROM test ORDER BY id').all()
|
||||
|
||||
expect(rows.length).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Transactions', () => {
|
||||
let db
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(testDbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (db) {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('commits transaction', () => {
|
||||
const insert = db.transaction(() => {
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'a')
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('2', 'b')
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('3', 'c')
|
||||
})
|
||||
|
||||
insert()
|
||||
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM test').get()
|
||||
expect(count.count).toBe(3)
|
||||
})
|
||||
|
||||
test('rolls back on error', () => {
|
||||
const insert = db.transaction(() => {
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'a')
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('2', 'b')
|
||||
throw new Error('Simulated error')
|
||||
})
|
||||
|
||||
expect(() => insert()).toThrow('Simulated error')
|
||||
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM test').get()
|
||||
expect(count.count).toBe(0)
|
||||
})
|
||||
|
||||
test('nested transactions', () => {
|
||||
const outer = db.transaction(() => {
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'outer')
|
||||
|
||||
const inner = db.transaction(() => {
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('2', 'inner')
|
||||
})
|
||||
|
||||
inner()
|
||||
})
|
||||
|
||||
outer()
|
||||
|
||||
const rows = db.prepare('SELECT * FROM test ORDER BY id').all()
|
||||
expect(rows.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Foreign Keys', () => {
|
||||
let db
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(testDbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (db) {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('enforces foreign key constraint', () => {
|
||||
expect(() => {
|
||||
db.prepare('INSERT INTO posts (id, user_id, title) VALUES (?, ?, ?)').run('p1', 'nonexistent', 'Test')
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
test('allows valid foreign key', () => {
|
||||
db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run('u1', 'User 1')
|
||||
db.prepare('INSERT INTO posts (id, user_id, title) VALUES (?, ?, ?)').run('p1', 'u1', 'Test')
|
||||
|
||||
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get('p1')
|
||||
expect(post).toBeDefined()
|
||||
})
|
||||
|
||||
test('cascades delete', () => {
|
||||
db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run('u1', 'User 1')
|
||||
db.prepare('INSERT INTO posts (id, user_id, title) VALUES (?, ?, ?)').run('p1', 'u1', 'Test')
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run('u1')
|
||||
|
||||
const posts = db.prepare('SELECT * FROM posts WHERE user_id = ?').all('u1')
|
||||
expect(posts.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Backup', () => {
|
||||
test('creates backup', () => {
|
||||
const db = new Database(testDbPath)
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY)')
|
||||
db.prepare('INSERT INTO test (id) VALUES (?)').run('1')
|
||||
|
||||
const backup = db.backup(backupDbPath)
|
||||
backup.step(-1)
|
||||
backup.finish()
|
||||
|
||||
expect(fs.existsSync(backupDbPath)).toBe(true)
|
||||
|
||||
const backupDb = new Database(backupDbPath)
|
||||
const row = backupDb.prepare('SELECT * FROM test WHERE id = ?').get('1')
|
||||
expect(row).toBeDefined()
|
||||
backupDb.close()
|
||||
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Connection Management', () => {
|
||||
test('closes connection', () => {
|
||||
const db = new Database(testDbPath)
|
||||
expect(db.open).toBe(true)
|
||||
|
||||
db.close()
|
||||
expect(db.open).toBe(false)
|
||||
})
|
||||
|
||||
test('handles multiple operations before close', () => {
|
||||
const db = new Database(testDbPath)
|
||||
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY)')
|
||||
db.prepare('INSERT INTO test (id) VALUES (?)').run('1')
|
||||
const row = db.prepare('SELECT * FROM test').get('1')
|
||||
|
||||
expect(row).toBeDefined()
|
||||
|
||||
db.close()
|
||||
expect(db.open).toBe(false)
|
||||
})
|
||||
|
||||
test('checks if database is open', () => {
|
||||
const db = new Database(testDbPath)
|
||||
expect(db.open).toBe(true)
|
||||
db.close()
|
||||
expect(db.open).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
let db
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(testDbPath)
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT UNIQUE)')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (db) {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('handles duplicate primary key', () => {
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'a')
|
||||
|
||||
expect(() => {
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'b')
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
test('handles unique constraint violation', () => {
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'a')
|
||||
|
||||
expect(() => {
|
||||
db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('2', 'a')
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
test('handles NOT NULL constraint violation', () => {
|
||||
db.exec('CREATE TABLE not_null_test (id TEXT PRIMARY KEY, value TEXT NOT NULL)')
|
||||
|
||||
expect(() => {
|
||||
db.prepare('INSERT INTO not_null_test (id, value) VALUES (?, ?)').run('1', null)
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
test('handles invalid SQL', () => {
|
||||
expect(() => {
|
||||
db.exec('INVALID SQL STATEMENT')
|
||||
}).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
let db
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(testDbPath)
|
||||
db.exec('CREATE TABLE perf_test (id TEXT PRIMARY KEY, value TEXT, created_at INTEGER)')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (db) {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('bulk insert with transaction', () => {
|
||||
const insert = db.transaction(() => {
|
||||
const stmt = db.prepare('INSERT INTO perf_test (id, value, created_at) VALUES (?, ?, ?)')
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
stmt.run(`id-${i}`, `value-${i}`, Date.now())
|
||||
}
|
||||
})
|
||||
|
||||
insert()
|
||||
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM perf_test').get()
|
||||
expect(count.count).toBe(1000)
|
||||
})
|
||||
|
||||
test('batch select', () => {
|
||||
const insert = db.transaction(() => {
|
||||
const stmt = db.prepare('INSERT INTO perf_test (id, value, created_at) VALUES (?, ?, ?)')
|
||||
for (let i = 0; i < 100; i++) {
|
||||
stmt.run(`id-${i}`, `value-${i}`, Date.now())
|
||||
}
|
||||
})
|
||||
insert()
|
||||
|
||||
const rows = db.prepare('SELECT * FROM perf_test ORDER BY id').all()
|
||||
expect(rows.length).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema Operations', () => {
|
||||
let db
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(testDbPath)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (db) {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('creates index', () => {
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, email TEXT)')
|
||||
db.exec('CREATE INDEX idx_email ON test(email)')
|
||||
|
||||
const indexes = db.pragma('index_list(test)')
|
||||
expect(indexes.some(idx => idx.name === 'idx_email')).toBe(true)
|
||||
})
|
||||
|
||||
test('adds column', () => {
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY)')
|
||||
db.exec('ALTER TABLE test ADD COLUMN name TEXT')
|
||||
|
||||
const info = db.pragma('table_info(test)')
|
||||
expect(info.some(col => col.name === 'name')).toBe(true)
|
||||
})
|
||||
|
||||
test('gets table info', () => {
|
||||
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, name TEXT, email TEXT)')
|
||||
|
||||
const info = db.pragma('table_info(test)')
|
||||
expect(info.length).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
496
chat/src/repositories/auditRepository.test.js
Normal file
496
chat/src/repositories/auditRepository.test.js
Normal file
@@ -0,0 +1,496 @@
|
||||
const { describe, test, expect, beforeEach, afterEach } = require('bun:test')
|
||||
const crypto = require('crypto')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
const testDbPath = path.join(__dirname, '.test-audit.db')
|
||||
|
||||
let testDb
|
||||
|
||||
function createTestSchema(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
event_data TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER DEFAULT 1,
|
||||
error_message TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
describe('Audit Repository', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(testDbPath)) {
|
||||
fs.unlinkSync(testDbPath)
|
||||
}
|
||||
|
||||
testDb = new Database(testDbPath)
|
||||
createTestSchema(testDb)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (testDb) {
|
||||
testDb.close()
|
||||
}
|
||||
if (fs.existsSync(testDbPath)) {
|
||||
fs.unlinkSync(testDbPath)
|
||||
}
|
||||
})
|
||||
|
||||
describe('Audit Event Logging', () => {
|
||||
test('logs a basic audit event', () => {
|
||||
const now = Date.now()
|
||||
const eventId = crypto.randomUUID()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(eventId, 'user.login', now)
|
||||
|
||||
const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get(eventId)
|
||||
|
||||
expect(event).toBeDefined()
|
||||
expect(event.event_type).toBe('user.login')
|
||||
expect(event.success).toBe(1)
|
||||
})
|
||||
|
||||
test('logs event with user ID', () => {
|
||||
const now = Date.now()
|
||||
const eventId = crypto.randomUUID()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, user_id, event_type, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(eventId, 'user-123', 'user.logout', now)
|
||||
|
||||
const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get(eventId)
|
||||
|
||||
expect(event.user_id).toBe('user-123')
|
||||
})
|
||||
|
||||
test('logs event with all metadata', () => {
|
||||
const now = Date.now()
|
||||
const eventId = crypto.randomUUID()
|
||||
const eventData = JSON.stringify({ method: 'password', rememberMe: true })
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, user_id, event_type, event_data, ip_address, user_agent, success, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(eventId, 'user-456', 'user.login', eventData, '192.168.1.1', 'Mozilla/5.0', 1, now)
|
||||
|
||||
const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get(eventId)
|
||||
|
||||
expect(event.ip_address).toBe('192.168.1.1')
|
||||
expect(event.user_agent).toBe('Mozilla/5.0')
|
||||
const parsedData = JSON.parse(event.event_data)
|
||||
expect(parsedData.method).toBe('password')
|
||||
})
|
||||
|
||||
test('logs failed event with error message', () => {
|
||||
const now = Date.now()
|
||||
const eventId = crypto.randomUUID()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, user_id, event_type, success, error_message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(eventId, 'user-789', 'user.login', 0, 'Invalid password', now)
|
||||
|
||||
const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get(eventId)
|
||||
|
||||
expect(event.success).toBe(0)
|
||||
expect(event.error_message).toBe('Invalid password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Types', () => {
|
||||
test('logs authentication events', () => {
|
||||
const now = Date.now()
|
||||
const eventTypes = ['user.login', 'user.logout', 'user.login.failed', 'user.password.reset', 'user.password.change']
|
||||
|
||||
eventTypes.forEach((type, i) => {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(`auth-${i}`, type, now + i)
|
||||
})
|
||||
|
||||
const events = testDb.prepare('SELECT * FROM audit_log WHERE event_type LIKE ?').all('user.%')
|
||||
|
||||
expect(events.length).toBe(5)
|
||||
})
|
||||
|
||||
test('logs session events', () => {
|
||||
const now = Date.now()
|
||||
const eventTypes = ['session.create', 'session.refresh', 'session.destroy', 'session.expired']
|
||||
|
||||
eventTypes.forEach((type, i) => {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(`session-${i}`, type, now + i)
|
||||
})
|
||||
|
||||
const events = testDb.prepare('SELECT * FROM audit_log WHERE event_type LIKE ?').all('session.%')
|
||||
|
||||
expect(events.length).toBe(4)
|
||||
})
|
||||
|
||||
test('logs payment events', () => {
|
||||
const now = Date.now()
|
||||
const eventTypes = ['payment.created', 'payment.succeeded', 'payment.failed', 'subscription.created', 'subscription.cancelled']
|
||||
|
||||
eventTypes.forEach((type, i) => {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(`payment-${i}`, type, now + i)
|
||||
})
|
||||
|
||||
const events = testDb.prepare('SELECT * FROM audit_log WHERE event_type LIKE ?').all('payment.%')
|
||||
|
||||
expect(events.length).toBe(3)
|
||||
})
|
||||
|
||||
test('logs security events', () => {
|
||||
const now = Date.now()
|
||||
const eventTypes = ['security.2fa.enabled', 'security.2fa.disabled', 'security.token.blacklisted', 'security.suspicious.activity']
|
||||
|
||||
eventTypes.forEach((type, i) => {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(`security-${i}`, type, now + i)
|
||||
})
|
||||
|
||||
const events = testDb.prepare('SELECT * FROM audit_log WHERE event_type LIKE ?').all('security.%')
|
||||
|
||||
expect(events.length).toBe(4)
|
||||
})
|
||||
|
||||
test('logs admin events', () => {
|
||||
const now = Date.now()
|
||||
const eventTypes = ['admin.login', 'admin.user.updated', 'admin.plan.changed', 'admin.settings.updated']
|
||||
|
||||
eventTypes.forEach((type, i) => {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(`admin-${i}`, type, now + i)
|
||||
})
|
||||
|
||||
const events = testDb.prepare('SELECT * FROM audit_log WHERE event_type LIKE ?').all('admin.%')
|
||||
|
||||
expect(events.length).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Query Operations', () => {
|
||||
test('gets user audit log with pagination', () => {
|
||||
const now = Date.now()
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, user_id, event_type, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(`page-${i}`, 'user-page', `event.${i}`, now + i)
|
||||
}
|
||||
|
||||
const page1 = testDb.prepare(`
|
||||
SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 10 OFFSET 0
|
||||
`).all('user-page')
|
||||
|
||||
const page2 = testDb.prepare(`
|
||||
SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 10 OFFSET 10
|
||||
`).all('user-page')
|
||||
|
||||
expect(page1.length).toBe(10)
|
||||
expect(page2.length).toBe(10)
|
||||
expect(page1[0].created_at).toBeGreaterThan(page1[1].created_at)
|
||||
})
|
||||
|
||||
test('filters by event type', () => {
|
||||
const now = Date.now()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, user_id, event_type, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('filter-1', 'user-filter', 'user.login', now)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, user_id, event_type, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('filter-2', 'user-filter', 'user.logout', now + 1)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, user_id, event_type, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('filter-3', 'user-filter', 'user.login', now + 2)
|
||||
|
||||
const loginEvents = testDb.prepare(`
|
||||
SELECT * FROM audit_log WHERE user_id = ? AND event_type = ? ORDER BY created_at DESC
|
||||
`).all('user-filter', 'user.login')
|
||||
|
||||
expect(loginEvents.length).toBe(2)
|
||||
})
|
||||
|
||||
test('gets recent audit events', () => {
|
||||
const now = Date.now()
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(`recent-${i}`, `event.${i}`, now + i)
|
||||
}
|
||||
|
||||
const recent = testDb.prepare(`
|
||||
SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 20
|
||||
`).all()
|
||||
|
||||
expect(recent.length).toBe(20)
|
||||
expect(recent[0].created_at).toBeGreaterThan(recent[19].created_at)
|
||||
})
|
||||
|
||||
test('gets recent events filtered by type', () => {
|
||||
const now = Date.now()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(`type-a-${i}`, 'typeA', now + i)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(`type-b-${i}`, 'typeB', now + 100 + i)
|
||||
}
|
||||
|
||||
const typeAEvents = testDb.prepare(`
|
||||
SELECT * FROM audit_log WHERE event_type = ? ORDER BY created_at DESC LIMIT 5
|
||||
`).all('typeA')
|
||||
|
||||
expect(typeAEvents.length).toBe(5)
|
||||
typeAEvents.forEach(e => expect(e.event_type).toBe('typeA'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Data Handling', () => {
|
||||
test('stores complex event data as JSON', () => {
|
||||
const now = Date.now()
|
||||
const eventData = {
|
||||
loginMethod: 'oauth',
|
||||
provider: 'google',
|
||||
deviceInfo: {
|
||||
browser: 'Chrome',
|
||||
os: 'Windows',
|
||||
device: 'Desktop'
|
||||
},
|
||||
location: {
|
||||
country: 'US',
|
||||
city: 'New York'
|
||||
}
|
||||
}
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, event_data, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('complex-1', 'user.login', JSON.stringify(eventData), now)
|
||||
|
||||
const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get('complex-1')
|
||||
const parsedData = JSON.parse(event.event_data)
|
||||
|
||||
expect(parsedData.loginMethod).toBe('oauth')
|
||||
expect(parsedData.deviceInfo.browser).toBe('Chrome')
|
||||
expect(parsedData.location.country).toBe('US')
|
||||
})
|
||||
|
||||
test('handles null event data', () => {
|
||||
const now = Date.now()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, event_data, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('null-data', 'user.logout', null, now)
|
||||
|
||||
const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get('null-data')
|
||||
|
||||
expect(event.event_data).toBeNull()
|
||||
})
|
||||
|
||||
test('handles empty event data', () => {
|
||||
const now = Date.now()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, event_data, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('empty-data', 'user.logout', '{}', now)
|
||||
|
||||
const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get('empty-data')
|
||||
const parsedData = JSON.parse(event.event_data)
|
||||
|
||||
expect(parsedData).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('IP and User Agent Tracking', () => {
|
||||
test('stores IP address', () => {
|
||||
const now = Date.now()
|
||||
const ipAddresses = ['192.168.1.1', '10.0.0.1', '172.16.0.1', '203.0.113.42', '2001:db8::1']
|
||||
|
||||
ipAddresses.forEach((ip, i) => {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, ip_address, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(`ip-${i}`, 'user.login', ip, now + i)
|
||||
})
|
||||
|
||||
const events = testDb.prepare('SELECT * FROM audit_log WHERE id LIKE ?').all('ip-%')
|
||||
|
||||
expect(events.length).toBe(5)
|
||||
expect(events[0].ip_address).toBe('192.168.1.1')
|
||||
expect(events[4].ip_address).toBe('2001:db8::1')
|
||||
})
|
||||
|
||||
test('stores user agent', () => {
|
||||
const now = Date.now()
|
||||
const userAgents = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15',
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148'
|
||||
]
|
||||
|
||||
userAgents.forEach((ua, i) => {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(`ua-${i}`, 'user.login', ua, now + i)
|
||||
})
|
||||
|
||||
const events = testDb.prepare('SELECT * FROM audit_log WHERE id LIKE ?').all('ua-%')
|
||||
|
||||
expect(events.length).toBe(3)
|
||||
expect(events[0].user_agent).toContain('Chrome')
|
||||
expect(events[1].user_agent).toContain('Safari')
|
||||
expect(events[2].user_agent).toContain('iPhone')
|
||||
})
|
||||
|
||||
test('detects login from new IP', () => {
|
||||
const now = Date.now()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, user_id, event_type, ip_address, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('new-ip-1', 'user-newip', 'user.login', '192.168.1.1', now)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, user_id, event_type, ip_address, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('new-ip-2', 'user-newip', 'user.login', '10.0.0.1', now + 1)
|
||||
|
||||
const uniqueIps = testDb.prepare(`
|
||||
SELECT DISTINCT ip_address FROM audit_log WHERE user_id = ?
|
||||
`).all('user-newip')
|
||||
|
||||
expect(uniqueIps.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Success/Failure Tracking', () => {
|
||||
test('tracks successful events', () => {
|
||||
const now = Date.now()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, success, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(`success-${i}`, 'user.login', 1, now + i)
|
||||
}
|
||||
|
||||
const successful = testDb.prepare(`
|
||||
SELECT COUNT(*) as count FROM audit_log WHERE event_type = ? AND success = 1
|
||||
`).get('user.login')
|
||||
|
||||
expect(successful.count).toBe(5)
|
||||
})
|
||||
|
||||
test('tracks failed events', () => {
|
||||
const now = Date.now()
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, success, error_message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(`fail-${i}`, 'user.login', 0, 'Invalid credentials', now + i)
|
||||
}
|
||||
|
||||
const failed = testDb.prepare(`
|
||||
SELECT COUNT(*) as count FROM audit_log WHERE event_type = ? AND success = 0
|
||||
`).get('user.login')
|
||||
|
||||
expect(failed.count).toBe(3)
|
||||
})
|
||||
|
||||
test('calculates success rate', () => {
|
||||
const now = Date.now()
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, success, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(`rate-${i}`, 'user.login', i < 6 ? 1 : 0, now + i)
|
||||
}
|
||||
|
||||
const stats = testDb.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful
|
||||
FROM audit_log WHERE event_type = ?
|
||||
`).get('user.login')
|
||||
|
||||
const successRate = stats.successful / stats.total
|
||||
expect(successRate).toBe(0.75)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time-based Queries', () => {
|
||||
test('queries events within time range', () => {
|
||||
const baseTime = Date.now()
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(`time-${i}`, 'user.login', baseTime + (i * 3600000))
|
||||
}
|
||||
|
||||
const hourAgo = baseTime + (20 * 3600000)
|
||||
const events = testDb.prepare(`
|
||||
SELECT * FROM audit_log WHERE created_at >= ? ORDER BY created_at DESC
|
||||
`).all(hourAgo)
|
||||
|
||||
expect(events.length).toBe(4)
|
||||
})
|
||||
|
||||
test('groups events by hour', () => {
|
||||
const baseTime = Date.now()
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO audit_log (id, event_type, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(`hour-${i}`, 'user.login', baseTime + (i * 1800000))
|
||||
}
|
||||
|
||||
const events = testDb.prepare('SELECT * FROM audit_log ORDER BY created_at').all()
|
||||
|
||||
expect(events.length).toBe(12)
|
||||
})
|
||||
})
|
||||
})
|
||||
539
chat/src/repositories/sessionRepository.test.js
Normal file
539
chat/src/repositories/sessionRepository.test.js
Normal file
@@ -0,0 +1,539 @@
|
||||
const { describe, test, expect, beforeEach, afterEach } = require('bun:test')
|
||||
const crypto = require('crypto')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
const testDbPath = path.join(__dirname, '.test-sessions.db')
|
||||
|
||||
let testDb
|
||||
|
||||
function createTestSchema(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
refresh_token_hash TEXT,
|
||||
device_fingerprint TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_accessed_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
device_fingerprint TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
used INTEGER DEFAULT 0,
|
||||
revoked INTEGER DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
used_at INTEGER
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS token_blacklist (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_jti TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
reason TEXT
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
describe('Session Repository', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(testDbPath)) {
|
||||
fs.unlinkSync(testDbPath)
|
||||
}
|
||||
|
||||
testDb = new Database(testDbPath)
|
||||
createTestSchema(testDb)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (testDb) {
|
||||
testDb.close()
|
||||
}
|
||||
if (fs.existsSync(testDbPath)) {
|
||||
fs.unlinkSync(testDbPath)
|
||||
}
|
||||
})
|
||||
|
||||
describe('Session CRUD Operations', () => {
|
||||
test('creates a session', () => {
|
||||
const now = Date.now()
|
||||
const sessionId = crypto.randomUUID()
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(sessionId, 'user-1', token, now + 3600000, now, now)
|
||||
|
||||
const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId)
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.user_id).toBe('user-1')
|
||||
expect(session.token).toBe(token)
|
||||
})
|
||||
|
||||
test('creates session with all metadata', () => {
|
||||
const now = Date.now()
|
||||
const sessionId = crypto.randomUUID()
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
const refreshTokenHash = crypto.randomBytes(64).toString('hex')
|
||||
const fingerprint = crypto.randomBytes(16).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, user_id, token, refresh_token_hash, device_fingerprint,
|
||||
ip_address, user_agent, expires_at, created_at, last_accessed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
sessionId, 'user-2', token, refreshTokenHash, fingerprint,
|
||||
'192.168.1.1', 'Mozilla/5.0', now + 3600000, now, now
|
||||
)
|
||||
|
||||
const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId)
|
||||
|
||||
expect(session.device_fingerprint).toBe(fingerprint)
|
||||
expect(session.ip_address).toBe('192.168.1.1')
|
||||
expect(session.user_agent).toBe('Mozilla/5.0')
|
||||
})
|
||||
|
||||
test('gets session by ID', () => {
|
||||
const now = Date.now()
|
||||
const sessionId = crypto.randomUUID()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(sessionId, 'user-3', 'token-3', now + 3600000, now, now)
|
||||
|
||||
const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId)
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.id).toBe(sessionId)
|
||||
})
|
||||
|
||||
test('gets session by token', () => {
|
||||
const now = Date.now()
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('session-4', 'user-4', token, now + 3600000, now, now)
|
||||
|
||||
const session = testDb.prepare('SELECT * FROM sessions WHERE token = ?').get(token)
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.id).toBe('session-4')
|
||||
})
|
||||
|
||||
test('gets all sessions for user', () => {
|
||||
const now = Date.now()
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(`session-${i}`, 'user-5', `token-5-${i}`, now + 3600000, now, now)
|
||||
}
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('session-other', 'user-other', 'token-other', now + 3600000, now, now)
|
||||
|
||||
const sessions = testDb.prepare(`
|
||||
SELECT * FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY last_accessed_at DESC
|
||||
`).all('user-5', now)
|
||||
|
||||
expect(sessions.length).toBe(3)
|
||||
})
|
||||
|
||||
test('excludes expired sessions', () => {
|
||||
const now = Date.now()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('session-active', 'user-6', 'token-active', now + 3600000, now, now)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('session-expired', 'user-6', 'token-expired', now - 1000, now, now)
|
||||
|
||||
const sessions = testDb.prepare(`
|
||||
SELECT * FROM sessions WHERE user_id = ? AND expires_at > ?
|
||||
`).all('user-6', now)
|
||||
|
||||
expect(sessions.length).toBe(1)
|
||||
expect(sessions[0].id).toBe('session-active')
|
||||
})
|
||||
|
||||
test('updates session last accessed time', () => {
|
||||
const now = Date.now()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('session-update', 'user-7', 'token-7', now + 3600000, now, now - 1000)
|
||||
|
||||
const newTime = Date.now()
|
||||
testDb.prepare('UPDATE sessions SET last_accessed_at = ? WHERE id = ?')
|
||||
.run(newTime, 'session-update')
|
||||
|
||||
const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get('session-update')
|
||||
|
||||
expect(session.last_accessed_at).toBe(newTime)
|
||||
})
|
||||
|
||||
test('deletes session (logout)', () => {
|
||||
const now = Date.now()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('session-delete', 'user-8', 'token-8', now + 3600000, now, now)
|
||||
|
||||
const result = testDb.prepare('DELETE FROM sessions WHERE id = ?').run('session-delete')
|
||||
|
||||
expect(result.changes).toBe(1)
|
||||
|
||||
const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get('session-delete')
|
||||
expect(session).toBeUndefined()
|
||||
})
|
||||
|
||||
test('deletes all sessions for user (logout all)', () => {
|
||||
const now = Date.now()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(`session-del-${i}`, 'user-9', `token-9-${i}`, now + 3600000, now, now)
|
||||
}
|
||||
|
||||
const result = testDb.prepare('DELETE FROM sessions WHERE user_id = ?').run('user-9')
|
||||
|
||||
expect(result.changes).toBe(5)
|
||||
})
|
||||
|
||||
test('cleans up expired sessions', () => {
|
||||
const now = Date.now()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('session-cleanup-1', 'user-10', 'token-10-1', now - 1000, now, now)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('session-cleanup-2', 'user-10', 'token-10-2', now + 3600000, now, now)
|
||||
|
||||
const result = testDb.prepare('DELETE FROM sessions WHERE expires_at <= ?').run(now)
|
||||
|
||||
expect(result.changes).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Refresh Token Operations', () => {
|
||||
test('creates refresh token', () => {
|
||||
const now = Date.now()
|
||||
const tokenId = crypto.randomUUID()
|
||||
const tokenHash = crypto.randomBytes(64).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(tokenId, 'user-rt-1', 'session-rt-1', tokenHash, now + 604800000, now)
|
||||
|
||||
const token = testDb.prepare('SELECT * FROM refresh_tokens WHERE id = ?').get(tokenId)
|
||||
|
||||
expect(token).toBeDefined()
|
||||
expect(token.user_id).toBe('user-rt-1')
|
||||
expect(token.used).toBe(0)
|
||||
expect(token.revoked).toBe(0)
|
||||
})
|
||||
|
||||
test('gets refresh token by hash', () => {
|
||||
const now = Date.now()
|
||||
const tokenHash = crypto.randomBytes(64).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('rt-2', 'user-rt-2', 'session-rt-2', tokenHash, now + 604800000, now)
|
||||
|
||||
const token = testDb.prepare(`
|
||||
SELECT * FROM refresh_tokens
|
||||
WHERE token_hash = ? AND used = 0 AND revoked = 0 AND expires_at > ?
|
||||
`).get(tokenHash, now)
|
||||
|
||||
expect(token).toBeDefined()
|
||||
expect(token.id).toBe('rt-2')
|
||||
})
|
||||
|
||||
test('excludes used refresh tokens', () => {
|
||||
const now = Date.now()
|
||||
const tokenHash = crypto.randomBytes(64).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at, used)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('rt-used', 'user-rt-3', 'session-rt-3', tokenHash, now + 604800000, now, 1)
|
||||
|
||||
const token = testDb.prepare(`
|
||||
SELECT * FROM refresh_tokens
|
||||
WHERE token_hash = ? AND used = 0 AND revoked = 0 AND expires_at > ?
|
||||
`).get(tokenHash, now)
|
||||
|
||||
expect(token).toBeUndefined()
|
||||
})
|
||||
|
||||
test('excludes revoked refresh tokens', () => {
|
||||
const now = Date.now()
|
||||
const tokenHash = crypto.randomBytes(64).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at, revoked)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('rt-revoked', 'user-rt-4', 'session-rt-4', tokenHash, now + 604800000, now, 1)
|
||||
|
||||
const token = testDb.prepare(`
|
||||
SELECT * FROM refresh_tokens
|
||||
WHERE token_hash = ? AND used = 0 AND revoked = 0 AND expires_at > ?
|
||||
`).get(tokenHash, now)
|
||||
|
||||
expect(token).toBeUndefined()
|
||||
})
|
||||
|
||||
test('excludes expired refresh tokens', () => {
|
||||
const now = Date.now()
|
||||
const tokenHash = crypto.randomBytes(64).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('rt-expired', 'user-rt-5', 'session-rt-5', tokenHash, now - 1000, now)
|
||||
|
||||
const token = testDb.prepare(`
|
||||
SELECT * FROM refresh_tokens
|
||||
WHERE token_hash = ? AND used = 0 AND revoked = 0 AND expires_at > ?
|
||||
`).get(tokenHash, now)
|
||||
|
||||
expect(token).toBeUndefined()
|
||||
})
|
||||
|
||||
test('marks refresh token as used', () => {
|
||||
const now = Date.now()
|
||||
const tokenHash = crypto.randomBytes(64).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('rt-mark-used', 'user-rt-6', 'session-rt-6', tokenHash, now + 604800000, now)
|
||||
|
||||
const usedAt = Date.now()
|
||||
testDb.prepare('UPDATE refresh_tokens SET used = 1, used_at = ? WHERE id = ?')
|
||||
.run(usedAt, 'rt-mark-used')
|
||||
|
||||
const token = testDb.prepare('SELECT * FROM refresh_tokens WHERE id = ?').get('rt-mark-used')
|
||||
|
||||
expect(token.used).toBe(1)
|
||||
expect(token.used_at).toBe(usedAt)
|
||||
})
|
||||
|
||||
test('revokes refresh token', () => {
|
||||
const now = Date.now()
|
||||
const tokenHash = crypto.randomBytes(64).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('rt-revoke', 'user-rt-7', 'session-rt-7', tokenHash, now + 604800000, now)
|
||||
|
||||
const result = testDb.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE id = ?').run('rt-revoke')
|
||||
|
||||
expect(result.changes).toBe(1)
|
||||
|
||||
const token = testDb.prepare('SELECT * FROM refresh_tokens WHERE id = ?').get('rt-revoke')
|
||||
expect(token.revoked).toBe(1)
|
||||
})
|
||||
|
||||
test('revokes all session refresh tokens', () => {
|
||||
const now = Date.now()
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(`rt-session-${i}`, 'user-rt-8', 'session-rt-8', `hash-${i}`, now + 604800000, now)
|
||||
}
|
||||
|
||||
const result = testDb.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE session_id = ?').run('session-rt-8')
|
||||
|
||||
expect(result.changes).toBe(3)
|
||||
})
|
||||
|
||||
test('revokes all user refresh tokens', () => {
|
||||
const now = Date.now()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(`rt-user-${i}`, 'user-rt-9', `session-${i}`, `hash-user-${i}`, now + 604800000, now)
|
||||
}
|
||||
|
||||
const result = testDb.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?').run('user-rt-9')
|
||||
|
||||
expect(result.changes).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Token Blacklist', () => {
|
||||
test('adds token to blacklist', () => {
|
||||
const now = Date.now()
|
||||
const jti = crypto.randomUUID()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(crypto.randomUUID(), jti, 'user-bl-1', now + 3600000, now, 'logout')
|
||||
|
||||
const entry = testDb.prepare('SELECT * FROM token_blacklist WHERE token_jti = ?').get(jti)
|
||||
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry.reason).toBe('logout')
|
||||
})
|
||||
|
||||
test('checks if token is blacklisted', () => {
|
||||
const now = Date.now()
|
||||
const jti = crypto.randomUUID()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(crypto.randomUUID(), jti, 'user-bl-2', now + 3600000, now)
|
||||
|
||||
const result = testDb.prepare('SELECT COUNT(*) as count FROM token_blacklist WHERE token_jti = ?').get(jti)
|
||||
|
||||
expect(result.count).toBe(1)
|
||||
})
|
||||
|
||||
test('token not blacklisted returns false', () => {
|
||||
const result = testDb.prepare('SELECT COUNT(*) as count FROM token_blacklist WHERE token_jti = ?').get('non-existent-jti')
|
||||
|
||||
expect(result.count).toBe(0)
|
||||
})
|
||||
|
||||
test('cleans up expired blacklist entries', () => {
|
||||
const now = Date.now()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(crypto.randomUUID(), 'jti-expired', 'user-bl-3', now - 1000, now)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(crypto.randomUUID(), 'jti-active', 'user-bl-3', now + 3600000, now)
|
||||
|
||||
const result = testDb.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(now)
|
||||
|
||||
expect(result.changes).toBe(1)
|
||||
|
||||
const active = testDb.prepare('SELECT * FROM token_blacklist WHERE token_jti = ?').get('jti-active')
|
||||
expect(active).toBeDefined()
|
||||
})
|
||||
|
||||
test('blacklist with different reasons', () => {
|
||||
const now = Date.now()
|
||||
const reasons = ['logout', 'password_change', 'security_breach', 'token_refresh']
|
||||
|
||||
reasons.forEach((reason, i) => {
|
||||
testDb.prepare(`
|
||||
INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(crypto.randomUUID(), `jti-${i}`, `user-bl-4-${i}`, now + 3600000, now, reason)
|
||||
})
|
||||
|
||||
const entries = testDb.prepare('SELECT * FROM token_blacklist WHERE user_id LIKE ?').all('user-bl-4-%')
|
||||
|
||||
expect(entries.length).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session Security', () => {
|
||||
test('enforces unique session tokens', () => {
|
||||
const now = Date.now()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('session-unique-1', 'user-sec-1', 'unique-token', now + 3600000, now, now)
|
||||
|
||||
expect(() => {
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('session-unique-2', 'user-sec-2', 'unique-token', now + 3600000, now, now)
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
test('stores device fingerprint', () => {
|
||||
const now = Date.now()
|
||||
const fingerprint = crypto.randomBytes(16).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, device_fingerprint, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('session-fp', 'user-sec-3', 'token-fp', fingerprint, now + 3600000, now, now)
|
||||
|
||||
const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get('session-fp')
|
||||
|
||||
expect(session.device_fingerprint).toBe(fingerprint)
|
||||
})
|
||||
|
||||
test('detects session from different device', () => {
|
||||
const now = Date.now()
|
||||
const fp1 = crypto.randomBytes(16).toString('hex')
|
||||
const fp2 = crypto.randomBytes(16).toString('hex')
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, device_fingerprint, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('session-dev-1', 'user-sec-4', 'token-dev-1', fp1, now + 3600000, now, now)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO sessions (id, user_id, token, device_fingerprint, expires_at, created_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('session-dev-2', 'user-sec-4', 'token-dev-2', fp2, now + 3600000, now, now)
|
||||
|
||||
const sessions = testDb.prepare('SELECT * FROM sessions WHERE user_id = ?').all('user-sec-4')
|
||||
|
||||
expect(sessions.length).toBe(2)
|
||||
expect(sessions[0].device_fingerprint).not.toBe(sessions[1].device_fingerprint)
|
||||
})
|
||||
})
|
||||
})
|
||||
457
chat/src/repositories/userRepository.test.js
Normal file
457
chat/src/repositories/userRepository.test.js
Normal file
@@ -0,0 +1,457 @@
|
||||
const { describe, test, expect, beforeEach, afterEach } = require('bun:test')
|
||||
const crypto = require('crypto')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const Database = require('better-sqlite3')
|
||||
const { initEncryption } = require('./encryption')
|
||||
|
||||
const testDbPath = path.join(__dirname, '.test-users.db')
|
||||
const testMasterKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
|
||||
let testDb
|
||||
let userRepository
|
||||
|
||||
function createTestSchema(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_encrypted TEXT,
|
||||
name TEXT,
|
||||
name_encrypted TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
providers TEXT DEFAULT '[]',
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verification_token TEXT,
|
||||
verification_expires_at INTEGER,
|
||||
reset_token TEXT,
|
||||
reset_expires_at INTEGER,
|
||||
plan TEXT DEFAULT 'hobby',
|
||||
billing_status TEXT DEFAULT 'active',
|
||||
billing_email TEXT,
|
||||
payment_method_last4 TEXT,
|
||||
subscription_renews_at INTEGER,
|
||||
referred_by_affiliate_code TEXT,
|
||||
affiliate_attribution_at INTEGER,
|
||||
affiliate_payouts TEXT DEFAULT '[]',
|
||||
two_factor_secret TEXT,
|
||||
two_factor_enabled INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
describe('User Repository', () => {
|
||||
beforeEach(async () => {
|
||||
initEncryption(testMasterKey)
|
||||
|
||||
if (fs.existsSync(testDbPath)) {
|
||||
fs.unlinkSync(testDbPath)
|
||||
}
|
||||
|
||||
testDb = new Database(testDbPath)
|
||||
createTestSchema(testDb)
|
||||
|
||||
const { getDatabase } = require('../database/connection')
|
||||
const originalGetDatabase = require('../database/connection').getDatabase
|
||||
|
||||
const mockConnection = {
|
||||
getDatabase: () => testDb
|
||||
}
|
||||
|
||||
userRepository = require('./userRepository')
|
||||
|
||||
const originalModule = require.cache[require.resolve('./userRepository')]
|
||||
if (originalModule) {
|
||||
delete require.cache[require.resolve('./userRepository')]
|
||||
}
|
||||
|
||||
process.env.TEST_DB = testDbPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (testDb) {
|
||||
testDb.close()
|
||||
}
|
||||
if (fs.existsSync(testDbPath)) {
|
||||
fs.unlinkSync(testDbPath)
|
||||
}
|
||||
})
|
||||
|
||||
describe('User CRUD Operations', () => {
|
||||
test('creates a user with required fields', () => {
|
||||
const userData = {
|
||||
id: 'user-test-1',
|
||||
email: 'test@example.com',
|
||||
passwordHash: '$2b$12$hashedpassword123'
|
||||
}
|
||||
|
||||
const stmt = testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const now = Date.now()
|
||||
stmt.run(
|
||||
userData.id,
|
||||
userData.email,
|
||||
'encrypted-email',
|
||||
userData.passwordHash,
|
||||
'hobby',
|
||||
'active',
|
||||
'[]',
|
||||
now,
|
||||
now
|
||||
)
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get(userData.id)
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user.email).toBe(userData.email)
|
||||
expect(user.plan).toBe('hobby')
|
||||
expect(user.billing_status).toBe('active')
|
||||
})
|
||||
|
||||
test('creates user with all optional fields', () => {
|
||||
const now = Date.now()
|
||||
const userData = {
|
||||
id: 'user-test-2',
|
||||
email: 'full@example.com',
|
||||
name: 'Test User',
|
||||
passwordHash: '$2b$12$hashedpassword123',
|
||||
emailVerified: true,
|
||||
plan: 'professional',
|
||||
billingStatus: 'active',
|
||||
billingEmail: 'billing@example.com'
|
||||
}
|
||||
|
||||
const stmt = testDb.prepare(`
|
||||
INSERT INTO users (
|
||||
id, email, email_encrypted, name, name_encrypted, password_hash,
|
||||
email_verified, plan, billing_status, billing_email, providers, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
stmt.run(
|
||||
userData.id,
|
||||
userData.email,
|
||||
'encrypted-email',
|
||||
userData.name,
|
||||
'encrypted-name',
|
||||
userData.passwordHash,
|
||||
userData.emailVerified ? 1 : 0,
|
||||
userData.plan,
|
||||
userData.billingStatus,
|
||||
userData.billingEmail,
|
||||
'[]',
|
||||
now,
|
||||
now
|
||||
)
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get(userData.id)
|
||||
|
||||
expect(user.name).toBe(userData.name)
|
||||
expect(user.email_verified).toBe(1)
|
||||
expect(user.plan).toBe('professional')
|
||||
expect(user.billing_email).toBe(userData.billingEmail)
|
||||
})
|
||||
|
||||
test('gets user by ID', () => {
|
||||
const now = Date.now()
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-get-1', 'get@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now)
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-get-1')
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user.id).toBe('user-get-1')
|
||||
expect(user.email).toBe('get@example.com')
|
||||
})
|
||||
|
||||
test('returns null for non-existent user', () => {
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('non-existent')
|
||||
expect(user).toBeUndefined()
|
||||
})
|
||||
|
||||
test('gets user by email', () => {
|
||||
const now = Date.now()
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-email-1', 'email@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now)
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE email = ?').get('email@example.com')
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user.id).toBe('user-email-1')
|
||||
})
|
||||
|
||||
test('updates user plan', () => {
|
||||
const now = Date.now()
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-update-1', 'update@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now)
|
||||
|
||||
testDb.prepare('UPDATE users SET plan = ?, updated_at = ? WHERE id = ?')
|
||||
.run('professional', Date.now(), 'user-update-1')
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-update-1')
|
||||
|
||||
expect(user.plan).toBe('professional')
|
||||
})
|
||||
|
||||
test('updates user email verification status', () => {
|
||||
const now = Date.now()
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-verify-1', 'verify@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now)
|
||||
|
||||
testDb.prepare('UPDATE users SET email_verified = ?, updated_at = ? WHERE id = ?')
|
||||
.run(1, Date.now(), 'user-verify-1')
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-verify-1')
|
||||
|
||||
expect(user.email_verified).toBe(1)
|
||||
})
|
||||
|
||||
test('deletes user', () => {
|
||||
const now = Date.now()
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-delete-1', 'delete@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now)
|
||||
|
||||
const result = testDb.prepare('DELETE FROM users WHERE id = ?').run('user-delete-1')
|
||||
|
||||
expect(result.changes).toBe(1)
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-delete-1')
|
||||
expect(user).toBeUndefined()
|
||||
})
|
||||
|
||||
test('gets all users with pagination', () => {
|
||||
const now = Date.now()
|
||||
for (let i = 0; i < 15; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(`user-page-${i}`, `page${i}@example.com`, 'encrypted', 'hash', 'hobby', 'active', '[]', now + i, now + i)
|
||||
}
|
||||
|
||||
const page1 = testDb.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT 10 OFFSET 0').all()
|
||||
const page2 = testDb.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT 10 OFFSET 10').all()
|
||||
|
||||
expect(page1.length).toBe(10)
|
||||
expect(page2.length).toBe(5)
|
||||
})
|
||||
|
||||
test('counts total users', () => {
|
||||
const now = Date.now()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(`user-count-${i}`, `count${i}@example.com`, 'encrypted', 'hash', 'hobby', 'active', '[]', now, now)
|
||||
}
|
||||
|
||||
const result = testDb.prepare('SELECT COUNT(*) as count FROM users').get()
|
||||
|
||||
expect(result.count).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Authentication Fields', () => {
|
||||
test('sets verification token', () => {
|
||||
const now = Date.now()
|
||||
const token = crypto.randomUUID()
|
||||
const expiresAt = now + (24 * 60 * 60 * 1000)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-token-1', 'token@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now)
|
||||
|
||||
testDb.prepare('UPDATE users SET verification_token = ?, verification_expires_at = ? WHERE id = ?')
|
||||
.run(token, expiresAt, 'user-token-1')
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE verification_token = ?').get(token)
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user.verification_token).toBe(token)
|
||||
})
|
||||
|
||||
test('sets password reset token', () => {
|
||||
const now = Date.now()
|
||||
const token = crypto.randomUUID()
|
||||
const expiresAt = now + (60 * 60 * 1000)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-reset-1', 'reset@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now)
|
||||
|
||||
testDb.prepare('UPDATE users SET reset_token = ?, reset_expires_at = ? WHERE id = ?')
|
||||
.run(token, expiresAt, 'user-reset-1')
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE reset_token = ?').get(token)
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user.reset_token).toBe(token)
|
||||
})
|
||||
|
||||
test('clears verification token after verification', () => {
|
||||
const now = Date.now()
|
||||
const token = crypto.randomUUID()
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at, verification_token, verification_expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-clear-1', 'clear@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now, token, now + 86400000)
|
||||
|
||||
testDb.prepare('UPDATE users SET verification_token = NULL, verification_expires_at = NULL, email_verified = 1 WHERE id = ?')
|
||||
.run('user-clear-1')
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-clear-1')
|
||||
|
||||
expect(user.verification_token).toBeNull()
|
||||
expect(user.email_verified).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Plan Management', () => {
|
||||
test('enforces valid plan values', () => {
|
||||
const validPlans = ['hobby', 'starter', 'professional', 'enterprise']
|
||||
|
||||
validPlans.forEach(plan => {
|
||||
expect(['hobby', 'starter', 'professional', 'enterprise'].includes(plan)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test('updates billing status', () => {
|
||||
const now = Date.now()
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-billing-1', 'billing@example.com', 'encrypted', 'hash', 'professional', 'active', '[]', now, now)
|
||||
|
||||
testDb.prepare('UPDATE users SET billing_status = ? WHERE id = ?')
|
||||
.run('past_due', 'user-billing-1')
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-billing-1')
|
||||
|
||||
expect(user.billing_status).toBe('past_due')
|
||||
})
|
||||
|
||||
test('sets subscription renewal date', () => {
|
||||
const now = Date.now()
|
||||
const renewsAt = now + (30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-renew-1', 'renew@example.com', 'encrypted', 'hash', 'professional', 'active', '[]', now, now)
|
||||
|
||||
testDb.prepare('UPDATE users SET subscription_renews_at = ? WHERE id = ?')
|
||||
.run(renewsAt, 'user-renew-1')
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-renew-1')
|
||||
|
||||
expect(user.subscription_renews_at).toBe(renewsAt)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User OAuth Providers', () => {
|
||||
test('stores multiple providers', () => {
|
||||
const now = Date.now()
|
||||
const providers = ['google', 'github']
|
||||
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-oauth-1', 'oauth@example.com', 'encrypted', 'hash', 'hobby', 'active', JSON.stringify(providers), now, now)
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-oauth-1')
|
||||
const parsedProviders = JSON.parse(user.providers)
|
||||
|
||||
expect(parsedProviders).toContain('google')
|
||||
expect(parsedProviders).toContain('github')
|
||||
})
|
||||
|
||||
test('adds provider to existing user', () => {
|
||||
const now = Date.now()
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-add-oauth-1', 'add-oauth@example.com', 'encrypted', 'hash', 'hobby', 'active', '["google"]', now, now)
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-add-oauth-1')
|
||||
const providers = JSON.parse(user.providers)
|
||||
providers.push('github')
|
||||
|
||||
testDb.prepare('UPDATE users SET providers = ? WHERE id = ?')
|
||||
.run(JSON.stringify(providers), 'user-add-oauth-1')
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-add-oauth-1')
|
||||
expect(JSON.parse(updated.providers)).toContain('github')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Two-Factor Authentication', () => {
|
||||
test('enables 2FA', () => {
|
||||
const now = Date.now()
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-2fa-1', '2fa@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now)
|
||||
|
||||
testDb.prepare('UPDATE users SET two_factor_enabled = 1, two_factor_secret = ? WHERE id = ?')
|
||||
.run('encrypted-secret', 'user-2fa-1')
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-2fa-1')
|
||||
|
||||
expect(user.two_factor_enabled).toBe(1)
|
||||
expect(user.two_factor_secret).toBe('encrypted-secret')
|
||||
})
|
||||
|
||||
test('disables 2FA', () => {
|
||||
const now = Date.now()
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at, two_factor_enabled, two_factor_secret)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-2fa-disable-1', '2fa-disable@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now, 1, 'encrypted-secret')
|
||||
|
||||
testDb.prepare('UPDATE users SET two_factor_enabled = 0, two_factor_secret = NULL WHERE id = ?')
|
||||
.run('user-2fa-disable-1')
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-2fa-disable-1')
|
||||
|
||||
expect(user.two_factor_enabled).toBe(0)
|
||||
expect(user.two_factor_secret).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Affiliate Attribution', () => {
|
||||
test('sets affiliate referral code', () => {
|
||||
const now = Date.now()
|
||||
testDb.prepare(`
|
||||
INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('user-aff-1', 'aff@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now)
|
||||
|
||||
testDb.prepare('UPDATE users SET referred_by_affiliate_code = ?, affiliate_attribution_at = ? WHERE id = ?')
|
||||
.run('AFFILIATE123', now, 'user-aff-1')
|
||||
|
||||
const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-aff-1')
|
||||
|
||||
expect(user.referred_by_affiliate_code).toBe('AFFILIATE123')
|
||||
expect(user.affiliate_attribution_at).toBe(now)
|
||||
})
|
||||
})
|
||||
})
|
||||
768
chat/src/tests/api-endpoints.test.js
Normal file
768
chat/src/tests/api-endpoints.test.js
Normal file
@@ -0,0 +1,768 @@
|
||||
const { describe, test, expect, beforeEach, afterEach } = require('bun:test')
|
||||
const crypto = require('crypto')
|
||||
const bcrypt = require('bcrypt')
|
||||
|
||||
const VALID_PLANS = ['hobby', 'starter', 'professional', 'enterprise']
|
||||
const PAID_PLANS = new Set(['starter', 'professional', 'enterprise'])
|
||||
const PASSWORD_SALT_ROUNDS = 12
|
||||
const USER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||
const ACCESS_TOKEN_TTL = 15 * 60
|
||||
const REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60
|
||||
|
||||
function isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
function isValidPassword(password) {
|
||||
return password && password.length >= 8
|
||||
}
|
||||
|
||||
function normalizePlan(plan) {
|
||||
return VALID_PLANS.includes(plan) ? plan : 'hobby'
|
||||
}
|
||||
|
||||
function isPaidPlan(plan) {
|
||||
return PAID_PLANS.has(plan)
|
||||
}
|
||||
|
||||
function generateAccessToken(payload, secret) {
|
||||
const jwt = require('jsonwebtoken')
|
||||
return jwt.sign({
|
||||
...payload,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL
|
||||
}, secret, { algorithm: 'HS256' })
|
||||
}
|
||||
|
||||
function verifyAccessToken(token, secret) {
|
||||
const jwt = require('jsonwebtoken')
|
||||
try {
|
||||
return jwt.verify(token, secret, { algorithms: ['HS256'] })
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
const cookies = {}
|
||||
if (!cookieHeader) return cookies
|
||||
|
||||
cookieHeader.split(';').forEach(cookie => {
|
||||
const [name, ...rest] = cookie.split('=')
|
||||
if (name && rest.length > 0) {
|
||||
cookies[name.trim()] = rest.join('=').trim()
|
||||
}
|
||||
})
|
||||
|
||||
return cookies
|
||||
}
|
||||
|
||||
function createMockRequest(options = {}) {
|
||||
return {
|
||||
method: options.method || 'GET',
|
||||
url: options.url || '/',
|
||||
headers: options.headers || {},
|
||||
body: options.body || {},
|
||||
ip: options.ip || '127.0.0.1',
|
||||
connection: { remoteAddress: options.ip || '127.0.0.1' }
|
||||
}
|
||||
}
|
||||
|
||||
function createMockResponse() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code
|
||||
return this
|
||||
},
|
||||
json(data) {
|
||||
this.body = data
|
||||
return this
|
||||
},
|
||||
setHeader(name, value) {
|
||||
this.headers[name] = value
|
||||
return this
|
||||
},
|
||||
end(data) {
|
||||
this.body = data
|
||||
return this
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
describe('API Endpoints', () => {
|
||||
describe('Authentication Endpoints', () => {
|
||||
const testSecret = 'test-jwt-secret-key-for-testing'
|
||||
|
||||
describe('POST /api/auth/register', () => {
|
||||
test('validates email format', () => {
|
||||
expect(isValidEmail('test@example.com')).toBe(true)
|
||||
expect(isValidEmail('user.name@domain.co.uk')).toBe(true)
|
||||
expect(isValidEmail('invalid')).toBe(false)
|
||||
expect(isValidEmail('no@domain')).toBe(false)
|
||||
expect(isValidEmail('@nodomain.com')).toBe(false)
|
||||
expect(isValidEmail('')).toBe(false)
|
||||
})
|
||||
|
||||
test('validates password strength', () => {
|
||||
expect(isValidPassword('password123')).toBe(true)
|
||||
expect(isValidPassword('short')).toBe(false)
|
||||
expect(isValidPassword('')).toBe(false)
|
||||
expect(isValidPassword(null)).toBe(false)
|
||||
})
|
||||
|
||||
test('hashes password with bcrypt', async () => {
|
||||
const password = 'testPassword123'
|
||||
const hash = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS)
|
||||
|
||||
expect(hash).toBeDefined()
|
||||
expect(hash).not.toBe(password)
|
||||
expect(await bcrypt.compare(password, hash)).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects duplicate email', async () => {
|
||||
const users = [{ email: 'existing@example.com' }]
|
||||
const newEmail = 'existing@example.com'
|
||||
|
||||
const exists = users.some(u => u.email === newEmail)
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('creates user with default plan', () => {
|
||||
const userData = {
|
||||
id: 'user-1',
|
||||
email: 'new@example.com',
|
||||
plan: 'hobby'
|
||||
}
|
||||
|
||||
expect(normalizePlan(userData.plan)).toBe('hobby')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
test('validates credentials', async () => {
|
||||
const password = 'correctPassword'
|
||||
const hash = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS)
|
||||
|
||||
expect(await bcrypt.compare('correctPassword', hash)).toBe(true)
|
||||
expect(await bcrypt.compare('wrongPassword', hash)).toBe(false)
|
||||
})
|
||||
|
||||
test('generates access token', () => {
|
||||
const payload = { userId: 'user-1', email: 'test@example.com' }
|
||||
const token = generateAccessToken(payload, testSecret)
|
||||
|
||||
expect(token).toBeDefined()
|
||||
expect(token.split('.').length).toBe(3)
|
||||
})
|
||||
|
||||
test('token contains user data', () => {
|
||||
const payload = { userId: 'user-1', email: 'test@example.com', plan: 'professional' }
|
||||
const token = generateAccessToken(payload, testSecret)
|
||||
const decoded = verifyAccessToken(token, testSecret)
|
||||
|
||||
expect(decoded.userId).toBe('user-1')
|
||||
expect(decoded.email).toBe('test@example.com')
|
||||
expect(decoded.plan).toBe('professional')
|
||||
})
|
||||
|
||||
test('rejects invalid token', () => {
|
||||
const decoded = verifyAccessToken('invalid-token', testSecret)
|
||||
expect(decoded).toBeNull()
|
||||
})
|
||||
|
||||
test('tracks failed login attempts', () => {
|
||||
const attempts = new Map()
|
||||
const ip = '192.168.1.1'
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const current = attempts.get(ip) || 0
|
||||
attempts.set(ip, current + 1)
|
||||
}
|
||||
|
||||
expect(attempts.get(ip)).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/logout', () => {
|
||||
test('clears session cookie', () => {
|
||||
const res = createMockResponse()
|
||||
res.setHeader('Set-Cookie', 'user_session=; Path=/; HttpOnly; Max-Age=0')
|
||||
|
||||
expect(res.headers['Set-Cookie']).toContain('Max-Age=0')
|
||||
})
|
||||
|
||||
test('blacklists access token', () => {
|
||||
const blacklist = new Set()
|
||||
const jti = crypto.randomUUID()
|
||||
|
||||
blacklist.add(jti)
|
||||
|
||||
expect(blacklist.has(jti)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/refresh', () => {
|
||||
test('validates refresh token', () => {
|
||||
const storedHash = 'salt:hash'
|
||||
const token = 'test-refresh-token'
|
||||
|
||||
expect(storedHash.split(':').length).toBe(2)
|
||||
})
|
||||
|
||||
test('generates new access token', () => {
|
||||
const payload = { userId: 'user-1', email: 'test@example.com' }
|
||||
const token = generateAccessToken(payload, testSecret)
|
||||
const decoded = verifyAccessToken(token, testSecret)
|
||||
|
||||
expect(decoded.userId).toBe('user-1')
|
||||
})
|
||||
|
||||
test('rotates refresh token', () => {
|
||||
const oldToken = crypto.randomBytes(64).toString('hex')
|
||||
const newToken = crypto.randomBytes(64).toString('hex')
|
||||
|
||||
expect(oldToken).not.toBe(newToken)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/forgot-password', () => {
|
||||
test('generates reset token', () => {
|
||||
const resetToken = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
expect(resetToken.length).toBe(64)
|
||||
})
|
||||
|
||||
test('sets reset expiry', () => {
|
||||
const now = Date.now()
|
||||
const expiryMs = 60 * 60 * 1000
|
||||
const expiresAt = now + expiryMs
|
||||
|
||||
expect(expiresAt).toBeGreaterThan(now)
|
||||
})
|
||||
|
||||
test('does not reveal if email exists', () => {
|
||||
const response = { message: 'If the email exists, a reset link has been sent' }
|
||||
|
||||
expect(response.message).not.toContain('not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/reset-password', () => {
|
||||
test('validates reset token', () => {
|
||||
const tokens = new Map()
|
||||
tokens.set('valid-token', { userId: 'user-1', expiresAt: Date.now() + 3600000 })
|
||||
|
||||
const tokenData = tokens.get('valid-token')
|
||||
expect(tokenData).toBeDefined()
|
||||
})
|
||||
|
||||
test('rejects expired token', () => {
|
||||
const tokens = new Map()
|
||||
tokens.set('expired-token', { userId: 'user-1', expiresAt: Date.now() - 1000 })
|
||||
|
||||
const tokenData = tokens.get('expired-token')
|
||||
const isValid = tokenData && tokenData.expiresAt > Date.now()
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
test('hashes new password', async () => {
|
||||
const newPassword = 'newPassword123'
|
||||
const hash = await bcrypt.hash(newPassword, PASSWORD_SALT_ROUNDS)
|
||||
|
||||
expect(await bcrypt.compare(newPassword, hash)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Endpoints', () => {
|
||||
describe('GET /api/user/profile', () => {
|
||||
test('returns user data without sensitive fields', () => {
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
plan: 'professional',
|
||||
passwordHash: '$2b$12$hash',
|
||||
twoFactorSecret: 'secret'
|
||||
}
|
||||
|
||||
const safeUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
plan: user.plan
|
||||
}
|
||||
|
||||
expect(safeUser.passwordHash).toBeUndefined()
|
||||
expect(safeUser.twoFactorSecret).toBeUndefined()
|
||||
})
|
||||
|
||||
test('requires authentication', () => {
|
||||
const req = createMockRequest({ headers: {} })
|
||||
const token = req.headers.authorization
|
||||
|
||||
expect(token).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/user/profile', () => {
|
||||
test('updates allowed fields', () => {
|
||||
const updates = { name: 'New Name' }
|
||||
const allowedFields = ['name', 'email']
|
||||
|
||||
const filteredUpdates = {}
|
||||
Object.keys(updates).forEach(key => {
|
||||
if (allowedFields.includes(key)) {
|
||||
filteredUpdates[key] = updates[key]
|
||||
}
|
||||
})
|
||||
|
||||
expect(filteredUpdates.name).toBe('New Name')
|
||||
})
|
||||
|
||||
test('ignores protected fields', () => {
|
||||
const updates = { name: 'New Name', plan: 'enterprise', passwordHash: 'hash' }
|
||||
const allowedFields = ['name', 'email']
|
||||
|
||||
const filteredUpdates = {}
|
||||
Object.keys(updates).forEach(key => {
|
||||
if (allowedFields.includes(key)) {
|
||||
filteredUpdates[key] = updates[key]
|
||||
}
|
||||
})
|
||||
|
||||
expect(filteredUpdates.plan).toBeUndefined()
|
||||
expect(filteredUpdates.passwordHash).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/user/usage', () => {
|
||||
test('returns token usage', () => {
|
||||
const usage = {
|
||||
used: 50000,
|
||||
limit: 100000,
|
||||
remaining: 50000
|
||||
}
|
||||
|
||||
expect(usage.remaining).toBe(usage.limit - usage.used)
|
||||
})
|
||||
|
||||
test('calculates percentage used', () => {
|
||||
const used = 50000
|
||||
const limit = 100000
|
||||
const percentage = (used / limit) * 100
|
||||
|
||||
expect(percentage).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/user/sessions', () => {
|
||||
test('lists active sessions', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-1', device: 'Chrome on Windows', lastAccessed: Date.now() },
|
||||
{ id: 'session-2', device: 'Safari on iPhone', lastAccessed: Date.now() - 3600000 }
|
||||
]
|
||||
|
||||
expect(sessions.length).toBe(2)
|
||||
})
|
||||
|
||||
test('excludes session tokens', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-1', token: 'secret-token', device: 'Chrome' }
|
||||
]
|
||||
|
||||
const safeSessions = sessions.map(s => ({ id: s.id, device: s.device }))
|
||||
|
||||
expect(safeSessions[0].token).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/user/sessions/:id', () => {
|
||||
test('revokes specific session', () => {
|
||||
const sessions = new Set(['session-1', 'session-2'])
|
||||
const toDelete = 'session-1'
|
||||
|
||||
sessions.delete(toDelete)
|
||||
|
||||
expect(sessions.has(toDelete)).toBe(false)
|
||||
expect(sessions.size).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Plan Endpoints', () => {
|
||||
describe('GET /api/plans', () => {
|
||||
test('returns all plans', () => {
|
||||
const plans = VALID_PLANS.map(plan => ({
|
||||
name: plan,
|
||||
tokenLimit: { hobby: 50000, starter: 100000, professional: 5000000, enterprise: 20000000 }[plan],
|
||||
appLimit: { hobby: 3, starter: 10, professional: 20, enterprise: Infinity }[plan],
|
||||
price: { hobby: 0, starter: 750, professional: 2500, enterprise: 7500 }[plan]
|
||||
}))
|
||||
|
||||
expect(plans.length).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/subscription', () => {
|
||||
test('validates plan selection', () => {
|
||||
expect(normalizePlan('professional')).toBe('professional')
|
||||
expect(normalizePlan('invalid')).toBe('hobby')
|
||||
})
|
||||
|
||||
test('validates billing cycle', () => {
|
||||
const cycles = ['monthly', 'yearly']
|
||||
|
||||
expect(cycles.includes('monthly')).toBe(true)
|
||||
expect(cycles.includes('yearly')).toBe(true)
|
||||
expect(cycles.includes('weekly')).toBe(false)
|
||||
})
|
||||
|
||||
test('calculates subscription price', () => {
|
||||
const prices = {
|
||||
starter_monthly_usd: 750,
|
||||
starter_yearly_usd: 7500
|
||||
}
|
||||
|
||||
expect(prices.starter_yearly_usd).toBeLessThan(prices.starter_monthly_usd * 12)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin Endpoints', () => {
|
||||
const adminSession = new Map()
|
||||
|
||||
describe('POST /api/admin/login', () => {
|
||||
test('validates admin credentials', () => {
|
||||
const adminUser = 'admin@example.com'
|
||||
const adminPassword = 'adminPassword123'
|
||||
|
||||
const credentials = { email: adminUser, password: adminPassword }
|
||||
|
||||
expect(credentials.email).toBe(adminUser)
|
||||
})
|
||||
|
||||
test('creates admin session', () => {
|
||||
const sessionId = crypto.randomUUID()
|
||||
adminSession.set(sessionId, { expiresAt: Date.now() + 86400000 })
|
||||
|
||||
expect(adminSession.has(sessionId)).toBe(true)
|
||||
})
|
||||
|
||||
test('rate limits failed attempts', () => {
|
||||
const attempts = { '192.168.1.1': 5 }
|
||||
const limit = 5
|
||||
|
||||
expect(attempts['192.168.1.1']).toBeGreaterThanOrEqual(limit)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/admin/users', () => {
|
||||
test('requires admin auth', () => {
|
||||
const req = createMockRequest({ headers: { cookie: '' } })
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
|
||||
expect(cookies.admin_session).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns paginated users', () => {
|
||||
const users = Array.from({ length: 25 }, (_, i) => ({ id: `user-${i}` }))
|
||||
const page = 1
|
||||
const limit = 10
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const paginatedUsers = users.slice(offset, offset + limit)
|
||||
|
||||
expect(paginatedUsers.length).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/admin/users/:id/plan', () => {
|
||||
test('updates user plan', () => {
|
||||
const user = { id: 'user-1', plan: 'hobby' }
|
||||
const newPlan = 'professional'
|
||||
|
||||
user.plan = normalizePlan(newPlan)
|
||||
|
||||
expect(user.plan).toBe('professional')
|
||||
})
|
||||
|
||||
test('rejects invalid plan', () => {
|
||||
const user = { id: 'user-1', plan: 'professional' }
|
||||
const invalidPlan = 'ultra'
|
||||
|
||||
user.plan = normalizePlan(invalidPlan)
|
||||
|
||||
expect(user.plan).toBe('hobby')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/admin/analytics', () => {
|
||||
test('returns usage statistics', () => {
|
||||
const stats = {
|
||||
totalUsers: 1000,
|
||||
activeUsers: 750,
|
||||
totalTokensUsed: 50000000,
|
||||
revenue: 150000
|
||||
}
|
||||
|
||||
expect(stats.activeUsers).toBeLessThanOrEqual(stats.totalUsers)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Middleware', () => {
|
||||
describe('Authentication Middleware', () => {
|
||||
test('extracts token from Authorization header', () => {
|
||||
const req = createMockRequest({
|
||||
headers: { authorization: 'Bearer test-token-123' }
|
||||
})
|
||||
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader && authHeader.startsWith('Bearer ')
|
||||
? authHeader.substring(7)
|
||||
: null
|
||||
|
||||
expect(token).toBe('test-token-123')
|
||||
})
|
||||
|
||||
test('extracts token from cookie', () => {
|
||||
const req = createMockRequest({
|
||||
headers: { cookie: 'access_token=cookie-token-456' }
|
||||
})
|
||||
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies.access_token || null
|
||||
|
||||
expect(token).toBe('cookie-token-456')
|
||||
})
|
||||
|
||||
test('prefers Authorization header over cookie', () => {
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer header-token',
|
||||
cookie: 'access_token=cookie-token'
|
||||
}
|
||||
})
|
||||
|
||||
const authHeader = req.headers.authorization
|
||||
const headerToken = authHeader && authHeader.startsWith('Bearer ')
|
||||
? authHeader.substring(7)
|
||||
: null
|
||||
|
||||
expect(headerToken).toBe('header-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rate Limiting Middleware', () => {
|
||||
const rateLimiter = {
|
||||
requests: new Map(),
|
||||
windowMs: 60000,
|
||||
maxRequests: 100,
|
||||
|
||||
check(key) {
|
||||
const now = Date.now()
|
||||
let record = this.requests.get(key)
|
||||
|
||||
if (!record || now - record.windowStart > this.windowMs) {
|
||||
record = { count: 0, windowStart: now }
|
||||
this.requests.set(key, record)
|
||||
}
|
||||
|
||||
record.count++
|
||||
return record.count <= this.maxRequests
|
||||
}
|
||||
}
|
||||
|
||||
test('allows requests within limit', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(rateLimiter.check('user-1')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('blocks requests over limit', () => {
|
||||
rateLimiter.requests.clear()
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
rateLimiter.check('user-2')
|
||||
}
|
||||
|
||||
expect(rateLimiter.check('user-2')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CORS Middleware', () => {
|
||||
test('sets CORS headers', () => {
|
||||
const res = createMockResponse()
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
|
||||
expect(res.headers['Access-Control-Allow-Origin']).toBe('*')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling Middleware', () => {
|
||||
test('handles validation errors', () => {
|
||||
const error = { name: 'ValidationError', message: 'Invalid input' }
|
||||
const res = createMockResponse()
|
||||
|
||||
res.status(400).json({ error: error.message })
|
||||
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.body.error).toBe('Invalid input')
|
||||
})
|
||||
|
||||
test('handles authentication errors', () => {
|
||||
const error = { name: 'UnauthorizedError', message: 'Invalid token' }
|
||||
const res = createMockResponse()
|
||||
|
||||
res.status(401).json({ error: error.message })
|
||||
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
test('handles not found errors', () => {
|
||||
const res = createMockResponse()
|
||||
|
||||
res.status(404).json({ error: 'Resource not found' })
|
||||
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
test('handles server errors', () => {
|
||||
const error = new Error('Internal server error')
|
||||
const res = createMockResponse()
|
||||
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
|
||||
expect(res.statusCode).toBe(500)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Request Validation', () => {
|
||||
describe('Body Validation', () => {
|
||||
test('validates JSON body', () => {
|
||||
const body = { email: 'test@example.com', password: 'password123' }
|
||||
|
||||
expect(typeof body).toBe('object')
|
||||
expect(body.email).toBeDefined()
|
||||
expect(body.password).toBeDefined()
|
||||
})
|
||||
|
||||
test('rejects missing required fields', () => {
|
||||
const body = { email: 'test@example.com' }
|
||||
const required = ['email', 'password']
|
||||
|
||||
const missing = required.filter(field => !body[field])
|
||||
|
||||
expect(missing).toContain('password')
|
||||
})
|
||||
|
||||
test('validates field types', () => {
|
||||
const body = { email: 123, password: 'password' }
|
||||
|
||||
expect(typeof body.email).not.toBe('string')
|
||||
expect(typeof body.password).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Query Parameter Validation', () => {
|
||||
test('parses pagination parameters', () => {
|
||||
const query = { page: '2', limit: '20' }
|
||||
|
||||
const page = parseInt(query.page) || 1
|
||||
const limit = parseInt(query.limit) || 10
|
||||
|
||||
expect(page).toBe(2)
|
||||
expect(limit).toBe(20)
|
||||
})
|
||||
|
||||
test('applies defaults for missing parameters', () => {
|
||||
const query = {}
|
||||
|
||||
const page = parseInt(query.page) || 1
|
||||
const limit = parseInt(query.limit) || 10
|
||||
|
||||
expect(page).toBe(1)
|
||||
expect(limit).toBe(10)
|
||||
})
|
||||
|
||||
test('validates parameter ranges', () => {
|
||||
const limit = 1000
|
||||
const maxLimit = 100
|
||||
|
||||
const validLimit = Math.min(limit, maxLimit)
|
||||
|
||||
expect(validLimit).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Path Parameter Validation', () => {
|
||||
test('validates user ID format', () => {
|
||||
const validId = 'user-123-abc'
|
||||
const invalidId = ''
|
||||
|
||||
expect(validId.length).toBeGreaterThan(0)
|
||||
expect(invalidId.length).toBe(0)
|
||||
})
|
||||
|
||||
test('validates UUID format', () => {
|
||||
const uuid = crypto.randomUUID()
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
expect(uuidRegex.test(uuid)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuth Integration', () => {
|
||||
const GOOGLE_CLIENT_ID = 'test-google-client-id'
|
||||
const GITHUB_CLIENT_ID = 'test-github-client-id'
|
||||
|
||||
describe('Google OAuth', () => {
|
||||
test('generates OAuth state', () => {
|
||||
const state = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
expect(state.length).toBe(64)
|
||||
})
|
||||
|
||||
test('builds authorization URL', () => {
|
||||
const redirectUri = 'https://example.com/auth/google/callback'
|
||||
const state = 'test-state'
|
||||
|
||||
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth')
|
||||
url.searchParams.set('client_id', GOOGLE_CLIENT_ID)
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('scope', 'email profile')
|
||||
url.searchParams.set('state', state)
|
||||
|
||||
expect(url.searchParams.get('client_id')).toBe(GOOGLE_CLIENT_ID)
|
||||
expect(url.searchParams.get('state')).toBe(state)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GitHub OAuth', () => {
|
||||
test('builds authorization URL', () => {
|
||||
const redirectUri = 'https://example.com/auth/github/callback'
|
||||
const state = 'test-state'
|
||||
|
||||
const url = new URL('https://github.com/login/oauth/authorize')
|
||||
url.searchParams.set('client_id', GITHUB_CLIENT_ID)
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('scope', 'user:email')
|
||||
url.searchParams.set('state', state)
|
||||
|
||||
expect(url.searchParams.get('client_id')).toBe(GITHUB_CLIENT_ID)
|
||||
})
|
||||
})
|
||||
})
|
||||
584
chat/src/tests/model-routing.test.js
Normal file
584
chat/src/tests/model-routing.test.js
Normal file
@@ -0,0 +1,584 @@
|
||||
const { describe, test, expect, beforeEach, afterEach } = require('bun:test')
|
||||
|
||||
const OPENROUTER_STATIC_FALLBACK_MODELS = [
|
||||
'anthropic/claude-3.5-sonnet',
|
||||
'openai/gpt-4o-mini',
|
||||
'mistralai/mistral-large-latest',
|
||||
'google/gemini-flash-1.5',
|
||||
]
|
||||
|
||||
const DEFAULT_TOKEN_RATES = {
|
||||
usd: 250,
|
||||
gbp: 200,
|
||||
eur: 250,
|
||||
}
|
||||
|
||||
const PLAN_TOKEN_LIMITS = {
|
||||
hobby: 50_000,
|
||||
starter: 100_000,
|
||||
professional: 5_000_000,
|
||||
enterprise: 20_000_000,
|
||||
}
|
||||
|
||||
const PLAN_APP_LIMITS = {
|
||||
hobby: 3,
|
||||
starter: 10,
|
||||
professional: 20,
|
||||
enterprise: Infinity,
|
||||
}
|
||||
|
||||
const EXTERNAL_TEST_LIMITS = {
|
||||
hobby: 3,
|
||||
starter: 50,
|
||||
professional: Infinity,
|
||||
enterprise: Infinity,
|
||||
}
|
||||
|
||||
const PAID_PLANS = new Set(['starter', 'professional', 'enterprise'])
|
||||
|
||||
function normalizePlanSelection(plan) {
|
||||
const validPlans = ['hobby', 'starter', 'professional', 'enterprise']
|
||||
return validPlans.includes(plan) ? plan : 'hobby'
|
||||
}
|
||||
|
||||
function isPaidPlan(plan) {
|
||||
return PAID_PLANS.has(plan)
|
||||
}
|
||||
|
||||
function getPlanTokenLimit(plan) {
|
||||
return PLAN_TOKEN_LIMITS[normalizePlanSelection(plan)] || PLAN_TOKEN_LIMITS.hobby
|
||||
}
|
||||
|
||||
function getPlanAppLimit(plan) {
|
||||
return PLAN_APP_LIMITS[normalizePlanSelection(plan)] || PLAN_APP_LIMITS.hobby
|
||||
}
|
||||
|
||||
function getExternalTestLimit(plan) {
|
||||
return EXTERNAL_TEST_LIMITS[normalizePlanSelection(plan)] || EXTERNAL_TEST_LIMITS.hobby
|
||||
}
|
||||
|
||||
function calculateTokenCost(tokens, currency) {
|
||||
const rate = DEFAULT_TOKEN_RATES[currency] || DEFAULT_TOKEN_RATES.usd
|
||||
return (tokens / 1_000_000) * rate
|
||||
}
|
||||
|
||||
function parseMemoryValue(raw) {
|
||||
if (raw === undefined || raw === null) return 0
|
||||
const str = String(raw).trim()
|
||||
const match = str.match(/^([0-9.]+)\s*([kKmMgGtT]i?)?(?:[bB])?$/)
|
||||
if (!match) return Number(str) || 0
|
||||
const value = parseFloat(match[1])
|
||||
const unit = (match[2] || '').toLowerCase()
|
||||
const KIB = 1024
|
||||
const MIB = KIB * 1024
|
||||
const GIB = MIB * 1024
|
||||
const TIB = GIB * 1024
|
||||
const multipliers = { '': 1, k: KIB, ki: KIB, m: MIB, mi: MIB, g: GIB, gi: GIB, t: TIB, ti: TIB }
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
return Math.round(value * (multipliers[unit] || 1))
|
||||
}
|
||||
|
||||
function resolvePositiveInt(raw, fallback) {
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback
|
||||
return Math.round(parsed)
|
||||
}
|
||||
|
||||
function resolveIntervalMs(raw, fallback, min) {
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback
|
||||
return Math.max(parsed, min)
|
||||
}
|
||||
|
||||
describe('Model Routing', () => {
|
||||
describe('Plan Selection and Validation', () => {
|
||||
test('normalizes valid plans', () => {
|
||||
expect(normalizePlanSelection('hobby')).toBe('hobby')
|
||||
expect(normalizePlanSelection('starter')).toBe('starter')
|
||||
expect(normalizePlanSelection('professional')).toBe('professional')
|
||||
expect(normalizePlanSelection('enterprise')).toBe('enterprise')
|
||||
})
|
||||
|
||||
test('defaults invalid plans to hobby', () => {
|
||||
expect(normalizePlanSelection('invalid')).toBe('hobby')
|
||||
expect(normalizePlanSelection('')).toBe('hobby')
|
||||
expect(normalizePlanSelection(null)).toBe('hobby')
|
||||
expect(normalizePlanSelection(undefined)).toBe('hobby')
|
||||
expect(normalizePlanSelection('free')).toBe('hobby')
|
||||
})
|
||||
|
||||
test('identifies paid plans correctly', () => {
|
||||
expect(isPaidPlan('hobby')).toBe(false)
|
||||
expect(isPaidPlan('starter')).toBe(true)
|
||||
expect(isPaidPlan('professional')).toBe(true)
|
||||
expect(isPaidPlan('enterprise')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns correct token limits by plan', () => {
|
||||
expect(getPlanTokenLimit('hobby')).toBe(50_000)
|
||||
expect(getPlanTokenLimit('starter')).toBe(100_000)
|
||||
expect(getPlanTokenLimit('professional')).toBe(5_000_000)
|
||||
expect(getPlanTokenLimit('enterprise')).toBe(20_000_000)
|
||||
})
|
||||
|
||||
test('returns correct app limits by plan', () => {
|
||||
expect(getPlanAppLimit('hobby')).toBe(3)
|
||||
expect(getPlanAppLimit('starter')).toBe(10)
|
||||
expect(getPlanAppLimit('professional')).toBe(20)
|
||||
expect(getPlanAppLimit('enterprise')).toBe(Infinity)
|
||||
})
|
||||
|
||||
test('returns correct external test limits by plan', () => {
|
||||
expect(getExternalTestLimit('hobby')).toBe(3)
|
||||
expect(getExternalTestLimit('starter')).toBe(50)
|
||||
expect(getExternalTestLimit('professional')).toBe(Infinity)
|
||||
expect(getExternalTestLimit('enterprise')).toBe(Infinity)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider Fallback Models', () => {
|
||||
test('has fallback models available', () => {
|
||||
expect(OPENROUTER_STATIC_FALLBACK_MODELS.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('includes major providers', () => {
|
||||
expect(OPENROUTER_STATIC_FALLBACK_MODELS.some(m => m.includes('anthropic'))).toBe(true)
|
||||
expect(OPENROUTER_STATIC_FALLBACK_MODELS.some(m => m.includes('openai'))).toBe(true)
|
||||
expect(OPENROUTER_STATIC_FALLBACK_MODELS.some(m => m.includes('mistralai'))).toBe(true)
|
||||
expect(OPENROUTER_STATIC_FALLBACK_MODELS.some(m => m.includes('google'))).toBe(true)
|
||||
})
|
||||
|
||||
test('fallback models are properly formatted', () => {
|
||||
OPENROUTER_STATIC_FALLBACK_MODELS.forEach(model => {
|
||||
expect(model).toMatch(/^[a-z0-9-]+\/[a-z0-9.-]+$/i)
|
||||
})
|
||||
})
|
||||
|
||||
test('gets next fallback model', () => {
|
||||
const usedModels = new Set(['anthropic/claude-3.5-sonnet'])
|
||||
const nextModel = OPENROUTER_STATIC_FALLBACK_MODELS.find(m => !usedModels.has(m))
|
||||
|
||||
expect(nextModel).toBeDefined()
|
||||
expect(nextModel).not.toBe('anthropic/claude-3.5-sonnet')
|
||||
})
|
||||
|
||||
test('returns undefined when all fallbacks exhausted', () => {
|
||||
const usedModels = new Set(OPENROUTER_STATIC_FALLBACK_MODELS)
|
||||
const nextModel = OPENROUTER_STATIC_FALLBACK_MODELS.find(m => !usedModels.has(m))
|
||||
|
||||
expect(nextModel).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model Selection Logic', () => {
|
||||
const modelTiers = {
|
||||
free: ['openai/gpt-3.5-turbo', 'google/gemini-flash-1.5'],
|
||||
standard: ['openai/gpt-4o-mini', 'anthropic/claude-3-haiku'],
|
||||
premium: ['openai/gpt-4o', 'anthropic/claude-3.5-sonnet'],
|
||||
enterprise: ['anthropic/claude-3-opus', 'openai/gpt-4-turbo']
|
||||
}
|
||||
|
||||
function getModelsForPlan(plan) {
|
||||
const normalized = normalizePlanSelection(plan)
|
||||
if (normalized === 'enterprise') {
|
||||
return [...modelTiers.free, ...modelTiers.standard, ...modelTiers.premium, ...modelTiers.enterprise]
|
||||
}
|
||||
if (normalized === 'professional') {
|
||||
return [...modelTiers.free, ...modelTiers.standard, ...modelTiers.premium]
|
||||
}
|
||||
if (normalized === 'starter') {
|
||||
return [...modelTiers.free, ...modelTiers.standard]
|
||||
}
|
||||
return [...modelTiers.free]
|
||||
}
|
||||
|
||||
test('hobby plan gets free models only', () => {
|
||||
const models = getModelsForPlan('hobby')
|
||||
expect(models).toEqual(modelTiers.free)
|
||||
})
|
||||
|
||||
test('starter plan gets free and standard models', () => {
|
||||
const models = getModelsForPlan('starter')
|
||||
expect(models).toContain('openai/gpt-3.5-turbo')
|
||||
expect(models).toContain('openai/gpt-4o-mini')
|
||||
expect(models).not.toContain('openai/gpt-4o')
|
||||
})
|
||||
|
||||
test('professional plan gets up to premium models', () => {
|
||||
const models = getModelsForPlan('professional')
|
||||
expect(models).toContain('openai/gpt-4o')
|
||||
expect(models).not.toContain('anthropic/claude-3-opus')
|
||||
})
|
||||
|
||||
test('enterprise plan gets all models', () => {
|
||||
const models = getModelsForPlan('enterprise')
|
||||
expect(models).toContain('anthropic/claude-3-opus')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Token Rate Calculations', () => {
|
||||
test('calculates USD token cost', () => {
|
||||
const cost = calculateTokenCost(1_000_000, 'usd')
|
||||
expect(cost).toBe(250)
|
||||
})
|
||||
|
||||
test('calculates GBP token cost', () => {
|
||||
const cost = calculateTokenCost(1_000_000, 'gbp')
|
||||
expect(cost).toBe(200)
|
||||
})
|
||||
|
||||
test('calculates EUR token cost', () => {
|
||||
const cost = calculateTokenCost(1_000_000, 'eur')
|
||||
expect(cost).toBe(250)
|
||||
})
|
||||
|
||||
test('calculates partial token cost', () => {
|
||||
const cost = calculateTokenCost(500_000, 'usd')
|
||||
expect(cost).toBe(125)
|
||||
})
|
||||
|
||||
test('defaults to USD for unknown currency', () => {
|
||||
const cost = calculateTokenCost(1_000_000, 'unknown')
|
||||
expect(cost).toBe(250)
|
||||
})
|
||||
|
||||
test('calculates zero cost for zero tokens', () => {
|
||||
const cost = calculateTokenCost(0, 'usd')
|
||||
expect(cost).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memory and Resource Parsing', () => {
|
||||
test('parses bytes value', () => {
|
||||
expect(parseMemoryValue('1024')).toBe(1024)
|
||||
})
|
||||
|
||||
test('parses kilobytes', () => {
|
||||
expect(parseMemoryValue('1K')).toBe(1024)
|
||||
expect(parseMemoryValue('1KB')).toBe(1024)
|
||||
expect(parseMemoryValue('1KiB')).toBe(1024)
|
||||
})
|
||||
|
||||
test('parses megabytes', () => {
|
||||
expect(parseMemoryValue('1M')).toBe(1024 * 1024)
|
||||
expect(parseMemoryValue('512MB')).toBe(512 * 1024 * 1024)
|
||||
})
|
||||
|
||||
test('parses gigabytes', () => {
|
||||
expect(parseMemoryValue('1G')).toBe(1024 * 1024 * 1024)
|
||||
expect(parseMemoryValue('2GB')).toBe(2 * 1024 * 1024 * 1024)
|
||||
})
|
||||
|
||||
test('handles null/undefined', () => {
|
||||
expect(parseMemoryValue(null)).toBe(0)
|
||||
expect(parseMemoryValue(undefined)).toBe(0)
|
||||
})
|
||||
|
||||
test('handles invalid input', () => {
|
||||
expect(parseMemoryValue('invalid')).toBe(0)
|
||||
expect(parseMemoryValue('')).toBe(0)
|
||||
})
|
||||
|
||||
test('resolvePositiveInt returns fallback for invalid', () => {
|
||||
expect(resolvePositiveInt('invalid', 10)).toBe(10)
|
||||
expect(resolvePositiveInt(null, 10)).toBe(10)
|
||||
expect(resolvePositiveInt(-5, 10)).toBe(10)
|
||||
})
|
||||
|
||||
test('resolvePositiveInt returns parsed value', () => {
|
||||
expect(resolvePositiveInt('50', 10)).toBe(50)
|
||||
expect(resolvePositiveInt(100, 10)).toBe(100)
|
||||
})
|
||||
|
||||
test('resolveIntervalMs enforces minimum', () => {
|
||||
expect(resolveIntervalMs('100', 1000, 500)).toBe(500)
|
||||
expect(resolveIntervalMs('2000', 1000, 500)).toBe(2000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rate Limiting', () => {
|
||||
const rateLimiter = {
|
||||
requests: new Map(),
|
||||
windowMs: 60000,
|
||||
maxRequests: 10,
|
||||
|
||||
check(key) {
|
||||
const now = Date.now()
|
||||
let record = this.requests.get(key)
|
||||
|
||||
if (!record || now - record.windowStart > this.windowMs) {
|
||||
record = { count: 0, windowStart: now }
|
||||
this.requests.set(key, record)
|
||||
}
|
||||
|
||||
record.count++
|
||||
|
||||
return {
|
||||
allowed: record.count <= this.maxRequests,
|
||||
remaining: Math.max(0, this.maxRequests - record.count),
|
||||
resetAt: record.windowStart + this.windowMs
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.requests.clear()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rateLimiter.reset()
|
||||
})
|
||||
|
||||
test('allows first request', () => {
|
||||
const result = rateLimiter.check('user-1')
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(9)
|
||||
})
|
||||
|
||||
test('tracks multiple requests', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
rateLimiter.check('user-2')
|
||||
}
|
||||
const result = rateLimiter.check('user-2')
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(4)
|
||||
})
|
||||
|
||||
test('blocks after limit reached', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rateLimiter.check('user-3')
|
||||
}
|
||||
const result = rateLimiter.check('user-3')
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.remaining).toBe(0)
|
||||
})
|
||||
|
||||
test('separate limits per key', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rateLimiter.check('user-4a')
|
||||
}
|
||||
|
||||
const resultA = rateLimiter.check('user-4a')
|
||||
const resultB = rateLimiter.check('user-4b')
|
||||
|
||||
expect(resultA.allowed).toBe(false)
|
||||
expect(resultB.allowed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider Health Checks', () => {
|
||||
const providerHealth = {
|
||||
openrouter: { status: 'healthy', latency: 150, lastCheck: Date.now() },
|
||||
mistral: { status: 'healthy', latency: 200, lastCheck: Date.now() },
|
||||
ollama: { status: 'degraded', latency: 500, lastCheck: Date.now() },
|
||||
google: { status: 'healthy', latency: 180, lastCheck: Date.now() }
|
||||
}
|
||||
|
||||
function isProviderHealthy(provider) {
|
||||
const health = providerHealth[provider]
|
||||
if (!health) return false
|
||||
return health.status === 'healthy'
|
||||
}
|
||||
|
||||
function getProviderLatency(provider) {
|
||||
return providerHealth[provider]?.latency || Infinity
|
||||
}
|
||||
|
||||
function getHealthyProviders() {
|
||||
return Object.entries(providerHealth)
|
||||
.filter(([_, health]) => health.status === 'healthy')
|
||||
.map(([name]) => name)
|
||||
}
|
||||
|
||||
test('checks OpenRouter health', () => {
|
||||
expect(isProviderHealthy('openrouter')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects degraded provider', () => {
|
||||
expect(isProviderHealthy('ollama')).toBe(false)
|
||||
})
|
||||
|
||||
test('handles unknown provider', () => {
|
||||
expect(isProviderHealthy('unknown')).toBe(false)
|
||||
})
|
||||
|
||||
test('gets provider latency', () => {
|
||||
expect(getProviderLatency('openrouter')).toBe(150)
|
||||
expect(getProviderLatency('unknown')).toBe(Infinity)
|
||||
})
|
||||
|
||||
test('lists healthy providers', () => {
|
||||
const healthy = getHealthyProviders()
|
||||
expect(healthy).toContain('openrouter')
|
||||
expect(healthy).toContain('mistral')
|
||||
expect(healthy).toContain('google')
|
||||
expect(healthy).not.toContain('ollama')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Request Priority Queue', () => {
|
||||
const priorityQueue = {
|
||||
queue: [],
|
||||
|
||||
add(request) {
|
||||
this.queue.push({
|
||||
...request,
|
||||
addedAt: Date.now()
|
||||
})
|
||||
this.queue.sort((a, b) => a.priority - b.priority)
|
||||
},
|
||||
|
||||
next() {
|
||||
return this.queue.shift()
|
||||
},
|
||||
|
||||
length() {
|
||||
return this.queue.length
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.queue = []
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
priorityQueue.clear()
|
||||
})
|
||||
|
||||
test('adds requests to queue', () => {
|
||||
priorityQueue.add({ id: 'req-1', priority: 5 })
|
||||
expect(priorityQueue.length()).toBe(1)
|
||||
})
|
||||
|
||||
test('orders by priority', () => {
|
||||
priorityQueue.add({ id: 'req-1', priority: 5 })
|
||||
priorityQueue.add({ id: 'req-2', priority: 1 })
|
||||
priorityQueue.add({ id: 'req-3', priority: 3 })
|
||||
|
||||
expect(priorityQueue.next().id).toBe('req-2')
|
||||
expect(priorityQueue.next().id).toBe('req-3')
|
||||
expect(priorityQueue.next().id).toBe('req-1')
|
||||
})
|
||||
|
||||
test('processes in order', () => {
|
||||
priorityQueue.add({ id: 'first', priority: 1 })
|
||||
priorityQueue.add({ id: 'second', priority: 2 })
|
||||
|
||||
expect(priorityQueue.next().id).toBe('first')
|
||||
expect(priorityQueue.next().id).toBe('second')
|
||||
expect(priorityQueue.length()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenRouter Configuration', () => {
|
||||
test('default API URL is correct', () => {
|
||||
const DEFAULT_OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||
expect(DEFAULT_OPENROUTER_API_URL).toContain('openrouter.ai')
|
||||
expect(DEFAULT_OPENROUTER_API_URL).toContain('chat/completions')
|
||||
})
|
||||
|
||||
test('default model is set', () => {
|
||||
const OPENROUTER_DEFAULT_MODEL = 'openai/gpt-4o-mini'
|
||||
expect(OPENROUTER_DEFAULT_MODEL).toMatch(/^[a-z0-9-]+\/[a-z0-9.-]+$/i)
|
||||
})
|
||||
|
||||
test('error detail limit is reasonable', () => {
|
||||
const OPENROUTER_ERROR_DETAIL_LIMIT = 400
|
||||
expect(OPENROUTER_ERROR_DETAIL_LIMIT).toBeGreaterThan(0)
|
||||
expect(OPENROUTER_ERROR_DETAIL_LIMIT).toBeLessThan(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mistral Configuration', () => {
|
||||
test('default API URL is correct', () => {
|
||||
const DEFAULT_MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'
|
||||
expect(DEFAULT_MISTRAL_API_URL).toContain('mistral.ai')
|
||||
expect(DEFAULT_MISTRAL_API_URL).toContain('chat/completions')
|
||||
})
|
||||
|
||||
test('default model is set', () => {
|
||||
const MISTRAL_DEFAULT_MODEL = 'mistral-large-latest'
|
||||
expect(MISTRAL_DEFAULT_MODEL).toBeDefined()
|
||||
expect(MISTRAL_DEFAULT_MODEL.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subscription and Payment Processing', () => {
|
||||
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
|
||||
}
|
||||
|
||||
test('yearly discount is applied', () => {
|
||||
const monthly = SUBSCRIPTION_PRICES.starter_monthly_usd
|
||||
const yearly = SUBSCRIPTION_PRICES.starter_yearly_usd
|
||||
const expectedYearly = monthly * 10
|
||||
|
||||
expect(yearly).toBeLessThan(monthly * 12)
|
||||
expect(yearly).toBe(expectedYearly)
|
||||
})
|
||||
|
||||
test('enterprise costs more than professional', () => {
|
||||
expect(SUBSCRIPTION_PRICES.enterprise_monthly_usd).toBeGreaterThan(SUBSCRIPTION_PRICES.professional_monthly_usd)
|
||||
expect(SUBSCRIPTION_PRICES.professional_monthly_usd).toBeGreaterThan(SUBSCRIPTION_PRICES.starter_monthly_usd)
|
||||
})
|
||||
|
||||
test('top-up tokens scale correctly', () => {
|
||||
expect(TOPUP_TOKENS.topup_2).toBeGreaterThan(TOPUP_TOKENS.topup_1)
|
||||
expect(TOPUP_TOKENS.topup_3).toBeGreaterThan(TOPUP_TOKENS.topup_2)
|
||||
expect(TOPUP_TOKENS.topup_4).toBeGreaterThan(TOPUP_TOKENS.topup_3)
|
||||
})
|
||||
|
||||
test('top-up price per token decreases with volume', () => {
|
||||
const pricePerToken1 = TOPUP_PRICES.topup_1_usd / TOPUP_TOKENS.topup_1
|
||||
const pricePerToken4 = TOPUP_PRICES.topup_4_usd / TOPUP_TOKENS.topup_4
|
||||
|
||||
expect(pricePerToken4).toBeLessThan(pricePerToken1)
|
||||
})
|
||||
|
||||
test('calculates subscription value', () => {
|
||||
const monthlyValue = SUBSCRIPTION_PRICES.professional_monthly_usd / 100
|
||||
const yearlyValue = SUBSCRIPTION_PRICES.professional_yearly_usd / (100 * 12)
|
||||
|
||||
expect(monthlyValue).toBe(25)
|
||||
expect(yearlyValue).toBeCloseTo(20.83, 1)
|
||||
})
|
||||
|
||||
test('validates currency support', () => {
|
||||
const SUPPORTED_CURRENCIES = ['usd', 'gbp', 'eur']
|
||||
|
||||
expect(SUPPORTED_CURRENCIES).toContain('usd')
|
||||
expect(SUPPORTED_CURRENCIES).toContain('gbp')
|
||||
expect(SUPPORTED_CURRENCIES).toContain('eur')
|
||||
expect(SUPPORTED_CURRENCIES.length).toBe(3)
|
||||
})
|
||||
|
||||
test('validates billing cycles', () => {
|
||||
const BILLING_CYCLES = ['monthly', 'yearly']
|
||||
|
||||
expect(BILLING_CYCLES).toContain('monthly')
|
||||
expect(BILLING_CYCLES).toContain('yearly')
|
||||
expect(BILLING_CYCLES.length).toBe(2)
|
||||
})
|
||||
})
|
||||
506
chat/src/tests/payments.test.js
Normal file
506
chat/src/tests/payments.test.js
Normal file
@@ -0,0 +1,506 @@
|
||||
const { describe, test, expect, beforeEach, afterEach } = require('bun:test')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const MIN_PAYMENT_AMOUNT = 50
|
||||
|
||||
const SUBSCRIPTION_PRICES = {
|
||||
starter_monthly_usd: 750,
|
||||
starter_yearly_usd: 7500,
|
||||
starter_monthly_gbp: 625,
|
||||
starter_yearly_gbp: 6250,
|
||||
starter_monthly_eur: 750,
|
||||
starter_yearly_eur: 7500,
|
||||
|
||||
professional_monthly_usd: 2500,
|
||||
professional_yearly_usd: 25000,
|
||||
professional_monthly_gbp: 2100,
|
||||
professional_yearly_gbp: 21000,
|
||||
professional_monthly_eur: 2500,
|
||||
professional_yearly_eur: 25000,
|
||||
|
||||
enterprise_monthly_usd: 7500,
|
||||
enterprise_yearly_usd: 75000,
|
||||
enterprise_monthly_gbp: 6250,
|
||||
enterprise_yearly_gbp: 62500,
|
||||
enterprise_monthly_eur: 7500,
|
||||
enterprise_yearly_eur: 75000
|
||||
}
|
||||
|
||||
const TOPUP_PRICES = {
|
||||
topup_1_usd: 750,
|
||||
topup_1_gbp: 500,
|
||||
topup_1_eur: 750,
|
||||
topup_2_usd: 2500,
|
||||
topup_2_gbp: 2000,
|
||||
topup_2_eur: 2500,
|
||||
topup_3_usd: 7500,
|
||||
topup_3_gbp: 6000,
|
||||
topup_3_eur: 7500,
|
||||
topup_4_usd: 12500,
|
||||
topup_4_gbp: 10000,
|
||||
topup_4_eur: 12500
|
||||
}
|
||||
|
||||
const TOPUP_TOKENS = {
|
||||
topup_1: 100_000,
|
||||
topup_2: 5_000_000,
|
||||
topup_3: 20_000_000,
|
||||
topup_4: 50_000_000
|
||||
}
|
||||
|
||||
const PAYG_PRICES = {
|
||||
usd: 250,
|
||||
gbp: 200,
|
||||
eur: 250
|
||||
}
|
||||
|
||||
const BUSINESS_TOPUP_DISCOUNT = 0.025
|
||||
const ENTERPRISE_TOPUP_DISCOUNT = 0.05
|
||||
|
||||
const BILLING_CYCLES = ['monthly', 'yearly']
|
||||
const SUPPORTED_CURRENCIES = ['usd', 'gbp', 'eur']
|
||||
|
||||
function formatPrice(cents, currency) {
|
||||
const symbols = { usd: '$', gbp: '£', eur: '€' }
|
||||
return `${symbols[currency] || '$'}${(cents / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
function getProductKey(plan, cycle, currency) {
|
||||
return `${plan}_${cycle}_${currency}`
|
||||
}
|
||||
|
||||
function getTopupProductKey(option, currency) {
|
||||
return `topup_${option}_${currency}`
|
||||
}
|
||||
|
||||
function calculateYearlySavings(monthlyPrice, yearlyPrice) {
|
||||
return (monthlyPrice * 12) - yearlyPrice
|
||||
}
|
||||
|
||||
function applyDiscount(price, discountRate) {
|
||||
return Math.round(price * (1 - discountRate))
|
||||
}
|
||||
|
||||
function generatePaymentId() {
|
||||
return `pay_${crypto.randomBytes(16).toString('hex')}`
|
||||
}
|
||||
|
||||
function validatePaymentAmount(amount) {
|
||||
return Number.isFinite(amount) && amount >= MIN_PAYMENT_AMOUNT
|
||||
}
|
||||
|
||||
function parseWebhookPayload(payload) {
|
||||
try {
|
||||
return JSON.parse(payload)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
describe('Payments', () => {
|
||||
describe('Subscription Pricing', () => {
|
||||
test('has all plan prices defined', () => {
|
||||
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(SUBSCRIPTION_PRICES[key]).toBeDefined()
|
||||
expect(SUBSCRIPTION_PRICES[key]).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('yearly price offers discount', () => {
|
||||
const plans = ['starter', 'professional', 'enterprise']
|
||||
|
||||
plans.forEach(plan => {
|
||||
const monthlyPrice = SUBSCRIPTION_PRICES[getProductKey(plan, 'monthly', 'usd')]
|
||||
const yearlyPrice = SUBSCRIPTION_PRICES[getProductKey(plan, 'yearly', 'usd')]
|
||||
const savings = calculateYearlySavings(monthlyPrice, yearlyPrice)
|
||||
|
||||
expect(yearlyPrice).toBeLessThan(monthlyPrice * 12)
|
||||
expect(savings).toBe(monthlyPrice * 2)
|
||||
})
|
||||
})
|
||||
|
||||
test('enterprise is most expensive', () => {
|
||||
expect(SUBSCRIPTION_PRICES.enterprise_monthly_usd).toBeGreaterThan(SUBSCRIPTION_PRICES.professional_monthly_usd)
|
||||
expect(SUBSCRIPTION_PRICES.professional_monthly_usd).toBeGreaterThan(SUBSCRIPTION_PRICES.starter_monthly_usd)
|
||||
})
|
||||
|
||||
test('formats prices correctly', () => {
|
||||
expect(formatPrice(750, 'usd')).toBe('$7.50')
|
||||
expect(formatPrice(625, 'gbp')).toBe('£6.25')
|
||||
expect(formatPrice(750, 'eur')).toBe('€7.50')
|
||||
})
|
||||
|
||||
test('generates product keys correctly', () => {
|
||||
expect(getProductKey('starter', 'monthly', 'usd')).toBe('starter_monthly_usd')
|
||||
expect(getProductKey('professional', 'yearly', 'gbp')).toBe('professional_yearly_gbp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Top-up Pricing', () => {
|
||||
test('has all top-up options defined', () => {
|
||||
const options = ['1', '2', '3', '4']
|
||||
const currencies = ['usd', 'gbp', 'eur']
|
||||
|
||||
options.forEach(option => {
|
||||
currencies.forEach(currency => {
|
||||
const key = getTopupProductKey(option, currency)
|
||||
expect(TOPUP_PRICES[key]).toBeDefined()
|
||||
expect(TOPUP_PRICES[key]).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('token amounts are correct', () => {
|
||||
expect(TOPUP_TOKENS.topup_1).toBe(100_000)
|
||||
expect(TOPUP_TOKENS.topup_2).toBe(5_000_000)
|
||||
expect(TOPUP_TOKENS.topup_3).toBe(20_000_000)
|
||||
expect(TOPUP_TOKENS.topup_4).toBe(50_000_000)
|
||||
})
|
||||
|
||||
test('larger top-ups have better value', () => {
|
||||
const pricePerToken1 = TOPUP_PRICES.topup_1_usd / TOPUP_TOKENS.topup_1
|
||||
const pricePerToken2 = TOPUP_PRICES.topup_2_usd / TOPUP_TOKENS.topup_2
|
||||
const pricePerToken3 = TOPUP_PRICES.topup_3_usd / TOPUP_TOKENS.topup_3
|
||||
const pricePerToken4 = TOPUP_PRICES.topup_4_usd / TOPUP_TOKENS.topup_4
|
||||
|
||||
expect(pricePerToken2).toBeLessThan(pricePerToken1)
|
||||
expect(pricePerToken3).toBeLessThan(pricePerToken2)
|
||||
expect(pricePerToken4).toBeLessThan(pricePerToken3)
|
||||
})
|
||||
|
||||
test('calculates tokens per dollar', () => {
|
||||
const tokensPerDollar1 = TOPUP_TOKENS.topup_1 / (TOPUP_PRICES.topup_1_usd / 100)
|
||||
const tokensPerDollar4 = TOPUP_TOKENS.topup_4 / (TOPUP_PRICES.topup_4_usd / 100)
|
||||
|
||||
expect(tokensPerDollar1).toBeCloseTo(13333.33, 0)
|
||||
expect(tokensPerDollar4).toBeCloseTo(40000, 0)
|
||||
expect(tokensPerDollar4).toBeGreaterThan(tokensPerDollar1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Discounts', () => {
|
||||
test('applies business discount', () => {
|
||||
const originalPrice = 1000
|
||||
const discountedPrice = applyDiscount(originalPrice, BUSINESS_TOPUP_DISCOUNT)
|
||||
|
||||
expect(discountedPrice).toBe(975)
|
||||
})
|
||||
|
||||
test('applies enterprise discount', () => {
|
||||
const originalPrice = 1000
|
||||
const discountedPrice = applyDiscount(originalPrice, ENTERPRISE_TOPUP_DISCOUNT)
|
||||
|
||||
expect(discountedPrice).toBe(950)
|
||||
})
|
||||
|
||||
test('enterprise discount is higher than business', () => {
|
||||
expect(ENTERPRISE_TOPUP_DISCOUNT).toBeGreaterThan(BUSINESS_TOPUP_DISCOUNT)
|
||||
})
|
||||
|
||||
test('discount rates are valid percentages', () => {
|
||||
expect(BUSINESS_TOPUP_DISCOUNT).toBeGreaterThanOrEqual(0)
|
||||
expect(BUSINESS_TOPUP_DISCOUNT).toBeLessThan(1)
|
||||
expect(ENTERPRISE_TOPUP_DISCOUNT).toBeGreaterThanOrEqual(0)
|
||||
expect(ENTERPRISE_TOPUP_DISCOUNT).toBeLessThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pay-as-you-go', () => {
|
||||
test('has prices for all currencies', () => {
|
||||
SUPPORTED_CURRENCIES.forEach(currency => {
|
||||
expect(PAYG_PRICES[currency]).toBeDefined()
|
||||
expect(PAYG_PRICES[currency]).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('calculates PAYG cost correctly', () => {
|
||||
const tokens = 500_000
|
||||
const cost = (tokens / 1_000_000) * PAYG_PRICES.usd
|
||||
|
||||
expect(cost).toBe(125)
|
||||
})
|
||||
|
||||
test('GBP is cheaper than USD/EUR for PAYG', () => {
|
||||
expect(PAYG_PRICES.gbp).toBeLessThan(PAYG_PRICES.usd)
|
||||
expect(PAYG_PRICES.gbp).toBeLessThan(PAYG_PRICES.eur)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Payment Validation', () => {
|
||||
test('validates minimum payment amount', () => {
|
||||
expect(validatePaymentAmount(50)).toBe(true)
|
||||
expect(validatePaymentAmount(49)).toBe(false)
|
||||
expect(validatePaymentAmount(0)).toBe(false)
|
||||
expect(validatePaymentAmount(-10)).toBe(false)
|
||||
expect(validatePaymentAmount(Infinity)).toBe(false)
|
||||
expect(validatePaymentAmount(NaN)).toBe(false)
|
||||
})
|
||||
|
||||
test('generates unique payment IDs', () => {
|
||||
const id1 = generatePaymentId()
|
||||
const id2 = generatePaymentId()
|
||||
|
||||
expect(id1).not.toBe(id2)
|
||||
expect(id1).toMatch(/^pay_[a-f0-9]{32}$/)
|
||||
})
|
||||
|
||||
test('parses valid webhook payload', () => {
|
||||
const payload = JSON.stringify({ event: 'payment.completed', amount: 1000 })
|
||||
const parsed = parseWebhookPayload(payload)
|
||||
|
||||
expect(parsed.event).toBe('payment.completed')
|
||||
expect(parsed.amount).toBe(1000)
|
||||
})
|
||||
|
||||
test('handles invalid webhook payload', () => {
|
||||
const parsed = parseWebhookPayload('not valid json')
|
||||
|
||||
expect(parsed).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Billing Cycles', () => {
|
||||
test('supports monthly and yearly', () => {
|
||||
expect(BILLING_CYCLES).toContain('monthly')
|
||||
expect(BILLING_CYCLES).toContain('yearly')
|
||||
expect(BILLING_CYCLES.length).toBe(2)
|
||||
})
|
||||
|
||||
test('calculates yearly savings percentage', () => {
|
||||
const monthlyPrice = SUBSCRIPTION_PRICES.starter_monthly_usd
|
||||
const yearlyPrice = SUBSCRIPTION_PRICES.starter_yearly_usd
|
||||
const savingsPercentage = ((monthlyPrice * 12 - yearlyPrice) / (monthlyPrice * 12)) * 100
|
||||
|
||||
expect(savingsPercentage).toBeCloseTo(16.67, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Currency Support', () => {
|
||||
test('supports USD, GBP, EUR', () => {
|
||||
expect(SUPPORTED_CURRENCIES).toContain('usd')
|
||||
expect(SUPPORTED_CURRENCIES).toContain('gbp')
|
||||
expect(SUPPORTED_CURRENCIES).toContain('eur')
|
||||
})
|
||||
|
||||
test('GBP prices are lower than USD', () => {
|
||||
const plans = ['starter', 'professional', 'enterprise']
|
||||
|
||||
plans.forEach(plan => {
|
||||
const usdPrice = SUBSCRIPTION_PRICES[getProductKey(plan, 'monthly', 'usd')]
|
||||
const gbpPrice = SUBSCRIPTION_PRICES[getProductKey(plan, 'monthly', 'gbp')]
|
||||
|
||||
expect(gbpPrice).toBeLessThan(usdPrice)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subscription Status', () => {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Refund Calculations', () => {
|
||||
function calculateProratedRefund(subscriptionPrice, daysRemaining, daysInPeriod) {
|
||||
return Math.round((subscriptionPrice * daysRemaining) / daysInPeriod)
|
||||
}
|
||||
|
||||
test('calculates prorated refund for mid-cycle cancellation', () => {
|
||||
const price = 750
|
||||
const daysRemaining = 15
|
||||
const daysInPeriod = 30
|
||||
|
||||
const refund = calculateProratedRefund(price, daysRemaining, daysInPeriod)
|
||||
|
||||
expect(refund).toBe(375)
|
||||
})
|
||||
|
||||
test('no refund at end of cycle', () => {
|
||||
const price = 750
|
||||
const daysRemaining = 0
|
||||
const daysInPeriod = 30
|
||||
|
||||
const refund = calculateProratedRefund(price, daysRemaining, daysInPeriod)
|
||||
|
||||
expect(refund).toBe(0)
|
||||
})
|
||||
|
||||
test('full refund at start of cycle', () => {
|
||||
const price = 750
|
||||
const daysRemaining = 30
|
||||
const daysInPeriod = 30
|
||||
|
||||
const refund = calculateProratedRefund(price, daysRemaining, daysInPeriod)
|
||||
|
||||
expect(refund).toBe(750)
|
||||
})
|
||||
})
|
||||
|
||||
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', 50000, 12.5)
|
||||
|
||||
expect(event.event).toBe('token.usage')
|
||||
expect(event.customerId).toBe('user-123')
|
||||
expect(event.properties.billableTokens).toBe(50000)
|
||||
expect(event.properties.costCents).toBe(12.5)
|
||||
expect(event.timestamp).toBeDefined()
|
||||
})
|
||||
|
||||
test('event name is correctly configured', () => {
|
||||
expect(DODO_USAGE_EVENT_NAME).toBe('token.usage')
|
||||
})
|
||||
|
||||
test('usage event fields are defined', () => {
|
||||
expect(DODO_USAGE_EVENT_COST_FIELD).toBe('costCents')
|
||||
expect(DODO_USAGE_EVENT_TOKENS_FIELD).toBe('billableTokens')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Webhook Handling', () => {
|
||||
function verifyWebhookSignature(payload, signature, secret) {
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex')
|
||||
return signature === `sha256=${expectedSignature}`
|
||||
}
|
||||
|
||||
const testSecret = 'webhook-secret-key'
|
||||
|
||||
test('verifies valid webhook signature', () => {
|
||||
const payload = JSON.stringify({ event: 'test' })
|
||||
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: 'test' })
|
||||
const signature = 'sha256=invalid'
|
||||
|
||||
expect(verifyWebhookSignature(payload, signature, testSecret)).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects tampered payload', () => {
|
||||
const payload = JSON.stringify({ event: 'test' })
|
||||
const signature = `sha256=${crypto.createHmac('sha256', testSecret).update(payload).digest('hex')}`
|
||||
const tamperedPayload = JSON.stringify({ event: 'tampered' })
|
||||
|
||||
expect(verifyWebhookSignature(tamperedPayload, signature, testSecret)).toBe(false)
|
||||
})
|
||||
|
||||
test('handles payment completed webhook', () => {
|
||||
const webhookPayload = {
|
||||
event: 'payment.completed',
|
||||
data: {
|
||||
payment_id: 'pay_123',
|
||||
amount: 750,
|
||||
currency: 'usd',
|
||||
customer_id: 'user_456'
|
||||
}
|
||||
}
|
||||
|
||||
expect(webhookPayload.event).toBe('payment.completed')
|
||||
expect(webhookPayload.data.amount).toBe(750)
|
||||
})
|
||||
|
||||
test('handles subscription created webhook', () => {
|
||||
const webhookPayload = {
|
||||
event: 'subscription.created',
|
||||
data: {
|
||||
subscription_id: 'sub_123',
|
||||
customer_id: 'user_456',
|
||||
plan: 'professional',
|
||||
status: 'active'
|
||||
}
|
||||
}
|
||||
|
||||
expect(webhookPayload.event).toBe('subscription.created')
|
||||
expect(webhookPayload.data.plan).toBe('professional')
|
||||
})
|
||||
|
||||
test('handles subscription cancelled webhook', () => {
|
||||
const webhookPayload = {
|
||||
event: 'subscription.cancelled',
|
||||
data: {
|
||||
subscription_id: 'sub_123',
|
||||
customer_id: 'user_456',
|
||||
cancelled_at: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
expect(webhookPayload.event).toBe('subscription.cancelled')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user