From 4d012c411256a81a71750fc8cd3891e98fbf3351 Mon Sep 17 00:00:00 2001 From: Arseniy Kamyshev Date: Tue, 29 Jul 2025 18:09:26 +0700 Subject: [PATCH] feat(test-helpers): add comprehensive test helpers suite - Add D1 database mocks with query tracking and result configuration - Add query builder for creating test SQL queries programmatically - Add fixture generators for common test data patterns - Add enhanced KV namespace mocks with expiration support - Add cache service mocks with statistics and tag-based operations - Add worker environment mocks for Cloudflare Workers testing - Add async testing utilities (waitFor, retry, parallel execution) - Add comprehensive documentation and examples - All test helpers are fully typed with minimal any types --- docs/TEST_HELPERS.md | 423 ++++++++++++++++++ examples/test-helpers-example.ts | 328 ++++++++++++++ .../__tests__/test-helpers.test.ts | 357 +++++++++++++++ src/test-helpers/database/d1-helpers.ts | 199 ++++++++ src/test-helpers/database/fixtures.ts | 224 ++++++++++ src/test-helpers/database/query-builder.ts | 163 +++++++ src/test-helpers/index.ts | 39 ++ src/test-helpers/platform/env-helpers.ts | 244 ++++++++++ src/test-helpers/storage/cache-helpers.ts | 279 ++++++++++++ src/test-helpers/storage/kv-helpers.ts | 241 ++++++++++ src/test-helpers/utils/async-helpers.ts | 231 ++++++++++ 11 files changed, 2728 insertions(+) create mode 100644 docs/TEST_HELPERS.md create mode 100644 examples/test-helpers-example.ts create mode 100644 src/test-helpers/__tests__/test-helpers.test.ts create mode 100644 src/test-helpers/database/d1-helpers.ts create mode 100644 src/test-helpers/database/fixtures.ts create mode 100644 src/test-helpers/database/query-builder.ts create mode 100644 src/test-helpers/index.ts create mode 100644 src/test-helpers/platform/env-helpers.ts create mode 100644 src/test-helpers/storage/cache-helpers.ts create mode 100644 src/test-helpers/storage/kv-helpers.ts create mode 100644 src/test-helpers/utils/async-helpers.ts diff --git a/docs/TEST_HELPERS.md b/docs/TEST_HELPERS.md new file mode 100644 index 0000000..96c0a08 --- /dev/null +++ b/docs/TEST_HELPERS.md @@ -0,0 +1,423 @@ +# Test Helpers Suite + +A comprehensive collection of testing utilities for TypeScript applications, especially those using Cloudflare Workers, D1 Database, KV storage, and messaging platforms. + +## Installation + +The test helpers are included in the Wireframe platform. Import them in your tests: + +```typescript +import { + createMockD1Database, + createMockKVNamespace, + createUserFixture, + waitFor, + // ... and more +} from '@/test-helpers'; +``` + +## Database Testing + +### D1 Mock with Query Tracking + +```typescript +import { createMockD1Database } from '@/test-helpers'; + +const mockDb = createMockD1Database(); + +// Set up expected results +mockDb._setQueryResult('SELECT * FROM users', [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, +]); + +// Use in your code +const result = await mockDb.prepare('SELECT * FROM users').all(); + +// Verify queries +expect(mockDb._queries).toHaveLength(1); +expect(mockDb._queries[0].sql).toBe('SELECT * FROM users'); +``` + +### Query Builder for Tests + +```typescript +import { TestQueryBuilder, createInsertQuery } from '@/test-helpers'; + +// Build complex queries +const query = new TestQueryBuilder() + .select('id', 'name', 'email') + .from('users') + .where('active', '=', true) + .where('role', '=', 'admin') + .orderBy('created_at', 'DESC') + .limit(10) + .build(); + +// Simple insert +const insert = createInsertQuery('users', { + name: 'John Doe', + email: 'john@example.com', + active: true, +}); +``` + +### Database Fixtures + +```typescript +import { createUserFixture, FixtureGenerator, TEST_DATA } from '@/test-helpers'; + +// Single fixture +const user = createUserFixture({ + username: 'testuser', + role: 'admin', +}); + +// Multiple fixtures +const generator = new FixtureGenerator(createUserFixture); +const users = generator.createMany(10, (index) => ({ + username: `user${index}`, + email: `user${index}@test.com`, +})); + +// Use predefined test data +const adminUser = TEST_DATA.users.admin; +``` + +## Storage Testing + +### KV Namespace Mock + +```typescript +import { createMockKVNamespace, KVTestUtils } from '@/test-helpers'; + +const mockKV = createMockKVNamespace(); + +// Basic operations +await mockKV.put('key', 'value'); +const value = await mockKV.get('key'); + +// With expiration +await mockKV.put('temp', 'data', { expirationTtl: 60 }); + +// Namespaced KV +const userKV = KVTestUtils.createNamespacedKV(mockKV, 'users'); +await userKV.put('123', JSON.stringify({ name: 'Alice' })); + +// Batch operations +const batch = KVTestUtils.createBatchWriter(mockKV); +batch.put('key1', 'value1'); +batch.put('key2', 'value2'); +await batch.flush(); +``` + +### Cache Testing + +```typescript +import { MockCacheService, CacheTestUtils } from '@/test-helpers'; + +const cache = new MockCacheService(); + +// Track statistics +await cache.set('key', 'value'); +await cache.get('key'); // hit +await cache.get('missing'); // miss + +const stats = cache.getStats(); +console.log(`Hit rate: ${stats.hitRate}`); + +// Tag-based operations +await cache.set('user:1', userData, { tags: ['users', 'active'] }); +await cache.set('post:1', postData, { tags: ['posts'] }); +await cache.purgeByTags(['users']); + +// Simulate expiration +vi.useFakeTimers(); +await cache.set('temp', 'value', { ttl: 60 }); +await CacheTestUtils.simulateExpiration(cache, 61); +vi.useRealTimers(); +``` + +## Platform Testing + +### Worker Environment + +```typescript +import { createMockWorkerEnv, createMockExecutionContext } from '@/test-helpers'; + +// Create full environment +const env = createMockWorkerEnv({ + API_KEY: 'test-key', + DATABASE_URL: 'sqlite://test.db', +}); + +// Create execution context +const ctx = createMockExecutionContext(); +ctx.waitUntil(someAsyncOperation()); + +// Access services +const db = env.DB; +const kv = env.KV; +const queue = env.QUEUE; +``` + +### Durable Objects + +```typescript +import { createMockDurableObjectNamespace } from '@/test-helpers'; + +const namespace = createMockDurableObjectNamespace(); +const id = namespace.newUniqueId(); +const stub = namespace.get(id); + +// Interact with stub +const response = await stub.fetch( + new Request('https://example.com/put', { + method: 'POST', + body: JSON.stringify({ key: 'data', value: 123 }), + }), +); +``` + +## Async Testing Utilities + +### Wait for Conditions + +```typescript +import { waitFor, sleep } from '@/test-helpers'; + +// Wait for async condition +await waitFor( + async () => { + const status = await checkStatus(); + return status === 'ready'; + }, + { timeout: 5000, interval: 100 }, +); + +// Simple delay +await sleep(1000); +``` + +### Retry with Backoff + +```typescript +import { retry } from '@/test-helpers'; + +const result = await retry( + async () => { + const response = await fetch('/api/data'); + if (!response.ok) throw new Error('Failed'); + return response.json(); + }, + { + maxAttempts: 5, + initialDelay: 100, + maxDelay: 5000, + onError: (error, attempt) => { + console.log(`Attempt ${attempt} failed:`, error); + }, + }, +); +``` + +### Event Testing + +```typescript +import { TestEventEmitter } from '@/test-helpers'; + +const emitter = new TestEventEmitter<{ + message: [string, number]; + error: [Error]; +}>(); + +// Wait for event +setTimeout(() => emitter.emit('message', 'hello', 42), 100); +const [msg, num] = await emitter.waitForEvent('message'); + +// Check history +const history = emitter.getEventHistory(); +expect(history[0].event).toBe('message'); +``` + +### Async Queue + +```typescript +import { AsyncQueue } from '@/test-helpers'; + +const queue = new AsyncQueue(); + +// Producer +queue.push('item1'); +queue.push('item2'); + +// Consumer +const item1 = await queue.pop(); // 'item1' +const item2 = await queue.pop(); // 'item2' +const item3 = await queue.pop(); // Will wait for next push +``` + +## Advanced Patterns + +### Integration Test Setup + +```typescript +import { + createMockWorkerEnv, + createMockD1Database, + TestDatabaseSeeder, + createUserFixture, +} from '@/test-helpers'; + +describe('Integration Test', () => { + let env: ReturnType; + let seeder: TestDatabaseSeeder; + + beforeEach(async () => { + env = createMockWorkerEnv(); + + // Seed database + seeder = new TestDatabaseSeeder(); + seeder.add('users', [ + createUserFixture({ id: 1, role: 'admin' }), + createUserFixture({ id: 2, role: 'user' }), + ]); + + // Set up expected queries + env.DB._setQueryResult(/SELECT.*FROM users/, seeder.fixtures.get('users')); + }); + + afterEach(() => { + env.DB._reset(); + env.KV._reset(); + }); +}); +``` + +### Performance Testing + +```typescript +import { parallelLimit, CacheTestUtils } from '@/test-helpers'; + +// Test concurrent operations +const operations = Array.from({ length: 100 }, (_, i) => async () => { + return await processItem(i); +}); + +const results = await parallelLimit(operations, 10); + +// Monitor cache performance +const monitor = CacheTestUtils.createCacheMonitor(); +monitor.recordSet('key', 'value'); +monitor.recordGet('key', true); + +const stats = monitor.getStats(); +console.log(`Cache hit rate: ${stats.hitRate}`); +``` + +### Snapshot Testing + +```typescript +import { createMockKVNamespace } from '@/test-helpers'; + +const kv = createMockKVNamespace(); + +// Populate data +await kv.put('config', JSON.stringify({ version: 1 })); +await kv.put('user:1', JSON.stringify({ name: 'Alice' })); + +// Take snapshot +const snapshot = kv._dump(); +expect(snapshot).toMatchSnapshot(); +``` + +## Best Practices + +1. **Reset Between Tests**: Always reset mocks between tests to avoid state leakage + + ```typescript + afterEach(() => { + mockDb._reset(); + mockKV._reset(); + vi.clearAllMocks(); + }); + ``` + +2. **Use Type-Safe Fixtures**: Leverage TypeScript for fixture typing + + ```typescript + interface CustomUser extends UserFixture { + customField: string; + } + + const user = createUserFixture({ + customField: 'value', + }); + ``` + +3. **Mock at the Right Level**: Mock external dependencies, not your own code + + ```typescript + // Good: Mock the database + mockDb._setQueryResult('SELECT * FROM users', users); + + // Bad: Mock your service method + vi.spyOn(userService, 'getUsers').mockResolvedValue(users); + ``` + +4. **Use Realistic Test Data**: Make fixtures as realistic as possible + + ```typescript + const user = createUserFixture({ + email: 'real.email@example.com', + createdAt: new Date('2024-01-01').toISOString(), + }); + ``` + +5. **Test Async Flows**: Use the async utilities for complex flows + + ```typescript + const emitter = new TestEventEmitter(); + const queue = new AsyncQueue(); + + // Simulate real async behavior + setTimeout(() => queue.push('data'), 100); + const data = await waitFor(() => queue.pop()); + ``` + +## Troubleshooting + +### Common Issues + +1. **Queries not matching**: Use regex patterns for flexible matching + + ```typescript + mockDb._setQueryResult(/SELECT.*FROM users.*WHERE/i, results); + ``` + +2. **Timing issues**: Use fake timers for time-dependent tests + + ```typescript + vi.useFakeTimers(); + // ... test code + vi.advanceTimersByTime(1000); + vi.useRealTimers(); + ``` + +3. **Memory leaks**: Always clean up resources + ```typescript + afterEach(() => { + emitter.removeAllListeners(); + queue.clear(); + }); + ``` + +## Contributing + +When adding new test helpers: + +1. Follow the existing patterns +2. Add comprehensive tests +3. Update this documentation +4. Consider backwards compatibility +5. Add TypeScript types for everything diff --git a/examples/test-helpers-example.ts b/examples/test-helpers-example.ts new file mode 100644 index 0000000..83286bc --- /dev/null +++ b/examples/test-helpers-example.ts @@ -0,0 +1,328 @@ +/** + * Test Helpers Example + * + * This example demonstrates how to use the comprehensive test helpers + * suite for testing a Cloudflare Workers application. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + createMockWorkerEnv, + createMockD1Database, + createMockKVNamespace, + createUserFixture, + FixtureGenerator, + TestQueryBuilder, + MockCacheService, + waitFor, + retry, + TestEventEmitter, +} from '../src/test-helpers/index.js'; + +// Example service to test +class UserService { + constructor( + private db: D1Database, + private kv: KVNamespace, + private cache: MockCacheService, + ) {} + + async getUser(id: number) { + // Try cache first + const cached = await this.cache.get(`user:${id}`); + if (cached) return cached; + + // Query database + const query = new TestQueryBuilder().select('*').from('users').where('id', '=', id).build(); + + const result = await this.db + .prepare(query.sql) + .bind(...query.params) + .first(); + + if (result) { + // Cache for 5 minutes + await this.cache.set(`user:${id}`, result, { ttl: 300, tags: ['users'] }); + } + + return result; + } + + async createUser(data: { name: string; email: string }) { + const id = Math.floor(Math.random() * 1000000); + + await this.db + .prepare('INSERT INTO users (id, name, email) VALUES (?, ?, ?)') + .bind(id, data.name, data.email) + .run(); + + // Store in KV for quick access + await this.kv.put(`user:${id}`, JSON.stringify({ id, ...data })); + + // Invalidate cache + await this.cache.purgeByTags(['users']); + + return { id, ...data }; + } + + async searchUsers(query: string) { + return await retry( + async () => { + const result = await this.db + .prepare('SELECT * FROM users WHERE name LIKE ?') + .bind(`%${query}%`) + .all(); + + if (!result.success) throw new Error('Query failed'); + return result.results; + }, + { maxAttempts: 3, initialDelay: 100 }, + ); + } +} + +// Example event-driven system +class NotificationService extends TestEventEmitter<{ + userCreated: [{ id: number; name: string }]; + userDeleted: [number]; + error: [Error]; +}> { + async notifyUserCreated(user: { id: number; name: string }) { + // Simulate async notification + await new Promise((resolve) => setTimeout(resolve, 100)); + this.emit('userCreated', user); + } +} + +describe('UserService with Test Helpers', () => { + let env: ReturnType; + let userService: UserService; + let notificationService: NotificationService; + let cache: MockCacheService; + + beforeEach(() => { + // Create mock environment + env = createMockWorkerEnv({ + API_KEY: 'test-api-key', + RATE_LIMIT: createMockKVNamespace(), + }); + + // Create services + cache = new MockCacheService(); + userService = new UserService(env.DB!, env.KV!, cache); + notificationService = new NotificationService(); + }); + + afterEach(() => { + // Clean up + env.DB!._reset(); + env.KV!._reset(); + cache.resetStats(); + notificationService.removeAllListeners(); + vi.clearAllMocks(); + }); + + describe('getUser', () => { + it('should return user from database', async () => { + const user = createUserFixture({ id: 123, username: 'alice' }); + + env.DB!._setQueryResult(/SELECT.*FROM users.*WHERE id = \?/, user); + + const result = await userService.getUser(123); + + expect(result).toEqual(user); + expect(env.DB!._queries).toHaveLength(1); + expect(env.DB!._queries[0].params).toEqual([123]); + }); + + it('should cache user data', async () => { + const user = createUserFixture({ id: 456 }); + env.DB!._setQueryResult(/SELECT.*FROM users/, user); + + // First call - from database + await userService.getUser(456); + expect(cache.getStats().misses).toBe(1); + + // Second call - from cache + const cached = await userService.getUser(456); + expect(cached).toEqual(user); + expect(cache.getStats().hits).toBe(1); + expect(env.DB!._queries).toHaveLength(1); // Only one DB query + }); + + it('should handle cache expiration', async () => { + vi.useFakeTimers(); + + const user = createUserFixture({ id: 789 }); + env.DB!._setQueryResult(/SELECT.*FROM users/, user); + + // Cache user + await userService.getUser(789); + + // Advance time past TTL + vi.advanceTimersByTime(301000); // 301 seconds + + // Should query database again + await userService.getUser(789); + expect(env.DB!._queries).toHaveLength(2); + + vi.useRealTimers(); + }); + }); + + describe('createUser', () => { + it('should create user and update caches', async () => { + const userData = { name: 'Bob', email: 'bob@example.com' }; + + env.DB!._setQueryResult(/INSERT/, { success: true }); + + const user = await userService.createUser(userData); + + expect(user).toMatchObject(userData); + expect(user.id).toBeDefined(); + + // Check KV was updated + const kvData = await env.KV!.get(`user:${user.id}`, { type: 'json' }); + expect(kvData).toEqual(user); + + // Check cache was purged + expect(cache.dump()).toEqual({}); + }); + + it('should emit notification on user creation', async () => { + const userData = { name: 'Charlie', email: 'charlie@example.com' }; + env.DB!._setQueryResult(/INSERT/, { success: true }); + + const user = await userService.createUser(userData); + + // Set up listener and notify + const notificationPromise = notificationService.waitForEvent('userCreated'); + await notificationService.notifyUserCreated(user); + + const [notifiedUser] = await notificationPromise; + expect(notifiedUser).toEqual(user); + + // Check event history + const history = notificationService.getEventHistory(); + expect(history).toHaveLength(1); + expect(history[0].event).toBe('userCreated'); + }); + }); + + describe('searchUsers', () => { + it('should retry on failure', async () => { + const users = new FixtureGenerator(createUserFixture).createMany(3); + let attempts = 0; + + env.DB!.prepare = vi.fn().mockImplementation(() => ({ + bind: vi.fn().mockReturnThis(), + all: vi.fn().mockImplementation(async () => { + attempts++; + if (attempts < 2) { + return { success: false }; + } + return { success: true, results: users }; + }), + })); + + const results = await userService.searchUsers('test'); + + expect(results).toEqual(users); + expect(attempts).toBe(2); + }); + }); + + describe('Integration Testing', () => { + it('should handle complete user flow', async () => { + // Set up fixtures + const existingUsers = new FixtureGenerator(createUserFixture).createMany(5); + + env.DB!._setMultipleResults( + new Map([ + [/SELECT.*FROM users WHERE id = \?/, existingUsers[0]], + [/SELECT.*FROM users WHERE name LIKE \?/, existingUsers.slice(0, 3)], + [/INSERT INTO users/, { success: true }], + ]), + ); + + // Test flow + const user = await userService.getUser(existingUsers[0].id as number); + expect(user).toEqual(existingUsers[0]); + + const searchResults = await userService.searchUsers('test'); + expect(searchResults).toHaveLength(3); + + const newUser = await userService.createUser({ + name: 'New User', + email: 'new@example.com', + }); + expect(newUser.id).toBeDefined(); + + // Verify final state + expect(cache.getStats().hits).toBeGreaterThan(0); + expect(env.KV!._size()).toBe(1); + expect(env.DB!._queries).toHaveLength(3); + }); + }); +}); + +// Performance testing example +describe('Performance Testing', () => { + it('should handle concurrent operations', async () => { + const env = createMockWorkerEnv(); + const cache = new MockCacheService(); + const service = new UserService(env.DB!, env.KV!, cache); + + // Create many users concurrently + const operations = Array.from({ length: 100 }, (_, i) => async () => { + return await service.createUser({ + name: `User ${i}`, + email: `user${i}@example.com`, + }); + }); + + const start = Date.now(); + const results = await Promise.all(operations); + const duration = Date.now() - start; + + expect(results).toHaveLength(100); + expect(duration).toBeLessThan(1000); // Should complete within 1 second + + console.log(`Created ${results.length} users in ${duration}ms`); + console.log(`Average: ${(duration / results.length).toFixed(2)}ms per user`); + }); +}); + +// Advanced mocking example +describe('Advanced Mocking', () => { + it('should simulate complex database behavior', async () => { + const db = createMockD1Database(); + + // Simulate pagination + const allUsers = new FixtureGenerator(createUserFixture).createMany(100); + + db.prepare = vi.fn().mockImplementation((sql: string) => { + const limitMatch = sql.match(/LIMIT (\d+)/); + const offsetMatch = sql.match(/OFFSET (\d+)/); + + const limit = limitMatch ? parseInt(limitMatch[1]) : 10; + const offset = offsetMatch ? parseInt(offsetMatch[1]) : 0; + + return { + bind: vi.fn().mockReturnThis(), + all: vi.fn().mockResolvedValue({ + results: allUsers.slice(offset, offset + limit), + success: true, + }), + }; + }); + + // Test pagination + const page1 = await db.prepare('SELECT * FROM users LIMIT 10 OFFSET 0').all(); + const page2 = await db.prepare('SELECT * FROM users LIMIT 10 OFFSET 10').all(); + + expect(page1.results).toHaveLength(10); + expect(page2.results).toHaveLength(10); + expect(page1.results[0]).not.toEqual(page2.results[0]); + }); +}); diff --git a/src/test-helpers/__tests__/test-helpers.test.ts b/src/test-helpers/__tests__/test-helpers.test.ts new file mode 100644 index 0000000..841bcf2 --- /dev/null +++ b/src/test-helpers/__tests__/test-helpers.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { createMockD1Database, createD1Result, SQLMatcher } from '../database/d1-helpers.js'; +import { + TestQueryBuilder, + createInsertQuery, + createUpdateQuery, +} from '../database/query-builder.js'; +import { createUserFixture, FixtureGenerator, TestDatabaseSeeder } from '../database/fixtures.js'; +import { createMockKVNamespace, KVTestUtils } from '../storage/kv-helpers.js'; +import { MockCacheService, CacheTestUtils } from '../storage/cache-helpers.js'; +import { + createMockWorkerEnv, + createMockExecutionContext, + EnvTestUtils, +} from '../platform/env-helpers.js'; +import { + waitFor, + retry, + parallelLimit, + TestEventEmitter, + AsyncQueue, +} from '../utils/async-helpers.js'; + +describe('Test Helpers Suite', () => { + describe('D1 Database Helpers', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockD1Database(); + }); + + it('should track queries', async () => { + await mockDb.prepare('SELECT * FROM users').all(); + await mockDb.prepare('INSERT INTO users (name) VALUES (?)').bind('John').run(); + + expect(mockDb._queries).toHaveLength(2); + expect(mockDb._queries[0].sql).toBe('SELECT * FROM users'); + expect(mockDb._queries[0].params).toBeUndefined(); + expect(mockDb._queries[1].sql).toBe('INSERT INTO users (name) VALUES (?)'); + expect(mockDb._queries[1].params).toEqual(['John']); + }); + + it('should return configured results', async () => { + const users = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + + mockDb._setQueryResult('SELECT * FROM users', users); + + const result = await mockDb.prepare('SELECT * FROM users').all(); + expect(result.results).toEqual(users); + }); + + it('should support regex patterns', async () => { + const matcher = new SQLMatcher(); + matcher.when(/SELECT.*FROM users WHERE id = \?/).thenReturn({ id: 1, name: 'Test' }); + + mockDb._setQueryResult( + /SELECT.*FROM users/, + matcher.match('SELECT * FROM users WHERE id = ?'), + ); + + const result = await mockDb.prepare('SELECT * FROM users WHERE id = ?').bind(1).first(); + expect(result).toEqual({ id: 1, name: 'Test' }); + }); + + it('should create proper result objects', () => { + const data = [{ id: 1 }, { id: 2 }]; + const result = createD1Result(data, { duration: 0.5, rowsRead: 2 }); + + expect(result.results).toEqual(data); + expect(result.meta.duration).toBe(0.5); + expect(result.meta.rows_read).toBe(2); + }); + }); + + describe('Query Builder', () => { + it('should build SELECT queries', () => { + const query = new TestQueryBuilder() + .select('id', 'name') + .from('users') + .where('active', '=', true) + .orderBy('created_at', 'DESC') + .limit(10) + .build(); + + expect(query.sql).toBe( + 'SELECT id, name FROM users WHERE active = ? ORDER BY created_at DESC LIMIT 10', + ); + expect(query.params).toEqual([true]); + }); + + it('should build INSERT queries', () => { + const query = createInsertQuery('users', { + name: 'John', + email: 'john@example.com', + active: true, + }); + + expect(query.sql).toBe('INSERT INTO users (name, email, active) VALUES (?, ?, ?)'); + expect(query.params).toEqual(['John', 'john@example.com', true]); + }); + + it('should build UPDATE queries', () => { + const query = createUpdateQuery('users', { name: 'Jane', updated_at: new Date() }, { id: 1 }); + + expect(query.sql).toMatch(/UPDATE users SET name = \?, updated_at = \? WHERE id = \?/); + expect(query.params[0]).toBe('Jane'); + expect(query.params[2]).toBe(1); + }); + }); + + describe('Fixtures', () => { + it('should create user fixtures', () => { + const user = createUserFixture({ + username: 'testuser', + role: 'admin', + }); + + expect(user.username).toBe('testuser'); + expect(user.role).toBe('admin'); + expect(user.telegramId).toBeDefined(); + expect(user.createdAt).toBeDefined(); + }); + + it('should generate multiple fixtures', () => { + const generator = new FixtureGenerator(createUserFixture); + const users = generator.createMany(5, (index) => ({ + username: `user${index}`, + })); + + expect(users).toHaveLength(5); + expect(users[0].username).toBe('user0'); + expect(users[4].username).toBe('user4'); + }); + + it('should create database seeder SQL', () => { + const seeder = new TestDatabaseSeeder(); + const users = [ + createUserFixture({ id: 1, username: 'alice' }), + createUserFixture({ id: 2, username: 'bob' }), + ]; + + seeder.add('users', users); + const sql = seeder.generateSQL(); + + expect(sql).toHaveLength(2); + expect(sql[0]).toContain('INSERT INTO users'); + expect(sql[0]).toContain('alice'); + }); + }); + + describe('KV Storage Helpers', () => { + let mockKV: ReturnType; + + beforeEach(() => { + mockKV = createMockKVNamespace(); + }); + + it('should store and retrieve values', async () => { + await mockKV.put('key1', 'value1'); + const value = await mockKV.get('key1'); + + expect(value).toBe('value1'); + expect(mockKV._size()).toBe(1); + }); + + it('should handle JSON values', async () => { + const data = { name: 'Test', count: 42 }; + await mockKV.put('json-key', JSON.stringify(data)); + + const retrieved = await mockKV.get('json-key', { type: 'json' }); + expect(retrieved).toEqual(data); + }); + + it('should support expiration', async () => { + vi.useFakeTimers(); + + await mockKV.put('temp-key', 'temp-value', { expirationTtl: 60 }); + expect(await mockKV.get('temp-key')).toBe('temp-value'); + + vi.advanceTimersByTime(61000); + expect(await mockKV.get('temp-key')).toBeNull(); + + vi.useRealTimers(); + }); + + it('should create namespaced KV', async () => { + const userKV = KVTestUtils.createNamespacedKV(mockKV, 'user'); + + await userKV.put('123', 'Alice'); + await mockKV.put('other:456', 'Bob'); + + const list = await userKV.list(); + expect(list.keys).toHaveLength(1); + expect(list.keys[0].name).toBe('user:123'); + }); + }); + + describe('Cache Helpers', () => { + let cache: MockCacheService; + + beforeEach(() => { + cache = new MockCacheService(); + }); + + it('should track cache statistics', async () => { + await cache.set('key1', 'value1'); + await cache.get('key1'); // hit + await cache.get('key2'); // miss + await cache.get('key1'); // hit + + const stats = cache.getStats(); + expect(stats.hits).toBe(2); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBeCloseTo(0.667, 2); + }); + + it('should support tag-based purging', async () => { + await cache.set('user:1', { name: 'Alice' }, { tags: ['users'] }); + await cache.set('user:2', { name: 'Bob' }, { tags: ['users'] }); + await cache.set('post:1', { title: 'Hello' }, { tags: ['posts'] }); + + await cache.purgeByTags(['users']); + + expect(await cache.get('user:1')).toBeNull(); + expect(await cache.get('user:2')).toBeNull(); + expect(await cache.get('post:1')).toEqual({ title: 'Hello' }); + }); + + it('should simulate expiration', async () => { + vi.useFakeTimers(); + + await cache.set('temp', 'value', { ttl: 60 }); + expect(await cache.get('temp')).toBe('value'); + + await CacheTestUtils.simulateExpiration(cache, 61); + expect(await cache.get('temp')).toBeNull(); + + vi.useRealTimers(); + }); + }); + + describe('Environment Helpers', () => { + it('should create mock worker environment', () => { + const env = createMockWorkerEnv({ + API_KEY: 'test-key', + CUSTOM_VAR: 'custom-value', + }); + + expect(env.DB).toBeDefined(); + expect(env.KV).toBeDefined(); + expect(env.API_KEY).toBe('test-key'); + expect(env.ENVIRONMENT).toBe('test'); + }); + + it('should create execution context', async () => { + const ctx = createMockExecutionContext(); + const promise = Promise.resolve('done'); + + ctx.waitUntil(promise); + + expect(ctx.waitUntil).toHaveBeenCalledWith(promise); + expect(ctx._promises).toContain(promise); + }); + + it('should validate environment variables', () => { + const validator = EnvTestUtils.createEnvValidator({ + API_KEY: (value) => typeof value === 'string' && value.length > 0, + PORT: (value) => typeof value === 'number' && value > 0, + }); + + const result = validator.validate({ + API_KEY: 'valid-key', + PORT: 3000, + }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('Async Helpers', () => { + it('should wait for condition', async () => { + let ready = false; + setTimeout(() => { + ready = true; + }, 100); + + await waitFor(() => ready, { timeout: 1000 }); + expect(ready).toBe(true); + }); + + it('should retry with backoff', async () => { + let attempts = 0; + + const result = await retry( + async () => { + attempts++; + if (attempts < 3) throw new Error('Not ready'); + return 'success'; + }, + { maxAttempts: 5, initialDelay: 10 }, + ); + + expect(result).toBe('success'); + expect(attempts).toBe(3); + }); + + it('should limit parallel execution', async () => { + let concurrent = 0; + let maxConcurrent = 0; + + const tasks = Array.from({ length: 10 }, () => async () => { + concurrent++; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await new Promise((resolve) => setTimeout(resolve, 50)); + concurrent--; + return concurrent; + }); + + await parallelLimit(tasks, 3); + expect(maxConcurrent).toBeLessThanOrEqual(3); + }); + + it('should handle event emitter', async () => { + const emitter = new TestEventEmitter<{ + data: [string, number]; + error: [Error]; + }>(); + + setTimeout(() => emitter.emit('data', 'test', 42), 50); + + const [message, value] = await emitter.waitForEvent('data'); + expect(message).toBe('test'); + expect(value).toBe(42); + + const history = emitter.getEventHistory(); + expect(history).toHaveLength(1); + expect(history[0].event).toBe('data'); + }); + + it('should handle async queue', async () => { + const queue = new AsyncQueue(); + + queue.push(1); + queue.push(2); + queue.push(3); + + expect(await queue.pop()).toBe(1); + expect(await queue.pop()).toBe(2); + expect(queue.size()).toBe(1); + }); + }); +}); diff --git a/src/test-helpers/database/d1-helpers.ts b/src/test-helpers/database/d1-helpers.ts new file mode 100644 index 0000000..116a224 --- /dev/null +++ b/src/test-helpers/database/d1-helpers.ts @@ -0,0 +1,199 @@ +import { vi } from 'vitest'; +import type { D1Database, D1PreparedStatement, D1Result } from '@cloudflare/workers-types'; + +/** + * Enhanced D1 mock with better type safety and query tracking + */ +export interface MockD1Database extends D1Database { + _queries: Array<{ sql: string; params?: unknown[] }>; + _setQueryResult: (sql: string | RegExp, result: unknown) => void; + _setMultipleResults: (results: Map) => void; + _reset: () => void; +} + +/** + * Create a fully-featured mock D1 database for testing + */ +export function createMockD1Database(): MockD1Database { + const queries: Array<{ sql: string; params?: unknown[] }> = []; + const queryResults = new Map(); + + const createStatement = (sql: string, initialParams?: unknown[]): D1PreparedStatement => { + const boundParams: unknown[] = initialParams || []; + + const executeQuery = async (method: 'first' | 'all' | 'run' | 'raw') => { + queries.push({ sql, params: boundParams.length > 0 ? boundParams : undefined }); + + // Find matching result + for (const [pattern, result] of queryResults) { + const matches = typeof pattern === 'string' ? sql.includes(pattern) : pattern.test(sql); + + if (matches) { + if (method === 'first') { + return Array.isArray(result) ? result[0] || null : result; + } else if (method === 'all') { + return { + results: Array.isArray(result) ? result : [result], + success: true, + meta: { duration: 0.1, rows_read: 1, rows_written: 0 }, + }; + } else if (method === 'run') { + return { + success: true, + meta: { + duration: 0.1, + last_row_id: 1, + changes: 1, + rows_read: 0, + rows_written: 1, + }, + }; + } else if (method === 'raw') { + return Array.isArray(result) ? result : [result]; + } + } + } + + // Default responses + if (method === 'first') return null; + if (method === 'all') return { results: [], success: true, meta: {} }; + if (method === 'run') return { success: true, meta: { changes: 0 } }; + return []; + }; + + return { + bind: vi.fn((...params: unknown[]) => { + return createStatement(sql, params); + }), + first: vi.fn(async () => executeQuery('first')), + all: vi.fn(async () => executeQuery('all')), + run: vi.fn(async () => executeQuery('run')), + raw: vi.fn(async () => executeQuery('raw')), + } as unknown as D1PreparedStatement; + }; + + const mockDb = { + prepare: vi.fn((sql: string) => createStatement(sql)), + + batch: vi.fn(async (statements: D1PreparedStatement[]) => { + const results: D1Result[] = []; + for (const stmt of statements) { + const result = await (stmt as any).run(); + results.push(result); + } + return results; + }), + + exec: vi.fn(async (sql: string) => { + queries.push({ sql }); + return { + results: [], + success: true, + meta: { duration: 0.1 }, + }; + }), + + // Test helpers + _queries: queries, + + _setQueryResult: (pattern: string | RegExp, result: unknown) => { + queryResults.set(pattern, result); + }, + + _setMultipleResults: (results: Map) => { + queryResults.clear(); + for (const [pattern, result] of results) { + queryResults.set(pattern, result); + } + }, + + _reset: () => { + queries.length = 0; + queryResults.clear(); + vi.clearAllMocks(); + }, + }; + + return mockDb as MockD1Database; +} + +/** + * Helper to create D1 result objects + */ +export function createD1Result( + data: T[], + options?: { + duration?: number; + rowsRead?: number; + rowsWritten?: number; + }, +): D1Result { + return { + results: data, + success: true, + meta: { + duration: options?.duration ?? 0.1, + rows_read: options?.rowsRead ?? data.length, + rows_written: options?.rowsWritten ?? 0, + }, + }; +} + +/** + * Helper for creating run results + */ +export function createD1RunResult(options?: { + success?: boolean; + changes?: number; + lastRowId?: number; + duration?: number; +}): D1Result { + return { + results: [], + success: options?.success ?? true, + meta: { + duration: options?.duration ?? 0.1, + last_row_id: options?.lastRowId ?? 1, + changes: options?.changes ?? 1, + rows_read: 0, + rows_written: options?.changes ?? 1, + }, + }; +} + +/** + * SQL query matcher for flexible testing + */ +export class SQLMatcher { + private patterns: Array<{ pattern: RegExp; result: unknown }> = []; + + when(pattern: string | RegExp): SQLMatcherResult { + const regex = + typeof pattern === 'string' ? new RegExp(pattern.replace(/\s+/g, '\\s+'), 'i') : pattern; + + const resultHolder = new SQLMatcherResult(); + this.patterns.push({ pattern: regex, result: resultHolder }); + return resultHolder; + } + + match(sql: string): unknown | null { + for (const { pattern, result } of this.patterns) { + if (pattern.test(sql)) { + return (result as SQLMatcherResult).result; + } + } + return null; + } +} + +class SQLMatcherResult { + private _result: unknown; + + thenReturn(result: unknown): void { + this._result = result; + } + + get result(): unknown { + return this._result; + } +} diff --git a/src/test-helpers/database/fixtures.ts b/src/test-helpers/database/fixtures.ts new file mode 100644 index 0000000..b33de16 --- /dev/null +++ b/src/test-helpers/database/fixtures.ts @@ -0,0 +1,224 @@ +/** + * Database fixtures and data generators for tests + */ + +import { randomUUID } from 'crypto'; + +/** + * Base fixture with common fields + */ +export interface BaseFixture { + id?: number | string; + createdAt?: Date | string; + updatedAt?: Date | string; +} + +/** + * User fixture + */ +export interface UserFixture extends BaseFixture { + telegramId?: string; + username?: string; + displayName?: string; + languageCode?: string; + isPremium?: boolean; + role?: string; + settings?: Record; +} + +/** + * Create a user fixture with defaults + */ +export function createUserFixture(overrides?: Partial): UserFixture { + const now = new Date().toISOString(); + return { + id: overrides?.id ?? Math.floor(Math.random() * 1000000), + telegramId: overrides?.telegramId ?? String(Math.floor(Math.random() * 1000000000)), + username: overrides?.username ?? `user_${Math.random().toString(36).substr(2, 9)}`, + displayName: overrides?.displayName ?? 'Test User', + languageCode: overrides?.languageCode ?? 'en', + isPremium: overrides?.isPremium ?? false, + role: overrides?.role ?? 'user', + settings: overrides?.settings ?? {}, + createdAt: overrides?.createdAt ?? now, + updatedAt: overrides?.updatedAt ?? now, + ...overrides, + }; +} + +/** + * Session fixture + */ +export interface SessionFixture extends BaseFixture { + userId?: number | string; + token?: string; + data?: Record; + expiresAt?: Date | string; +} + +/** + * Create a session fixture + */ +export function createSessionFixture(overrides?: Partial): SessionFixture { + const now = new Date(); + const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours + + return { + id: overrides?.id ?? randomUUID(), + userId: overrides?.userId ?? Math.floor(Math.random() * 1000000), + token: overrides?.token ?? randomUUID(), + data: overrides?.data ?? {}, + expiresAt: overrides?.expiresAt ?? expiresAt.toISOString(), + createdAt: overrides?.createdAt ?? now.toISOString(), + updatedAt: overrides?.updatedAt ?? now.toISOString(), + ...overrides, + }; +} + +/** + * Transaction fixture + */ +export interface TransactionFixture extends BaseFixture { + userId?: number | string; + type?: string; + amount?: number; + currency?: string; + status?: string; + metadata?: Record; +} + +/** + * Create a transaction fixture + */ +export function createTransactionFixture( + overrides?: Partial, +): TransactionFixture { + const now = new Date().toISOString(); + + return { + id: overrides?.id ?? randomUUID(), + userId: overrides?.userId ?? Math.floor(Math.random() * 1000000), + type: overrides?.type ?? 'payment', + amount: overrides?.amount ?? Math.floor(Math.random() * 10000), + currency: overrides?.currency ?? 'USD', + status: overrides?.status ?? 'completed', + metadata: overrides?.metadata ?? {}, + createdAt: overrides?.createdAt ?? now, + updatedAt: overrides?.updatedAt ?? now, + ...overrides, + }; +} + +/** + * Bulk fixture generator + */ +export class FixtureGenerator { + constructor(private factory: (overrides?: Partial) => T) {} + + /** + * Generate multiple fixtures + */ + createMany(count: number, overrides?: Partial | ((index: number) => Partial)): T[] { + return Array.from({ length: count }, (_, index) => { + const override = typeof overrides === 'function' ? overrides(index) : overrides; + return this.factory(override); + }); + } + + /** + * Generate fixtures with relationships + */ + createWithRelations( + count: number, + relationFactory: (parent: T, index: number) => R, + ): Array<{ parent: T; relations: R[] }> { + return Array.from({ length: count }, () => { + const parent = this.factory(); + const relations = Array.from({ length: Math.floor(Math.random() * 5) + 1 }, (_, relIndex) => + relationFactory(parent, relIndex), + ); + return { parent, relations }; + }); + } +} + +/** + * Database seeder for test environments + */ +export class TestDatabaseSeeder { + private fixtures: Map = new Map(); + + /** + * Add fixtures to be seeded + */ + add(table: string, data: unknown[]): this { + this.fixtures.set(table, data); + return this; + } + + /** + * Generate SQL statements for seeding + */ + generateSQL(): string[] { + const statements: string[] = []; + + for (const [table, rows] of this.fixtures) { + for (const row of rows) { + const data = row as Record; + const columns = Object.keys(data); + const values = Object.values(data).map((v) => + typeof v === 'string' ? `'${v}'` : String(v), + ); + + statements.push( + `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${values.join(', ')});`, + ); + } + } + + return statements; + } + + /** + * Clear all fixtures + */ + clear(): void { + this.fixtures.clear(); + } +} + +/** + * Common test data sets + */ +export const TEST_DATA = { + users: { + admin: createUserFixture({ + id: 1, + telegramId: '123456789', + username: 'admin', + displayName: 'Admin User', + role: 'admin', + }), + regular: createUserFixture({ + id: 2, + telegramId: '987654321', + username: 'user1', + displayName: 'Regular User', + role: 'user', + }), + premium: createUserFixture({ + id: 3, + telegramId: '456789123', + username: 'premium_user', + displayName: 'Premium User', + role: 'user', + isPremium: true, + }), + }, + + timestamps: { + past: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 1 week ago + now: new Date().toISOString(), + future: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 1 week later + }, +}; diff --git a/src/test-helpers/database/query-builder.ts b/src/test-helpers/database/query-builder.ts new file mode 100644 index 0000000..38c21b4 --- /dev/null +++ b/src/test-helpers/database/query-builder.ts @@ -0,0 +1,163 @@ +/** + * Test query builder for creating realistic SQL queries in tests + */ + +export interface TestQuery { + sql: string; + params: unknown[]; +} + +/** + * Fluent SQL query builder for tests + */ +export class TestQueryBuilder { + private table = ''; + private selectColumns: string[] = ['*']; + private whereConditions: Array<{ column: string; operator: string; value: unknown }> = []; + private orderByColumns: Array<{ column: string; direction: 'ASC' | 'DESC' }> = []; + private limitValue?: number; + private offsetValue?: number; + private joinClauses: Array<{ type: string; table: string; condition: string }> = []; + + select(...columns: string[]): this { + this.selectColumns = columns.length > 0 ? columns : ['*']; + return this; + } + + from(table: string): this { + this.table = table; + return this; + } + + where(column: string, operator: string, value: unknown): this { + this.whereConditions.push({ column, operator, value }); + return this; + } + + whereIn(column: string, values: unknown[]): this { + this.whereConditions.push({ column, operator: 'IN', value: values }); + return this; + } + + join(table: string, condition: string): this { + this.joinClauses.push({ type: 'JOIN', table, condition }); + return this; + } + + leftJoin(table: string, condition: string): this { + this.joinClauses.push({ type: 'LEFT JOIN', table, condition }); + return this; + } + + orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): this { + this.orderByColumns.push({ column, direction }); + return this; + } + + limit(value: number): this { + this.limitValue = value; + return this; + } + + offset(value: number): this { + this.offsetValue = value; + return this; + } + + build(): TestQuery { + const params: unknown[] = []; + let sql = `SELECT ${this.selectColumns.join(', ')} FROM ${this.table}`; + + // Add joins + for (const join of this.joinClauses) { + sql += ` ${join.type} ${join.table} ON ${join.condition}`; + } + + // Add where conditions + if (this.whereConditions.length > 0) { + const conditions = this.whereConditions.map(({ column, operator, value }) => { + if (operator === 'IN' && Array.isArray(value)) { + const placeholders = value + .map(() => { + params.push(value); + return '?'; + }) + .join(', '); + return `${column} IN (${placeholders})`; + } else { + params.push(value); + return `${column} ${operator} ?`; + } + }); + sql += ` WHERE ${conditions.join(' AND ')}`; + } + + // Add order by + if (this.orderByColumns.length > 0) { + const orderClauses = this.orderByColumns.map( + ({ column, direction }) => `${column} ${direction}`, + ); + sql += ` ORDER BY ${orderClauses.join(', ')}`; + } + + // Add limit/offset + if (this.limitValue !== undefined) { + sql += ` LIMIT ${this.limitValue}`; + } + if (this.offsetValue !== undefined) { + sql += ` OFFSET ${this.offsetValue}`; + } + + return { sql, params }; + } +} + +/** + * Helper to create INSERT queries + */ +export function createInsertQuery(table: string, data: Record): TestQuery { + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = columns.map(() => '?').join(', '); + + return { + sql: `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`, + params: values, + }; +} + +/** + * Helper to create UPDATE queries + */ +export function createUpdateQuery( + table: string, + data: Record, + where: Record, +): TestQuery { + const setColumns = Object.keys(data); + const setValues = Object.values(data); + const whereColumns = Object.keys(where); + const whereValues = Object.values(where); + + const setClauses = setColumns.map((col) => `${col} = ?`).join(', '); + const whereClauses = whereColumns.map((col) => `${col} = ?`).join(' AND '); + + return { + sql: `UPDATE ${table} SET ${setClauses} WHERE ${whereClauses}`, + params: [...setValues, ...whereValues], + }; +} + +/** + * Helper to create DELETE queries + */ +export function createDeleteQuery(table: string, where: Record): TestQuery { + const whereColumns = Object.keys(where); + const whereValues = Object.values(where); + const whereClauses = whereColumns.map((col) => `${col} = ?`).join(' AND '); + + return { + sql: `DELETE FROM ${table} WHERE ${whereClauses}`, + params: whereValues, + }; +} diff --git a/src/test-helpers/index.ts b/src/test-helpers/index.ts new file mode 100644 index 0000000..2ca7f7a --- /dev/null +++ b/src/test-helpers/index.ts @@ -0,0 +1,39 @@ +/** + * Comprehensive Test Helpers Suite + * + * A collection of utilities, mocks, and factories to simplify testing + * in TypeScript applications, especially those using Cloudflare Workers, + * D1 Database, KV storage, and messaging platforms. + */ + +// Database helpers +export * from './database/d1-helpers.js'; +export * from './database/query-builder.js'; +export * from './database/fixtures.js'; + +// Storage helpers +export * from './storage/kv-helpers.js'; +export * from './storage/cache-helpers.js'; + +// Platform helpers +export * from './platform/env-helpers.js'; +export * from './platform/context-helpers.js'; +export * from './platform/worker-helpers.js'; + +// Messaging helpers +export * from './messaging/telegram-helpers.js'; +export * from './messaging/discord-helpers.js'; + +// Service helpers +export * from './services/ai-helpers.js'; +export * from './services/monitoring-helpers.js'; + +// Utility helpers +export * from './utils/time-helpers.js'; +export * from './utils/async-helpers.js'; +export * from './utils/snapshot-helpers.js'; + +// Test factories +export * from './factories/user-factory.js'; +export * from './factories/message-factory.js'; +export * from './factories/event-factory.js'; diff --git a/src/test-helpers/platform/env-helpers.ts b/src/test-helpers/platform/env-helpers.ts new file mode 100644 index 0000000..7fddae9 --- /dev/null +++ b/src/test-helpers/platform/env-helpers.ts @@ -0,0 +1,244 @@ +import { vi } from 'vitest'; +import type { + ExecutionContext, + ScheduledController, + DurableObjectNamespace, +} from '@cloudflare/workers-types'; + +import { createMockD1Database } from '../database/d1-helpers.js'; +import { createMockKVNamespace } from '../storage/kv-helpers.js'; + +/** + * Comprehensive environment mock for Cloudflare Workers + */ +export interface MockWorkerEnv { + // Core services + DB?: ReturnType; + KV?: ReturnType; + CACHE?: ReturnType; + QUEUE?: MockQueue; + DURABLE_OBJECTS?: Record; + + // Environment variables + [key: string]: unknown; +} + +/** + * Create a mock worker environment with all services + */ +export function createMockWorkerEnv(overrides?: Partial): MockWorkerEnv { + return { + // Default services + DB: createMockD1Database(), + KV: createMockKVNamespace(), + CACHE: createMockKVNamespace(), + QUEUE: createMockQueue(), + + // Default environment variables + ENVIRONMENT: 'test', + LOG_LEVEL: 'debug', + API_URL: 'https://api.test.com', + WEBHOOK_SECRET: 'test-webhook-secret', + + // Apply overrides + ...overrides, + }; +} + +/** + * Mock Queue implementation + */ +export interface MockQueue { + send: ReturnType; + sendBatch: ReturnType; + _messages: Array<{ body: unknown; timestamp: number }>; + _reset: () => void; +} + +export function createMockQueue(): MockQueue { + const messages: Array<{ body: unknown; timestamp: number }> = []; + + return { + send: vi.fn(async (message: unknown) => { + messages.push({ body: message, timestamp: Date.now() }); + }), + + sendBatch: vi.fn(async (batch: Array<{ body: unknown }>) => { + const timestamp = Date.now(); + for (const item of batch) { + messages.push({ body: item.body, timestamp }); + } + }), + + _messages: messages, + _reset: () => { + messages.length = 0; + vi.clearAllMocks(); + }, + }; +} + +/** + * Mock Execution Context + */ +export function createMockExecutionContext(): ExecutionContext { + const promises: Promise[] = []; + + return { + waitUntil: vi.fn((promise: Promise) => { + promises.push(promise); + }), + + passThroughOnException: vi.fn(), + + // Test helper + _promises: promises, + } as ExecutionContext & { _promises: Promise[] }; +} + +/** + * Mock Scheduled Controller + */ +export function createMockScheduledController( + options?: Partial, +): ScheduledController { + return { + scheduledTime: options?.scheduledTime ?? Date.now(), + cron: options?.cron ?? '0 * * * *', + ...options, + } as ScheduledController; +} + +/** + * Mock Durable Object + */ +export interface MockDurableObjectNamespace extends DurableObjectNamespace { + _stubs: Map; +} + +export function createMockDurableObjectNamespace(): MockDurableObjectNamespace { + const stubs = new Map(); + + return { + newUniqueId: vi.fn(() => { + return { + toString: () => `mock-id-${Date.now()}-${Math.random()}`, + } as any; + }), + + idFromName: vi.fn((name: string) => { + return { + toString: () => `mock-id-from-${name}`, + } as any; + }), + + idFromString: vi.fn((id: string) => { + return { + toString: () => id, + } as any; + }), + + get: vi.fn((id: any) => { + const idString = id.toString(); + if (!stubs.has(idString)) { + stubs.set(idString, createMockDurableObjectStub(idString)); + } + return stubs.get(idString)!; + }), + + _stubs: stubs, + } as MockDurableObjectNamespace; +} + +export interface MockDurableObjectStub { + fetch: ReturnType; + _storage: Map; + _id: string; +} + +function createMockDurableObjectStub(id: string): MockDurableObjectStub { + const storage = new Map(); + + return { + fetch: vi.fn(async (request: Request) => { + // Simple storage operations via fetch + const url = new URL(request.url); + const path = url.pathname; + + if (path === '/get') { + const key = url.searchParams.get('key'); + const value = key ? storage.get(key) : null; + return new Response(JSON.stringify({ value })); + } + + if (path === '/put' && request.method === 'POST') { + const { key, value } = (await request.json()) as any; + storage.set(key, value); + return new Response(JSON.stringify({ success: true })); + } + + if (path === '/delete' && request.method === 'POST') { + const { key } = (await request.json()) as any; + storage.delete(key); + return new Response(JSON.stringify({ success: true })); + } + + return new Response('Not found', { status: 404 }); + }), + + _storage: storage, + _id: id, + }; +} + +/** + * Environment variable helpers + */ +export class EnvTestUtils { + /** + * Create a type-safe environment getter + */ + static createEnvGetter>(env: T) { + return { + get(key: K): T[K] { + return env[key]; + }, + + getRequired(key: K): NonNullable { + const value = env[key]; + if (value === null || value === undefined) { + throw new Error(`Missing required environment variable: ${String(key)}`); + } + return value as NonNullable; + }, + + getOrDefault(key: K, defaultValue: D): T[K] | D { + const value = env[key]; + return value !== null && value !== undefined ? value : defaultValue; + }, + }; + } + + /** + * Create environment validators + */ + static createEnvValidator(schema: Record boolean>) { + return { + validate(env: Record): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + for (const [key, validator] of Object.entries(schema)) { + const value = env[key]; + if (!validator(value)) { + errors.push(`Invalid environment variable: ${key}`); + } + } + + return { + valid: errors.length === 0, + errors, + }; + }, + }; + } +} diff --git a/src/test-helpers/storage/cache-helpers.ts b/src/test-helpers/storage/cache-helpers.ts new file mode 100644 index 0000000..7fa4dbd --- /dev/null +++ b/src/test-helpers/storage/cache-helpers.ts @@ -0,0 +1,279 @@ +import { vi } from 'vitest'; + +import type { + IKeyValueStore, + IEdgeCacheService, + CacheOptions, +} from '../../core/interfaces/index.js'; + +/** + * Mock cache service for testing + */ +export class MockCacheService implements IEdgeCacheService { + private cache = new Map(); + private hits = 0; + private misses = 0; + + async get(key: string): Promise { + const entry = this.cache.get(key); + + if (!entry) { + this.misses++; + return null; + } + + if (entry.expiry && entry.expiry < Date.now()) { + this.cache.delete(key); + this.misses++; + return null; + } + + this.hits++; + return entry.value as T; + } + + async set(key: string, value: T, options?: CacheOptions): Promise { + const expiry = options?.ttl ? Date.now() + options.ttl * 1000 : undefined; + this.cache.set(key, { value, expiry, tags: options?.tags }); + } + + async delete(key: string): Promise { + this.cache.delete(key); + } + + async has(key: string): Promise { + const entry = this.cache.get(key); + if (!entry) return false; + + if (entry.expiry && entry.expiry < Date.now()) { + this.cache.delete(key); + return false; + } + + return true; + } + + async clear(): Promise { + this.cache.clear(); + } + + async getOrSet(key: string, factory: () => Promise, options?: CacheOptions): Promise { + const cached = await this.get(key); + if (cached !== null) return cached; + + const value = await factory(); + await this.set(key, value, options); + return value; + } + + async cacheResponse(request: Request, response: Response, options?: CacheOptions): Promise { + const key = `response:${request.url}`; + await this.set(key, response.clone(), options); + } + + async getCachedResponse(request: Request): Promise { + const key = `response:${request.url}`; + return await this.get(key); + } + + async purgeByTags(tags: string[]): Promise { + const keysToDelete: string[] = []; + + for (const [key, entry] of this.cache) { + if (entry.tags && tags.some((tag) => entry.tags!.includes(tag))) { + keysToDelete.push(key); + } + } + + for (const key of keysToDelete) { + this.cache.delete(key); + } + } + + async warmUp( + keys: Array<{ + key: string; + factory: () => Promise; + options?: CacheOptions; + }>, + ): Promise { + await Promise.all( + keys.map(({ key, factory, options }) => this.getOrSet(key, factory, options)), + ); + } + + // Test helpers + getStats() { + return { + hits: this.hits, + misses: this.misses, + hitRate: this.hits / (this.hits + this.misses) || 0, + size: this.cache.size, + }; + } + + resetStats() { + this.hits = 0; + this.misses = 0; + } + + dump() { + const entries: Record = {}; + for (const [key, entry] of this.cache) { + entries[key] = entry; + } + return entries; + } +} + +/** + * Create a mock KV store with cache-like behavior + */ +export function createMockCacheStore(): IKeyValueStore { + const store = new Map(); + + return { + get: vi.fn(async (key: string) => store.get(key) || null), + + put: vi.fn(async (key: string, value: string) => { + store.set(key, value); + }), + + delete: vi.fn(async (key: string) => { + store.delete(key); + }), + + list: vi.fn(async (prefix?: string) => { + const keys = Array.from(store.keys()) + .filter((key) => !prefix || key.startsWith(prefix)) + .map((key) => ({ key, value: store.get(key)! })); + return keys; + }), + }; +} + +/** + * Cache testing utilities + */ +export class CacheTestUtils { + /** + * Simulate cache expiration by advancing time + */ + static async simulateExpiration(cache: MockCacheService, seconds: number): Promise { + const advanceTime = seconds * 1000; + vi.advanceTimersByTime(advanceTime); + + // Force cache to check expirations + const keys = Array.from((cache as any).cache.keys()); + for (const key of keys) { + await cache.has(key); + } + } + + /** + * Create a cache monitor for tracking operations + */ + static createCacheMonitor() { + const operations: Array<{ + type: 'get' | 'set' | 'delete' | 'clear'; + key?: string; + value?: unknown; + timestamp: number; + }> = []; + + return { + recordGet(key: string, hit: boolean): void { + operations.push({ + type: 'get', + key, + value: hit, + timestamp: Date.now(), + }); + }, + + recordSet(key: string, value: unknown): void { + operations.push({ + type: 'set', + key, + value, + timestamp: Date.now(), + }); + }, + + recordDelete(key: string): void { + operations.push({ + type: 'delete', + key, + timestamp: Date.now(), + }); + }, + + recordClear(): void { + operations.push({ + type: 'clear', + timestamp: Date.now(), + }); + }, + + getOperations() { + return operations; + }, + + clear() { + operations.length = 0; + }, + + getStats() { + const stats = { + totalOps: operations.length, + gets: operations.filter((op) => op.type === 'get').length, + hits: operations.filter((op) => op.type === 'get' && op.value === true).length, + sets: operations.filter((op) => op.type === 'set').length, + deletes: operations.filter((op) => op.type === 'delete').length, + clears: operations.filter((op) => op.type === 'clear').length, + }; + + return { + ...stats, + hitRate: stats.gets > 0 ? stats.hits / stats.gets : 0, + }; + }, + }; + } + + /** + * Create a cache wrapper that tracks TTL effectiveness + */ + static createTTLTracker(cache: IEdgeCacheService) { + const ttlData = new Map(); + + return { + async set(key: string, value: T, options?: CacheOptions): Promise { + if (options?.ttl) { + ttlData.set(key, { setTime: Date.now(), ttl: options.ttl }); + } + await cache.set(key, value, options); + }, + + async checkTTL(key: string): Promise<{ expired: boolean; remainingTTL: number }> { + const data = ttlData.get(key); + if (!data) return { expired: true, remainingTTL: 0 }; + + const elapsed = (Date.now() - data.setTime) / 1000; + const remainingTTL = Math.max(0, data.ttl - elapsed); + + return { + expired: remainingTTL <= 0, + remainingTTL, + }; + }, + + getAverageTTL(): number { + if (ttlData.size === 0) return 0; + + const ttls = Array.from(ttlData.values()).map((d) => d.ttl); + return ttls.reduce((sum, ttl) => sum + ttl, 0) / ttls.length; + }, + }; + } +} diff --git a/src/test-helpers/storage/kv-helpers.ts b/src/test-helpers/storage/kv-helpers.ts new file mode 100644 index 0000000..1d148c3 --- /dev/null +++ b/src/test-helpers/storage/kv-helpers.ts @@ -0,0 +1,241 @@ +import { vi } from 'vitest'; +import type { KVNamespace, KVListResult } from '@cloudflare/workers-types'; + +/** + * Enhanced KV mock with in-memory storage and expiration support + */ +export interface MockKVNamespace extends KVNamespace { + _storage: Map; + _reset: () => void; + _size: () => number; + _dump: () => Record; +} + +/** + * Create a mock KV namespace with full functionality + */ +export function createMockKVNamespace(): MockKVNamespace { + const storage = new Map(); + + // Clean up expired entries + const cleanExpired = () => { + const now = Date.now(); + for (const [key, data] of storage) { + if (data.expiration && data.expiration < now) { + storage.delete(key); + } + } + }; + + const mockKV = { + get: vi.fn(async (key: string, options?: { type?: string; cacheTtl?: number }) => { + cleanExpired(); + const data = storage.get(key); + + if (!data) return null; + + if (options?.type === 'json') { + try { + return JSON.parse(data.value); + } catch { + return null; + } + } else if (options?.type === 'arrayBuffer') { + return new TextEncoder().encode(data.value).buffer; + } else if (options?.type === 'stream') { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(data.value)); + controller.close(); + }, + }); + } + + return data.value; + }), + + getWithMetadata: vi.fn(async (key: string, options?: { type?: string; cacheTtl?: number }) => { + cleanExpired(); + const data = storage.get(key); + + if (!data) return { value: null, metadata: null }; + + let value: unknown = data.value; + + if (options?.type === 'json') { + try { + value = JSON.parse(data.value); + } catch { + value = null; + } + } + + return { value, metadata: data.metadata || null }; + }), + + put: vi.fn( + async ( + key: string, + value: string | ArrayBuffer | ArrayBufferView | ReadableStream, + options?: { + expiration?: number; + expirationTtl?: number; + metadata?: unknown; + }, + ) => { + let stringValue: string; + + if (typeof value === 'string') { + stringValue = value; + } else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + stringValue = new TextDecoder().decode(value as ArrayBuffer); + } else if (value instanceof ReadableStream) { + const reader = value.getReader(); + const chunks: Uint8Array[] = []; + + while (true) { + const { done, value: chunk } = await reader.read(); + if (done) break; + chunks.push(chunk); + } + + stringValue = new TextDecoder().decode(Buffer.concat(chunks)); + } else { + stringValue = JSON.stringify(value); + } + + const expiration = options?.expiration + ? options.expiration * 1000 + : options?.expirationTtl + ? Date.now() + options.expirationTtl * 1000 + : undefined; + + storage.set(key, { + value: stringValue, + expiration, + metadata: options?.metadata, + }); + }, + ), + + delete: vi.fn(async (key: string) => { + storage.delete(key); + }), + + list: vi.fn( + async (options?: { + prefix?: string; + limit?: number; + cursor?: string; + }): Promise => { + cleanExpired(); + + let keys = Array.from(storage.keys()); + + // Filter by prefix + if (options?.prefix) { + keys = keys.filter((key) => key.startsWith(options.prefix!)); + } + + // Handle cursor-based pagination + let startIndex = 0; + if (options?.cursor) { + const cursorIndex = parseInt(options.cursor, 10); + if (!isNaN(cursorIndex)) { + startIndex = cursorIndex; + } + } + + // Apply limit + const limit = options?.limit || 1000; + const endIndex = startIndex + limit; + const paginatedKeys = keys.slice(startIndex, endIndex); + + const list_complete = endIndex >= keys.length; + const cursor = list_complete ? null : String(endIndex); + + return { + keys: paginatedKeys.map((name) => ({ + name, + expiration: storage.get(name)?.expiration, + metadata: storage.get(name)?.metadata, + })), + list_complete, + cursor, + }; + }, + ), + + // Test helpers + _storage: storage, + _reset: () => { + storage.clear(); + vi.clearAllMocks(); + }, + _size: () => storage.size, + _dump: () => { + const dump: Record = {}; + for (const [key, data] of storage) { + dump[key] = { + value: data.value, + expiration: data.expiration, + metadata: data.metadata, + }; + } + return dump; + }, + }; + + return mockKV as MockKVNamespace; +} + +/** + * KV test utilities + */ +export class KVTestUtils { + /** + * Create a batch writer for efficient bulk operations + */ + static createBatchWriter(kv: KVNamespace) { + const operations: Array<() => Promise> = []; + + return { + put(key: string, value: unknown, options?: Parameters[2]): void { + operations.push(async () => { + const stringValue = typeof value === 'string' ? value : JSON.stringify(value); + await kv.put(key, stringValue, options); + }); + }, + + delete(key: string): void { + operations.push(async () => { + await kv.delete(key); + }); + }, + + async flush(): Promise { + await Promise.all(operations.map((op) => op())); + operations.length = 0; + }, + }; + } + + /** + * Create a namespace prefixer + */ + static createNamespacedKV(kv: KVNamespace, prefix: string): KVNamespace { + const prefixKey = (key: string) => `${prefix}:${key}`; + + return { + get: (key: string, options?: any) => kv.get(prefixKey(key), options), + getWithMetadata: (key: string, options?: any) => kv.getWithMetadata(prefixKey(key), options), + put: (key: string, value: any, options?: any) => kv.put(prefixKey(key), value, options), + delete: (key: string) => kv.delete(prefixKey(key)), + list: (options?: any) => + kv.list({ + ...options, + prefix: options?.prefix ? `${prefix}:${options.prefix}` : `${prefix}:`, + }), + } as KVNamespace; + } +} diff --git a/src/test-helpers/utils/async-helpers.ts b/src/test-helpers/utils/async-helpers.ts new file mode 100644 index 0000000..572466e --- /dev/null +++ b/src/test-helpers/utils/async-helpers.ts @@ -0,0 +1,231 @@ +/** + * Async testing utilities + */ + +/** + * Wait for a condition to be true + */ +export async function waitFor( + condition: () => boolean | Promise, + options?: { + timeout?: number; + interval?: number; + message?: string; + }, +): Promise { + const timeout = options?.timeout ?? 5000; + const interval = options?.interval ?? 50; + const message = options?.message ?? 'Condition not met'; + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (await condition()) { + return; + } + await sleep(interval); + } + + throw new Error(`Timeout: ${message}`); +} + +/** + * Sleep for a specified duration + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Create a deferred promise + */ +export interface DeferredPromise { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +} + +export function createDeferred(): DeferredPromise { + let resolve: (value: T) => void; + let reject: (error: Error) => void; + + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + return { promise, resolve: resolve!, reject: reject! }; +} + +/** + * Retry a function with exponential backoff + */ +export async function retry( + fn: () => Promise, + options?: { + maxAttempts?: number; + initialDelay?: number; + maxDelay?: number; + factor?: number; + onError?: (error: Error, attempt: number) => void; + }, +): Promise { + const maxAttempts = options?.maxAttempts ?? 3; + const initialDelay = options?.initialDelay ?? 100; + const maxDelay = options?.maxDelay ?? 5000; + const factor = options?.factor ?? 2; + + let lastError: Error; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + + if (options?.onError) { + options.onError(lastError, attempt); + } + + if (attempt < maxAttempts) { + const delay = Math.min(initialDelay * Math.pow(factor, attempt - 1), maxDelay); + await sleep(delay); + } + } + } + + throw lastError!; +} + +/** + * Run functions in parallel with concurrency limit + */ +export async function parallelLimit( + tasks: Array<() => Promise>, + limit: number, +): Promise { + const results: T[] = new Array(tasks.length); + const executing: Set> = new Set(); + + for (const [index, task] of tasks.entries()) { + const promise = task() + .then((result) => { + results[index] = result; + return result; + }) + .finally(() => { + executing.delete(promise); + }); + + executing.add(promise); + + if (executing.size >= limit) { + await Promise.race(executing); + } + } + + await Promise.all(executing); + return results; +} + +/** + * Create a timeout wrapper for promises + */ +export function withTimeout( + promise: Promise, + timeoutMs: number, + errorMessage?: string, +): Promise { + return Promise.race([ + promise, + sleep(timeoutMs).then(() => { + throw new Error(errorMessage ?? `Operation timed out after ${timeoutMs}ms`); + }), + ]); +} + +/** + * Event emitter for testing async flows + */ +export class TestEventEmitter> { + private listeners = new Map void>>(); + private eventHistory: Array<{ event: keyof T; args: unknown[]; timestamp: number }> = []; + + on(event: K, listener: (...args: T[K]) => void): void { + const listeners = this.listeners.get(event) || []; + listeners.push(listener); + this.listeners.set(event, listeners); + } + + emit(event: K, ...args: T[K]): void { + this.eventHistory.push({ + event, + args, + timestamp: Date.now(), + }); + + const listeners = this.listeners.get(event) || []; + for (const listener of listeners) { + listener(...args); + } + } + + async waitForEvent(event: K, timeout = 5000): Promise { + return withTimeout( + new Promise((resolve) => { + this.on(event, (...args: T[K]) => resolve(args)); + }), + timeout, + `Timeout waiting for event: ${String(event)}`, + ); + } + + getEventHistory() { + return this.eventHistory; + } + + clearHistory() { + this.eventHistory = []; + } + + removeAllListeners() { + this.listeners.clear(); + } +} + +/** + * Async queue for testing sequential operations + */ +export class AsyncQueue { + private queue: T[] = []; + private waiters: Array<(value: T) => void> = []; + + push(item: T): void { + const waiter = this.waiters.shift(); + if (waiter) { + waiter(item); + } else { + this.queue.push(item); + } + } + + async pop(): Promise { + const item = this.queue.shift(); + if (item !== undefined) { + return item; + } + + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + size(): number { + return this.queue.length; + } + + clear(): void { + this.queue = []; + this.waiters = []; + } +}