From 810c562f865f23ae6e67d8f0fd3477fc2b4e47ea Mon Sep 17 00:00:00 2001 From: geoffsee <> Date: Sat, 31 May 2025 19:36:13 -0400 Subject: [PATCH 1/4] add server test suite --- vite.config.ts | 2 +- workers/site/__tests__/AssetService.test.ts | 189 ++++++++++++++++++ workers/site/__tests__/api-router.test.ts | 16 ++ workers/site/services/AssetService.ts | 2 +- .../services/__tests__/AssetService.test.ts | 164 +++++++++++++++ 5 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 workers/site/__tests__/AssetService.test.ts create mode 100644 workers/site/__tests__/api-router.test.ts create mode 100644 workers/site/services/__tests__/AssetService.test.ts diff --git a/vite.config.ts b/vite.config.ts index b0b083a..8b9da68 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -85,7 +85,7 @@ export default defineConfig(({command}) => { environment: 'jsdom', registerNodeLoader: false, setupFiles: ['./src/test/setup.ts'], - exclude: [...configDefaults.exclude, 'workers/**', 'dist/**'], + exclude: [...configDefaults.exclude, 'dist/**'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], diff --git a/workers/site/__tests__/AssetService.test.ts b/workers/site/__tests__/AssetService.test.ts new file mode 100644 index 0000000..2a3b4b7 --- /dev/null +++ b/workers/site/__tests__/AssetService.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getSnapshot, Instance } from 'mobx-state-tree'; +import AssetService from '../services/AssetService'; + +// Define types for testing +type AssetServiceInstance = Instance; + +// Mock the vike/server module +vi.mock('vike/server', () => ({ + renderPage: vi.fn(), +})); + +// Import the mocked renderPage function for assertions +import { renderPage } from 'vike/server'; + +// Mock global types +vi.stubGlobal('ReadableStream', class MockReadableStream {}); +vi.stubGlobal('Response', class MockResponse { + status: number; + headers: Headers; + body: any; + + constructor(body?: any, init?: ResponseInit) { + this.body = body; + this.status = init?.status || 200; + this.headers = new Headers(init?.headers); + } + + clone() { + return this; + } + + async text() { + return this.body?.toString() || ''; + } +}); + +describe('AssetService', () => { + let assetService: AssetServiceInstance; + + beforeEach(() => { + // Create a new instance of the service before each test + assetService = AssetService.create(); + + // Reset mocks + vi.resetAllMocks(); + }); + + describe('Initial state', () => { + it('should have empty env and ctx objects initially', () => { + expect(assetService.env).toEqual({}); + expect(assetService.ctx).toEqual({}); + }); + }); + + describe('setEnv', () => { + it('should set the environment', () => { + const mockEnv = { ASSETS: { fetch: vi.fn() } }; + assetService.setEnv(mockEnv); + expect(assetService.env).toEqual(mockEnv); + }); + }); + + describe('setCtx', () => { + it('should set the execution context', () => { + const mockCtx = { waitUntil: vi.fn() }; + assetService.setCtx(mockCtx); + expect(assetService.ctx).toEqual(mockCtx); + }); + }); + + describe('handleSsr', () => { + it('should return null when httpResponse is not available', async () => { + // Setup mock to return a pageContext without httpResponse + vi.mocked(renderPage).mockResolvedValue({}); + + const url = 'https://example.com'; + const headers = new Headers(); + const env = {}; + + const result = await assetService.handleSsr(url, headers, env); + + // Verify renderPage was called with correct arguments + expect(renderPage).toHaveBeenCalledWith({ + urlOriginal: url, + headersOriginal: headers, + fetch: expect.any(Function), + env, + }); + + // Verify result is null + expect(result).toBeNull(); + }); + + it('should return a Response when httpResponse is available', async () => { + // Create mock stream + const mockStream = new ReadableStream(); + + // Setup mock to return a pageContext with httpResponse + vi.mocked(renderPage).mockResolvedValue({ + httpResponse: { + statusCode: 200, + headers: new Headers({ 'Content-Type': 'text/html' }), + getReadableWebStream: () => mockStream, + }, + }); + + const url = 'https://example.com'; + const headers = new Headers(); + const env = {}; + + const result = await assetService.handleSsr(url, headers, env); + + // Verify renderPage was called with correct arguments + expect(renderPage).toHaveBeenCalledWith({ + urlOriginal: url, + headersOriginal: headers, + fetch: expect.any(Function), + env, + }); + + // Verify result is a Response with correct properties + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(200); + expect(result.headers.get('Content-Type')).toBe('text/html'); + }); + }); + + describe('handleStaticAssets', () => { + it('should fetch assets from the environment', async () => { + // Create mock request + const request = new Request('https://example.com/static/image.png'); + + // Create mock response + const mockResponse = new Response('Mock asset content', { + status: 200, + headers: { 'Content-Type': 'image/png' }, + }); + + // Create mock environment with ASSETS.fetch + const mockEnv = { + ASSETS: { + fetch: vi.fn().mockResolvedValue(mockResponse), + }, + }; + + // Set the environment + assetService.setEnv(mockEnv); + + // Call the method + const result = await assetService.handleStaticAssets(request, mockEnv); + + // Verify ASSETS.fetch was called with the request + expect(mockEnv.ASSETS.fetch).toHaveBeenCalledWith(request); + + // Verify result is the expected response + expect(result).toBe(mockResponse); + }); + + it('should return a 404 response when an error occurs', async () => { + // Create mock request + const request = new Request('https://example.com/static/not-found.png'); + + // Create mock environment with ASSETS.fetch that throws an error + const mockEnv = { + ASSETS: { + fetch: vi.fn().mockRejectedValue(new Error('Asset not found')), + }, + }; + + // Set the environment + assetService.setEnv(mockEnv); + + // Call the method + const result = await assetService.handleStaticAssets(request, mockEnv); + + // Verify ASSETS.fetch was called with the request + expect(mockEnv.ASSETS.fetch).toHaveBeenCalledWith(request); + + // Verify result is a 404 Response + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(404); + + // Verify response body + const text = await result.clone().text(); + expect(text).toBe('Asset not found'); + }); + }); +}); diff --git a/workers/site/__tests__/api-router.test.ts b/workers/site/__tests__/api-router.test.ts new file mode 100644 index 0000000..55ab870 --- /dev/null +++ b/workers/site/__tests__/api-router.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createRouter } from '../api-router'; + +// Mock the vike/server module +vi.mock('vike/server', () => ({ + renderPage: vi.fn() +})); + +describe('api-router', () => { + // Test that the router is created successfully + it('creates a router', () => { + const router = createRouter(); + expect(router).toBeDefined(); + expect(typeof router.handle).toBe('function'); + }); +}); \ No newline at end of file diff --git a/workers/site/services/AssetService.ts b/workers/site/services/AssetService.ts index 999cdb6..53a29b0 100644 --- a/workers/site/services/AssetService.ts +++ b/workers/site/services/AssetService.ts @@ -40,7 +40,7 @@ export default types async handleStaticAssets(request: Request, env) { console.log("handleStaticAssets"); try { - return env.ASSETS.fetch(request); + return await env.ASSETS.fetch(request); } catch (error) { console.error("Error serving static asset:", error); return new Response("Asset not found", { status: 404 }); diff --git a/workers/site/services/__tests__/AssetService.test.ts b/workers/site/services/__tests__/AssetService.test.ts new file mode 100644 index 0000000..cb56162 --- /dev/null +++ b/workers/site/services/__tests__/AssetService.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getSnapshot } from 'mobx-state-tree'; +import AssetService from '../AssetService'; + +// Mock the vike/server module +vi.mock('vike/server', () => ({ + renderPage: vi.fn(), +})); + +// Import the mocked renderPage function for assertions +import { renderPage } from 'vike/server'; + +describe('AssetService', () => { + let assetService; + + beforeEach(() => { + // Create a new instance of the service before each test + assetService = AssetService.create(); + + // Reset mocks + vi.resetAllMocks(); + }); + + describe('Initial state', () => { + it('should have empty env and ctx objects initially', () => { + expect(assetService.env).toEqual({}); + expect(assetService.ctx).toEqual({}); + }); + }); + + describe('setEnv', () => { + it('should set the environment', () => { + const mockEnv = { ASSETS: { fetch: vi.fn() } }; + assetService.setEnv(mockEnv); + expect(assetService.env).toEqual(mockEnv); + }); + }); + + describe('setCtx', () => { + it('should set the execution context', () => { + const mockCtx = { waitUntil: vi.fn() }; + assetService.setCtx(mockCtx); + expect(assetService.ctx).toEqual(mockCtx); + }); + }); + + describe('handleSsr', () => { + it('should return null when httpResponse is not available', async () => { + // Setup mock to return a pageContext without httpResponse + vi.mocked(renderPage).mockResolvedValue({}); + + const url = 'https://example.com'; + const headers = new Headers(); + const env = {}; + + const result = await assetService.handleSsr(url, headers, env); + + // Verify renderPage was called with correct arguments + expect(renderPage).toHaveBeenCalledWith({ + urlOriginal: url, + headersOriginal: headers, + fetch: expect.any(Function), + env, + }); + + // Verify result is null + expect(result).toBeNull(); + }); + + it('should return a Response when httpResponse is available', async () => { + // Create mock stream + const mockStream = new ReadableStream(); + + // Setup mock to return a pageContext with httpResponse + vi.mocked(renderPage).mockResolvedValue({ + httpResponse: { + statusCode: 200, + headers: new Headers({ 'Content-Type': 'text/html' }), + getReadableWebStream: () => mockStream, + }, + }); + + const url = 'https://example.com'; + const headers = new Headers(); + const env = {}; + + const result = await assetService.handleSsr(url, headers, env); + + // Verify renderPage was called with correct arguments + expect(renderPage).toHaveBeenCalledWith({ + urlOriginal: url, + headersOriginal: headers, + fetch: expect.any(Function), + env, + }); + + // Verify result is a Response with correct properties + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(200); + expect(result.headers.get('Content-Type')).toBe('text/html'); + }); + }); + + describe('handleStaticAssets', () => { + it('should fetch assets from the environment', async () => { + // Create mock request + const request = new Request('https://example.com/static/image.png'); + + // Create mock response + const mockResponse = new Response('Mock asset content', { + status: 200, + headers: { 'Content-Type': 'image/png' }, + }); + + // Create mock environment with ASSETS.fetch + const mockEnv = { + ASSETS: { + fetch: vi.fn().mockResolvedValue(mockResponse), + }, + }; + + // Set the environment + assetService.setEnv(mockEnv); + + // Call the method + const result = await assetService.handleStaticAssets(request, mockEnv); + + // Verify ASSETS.fetch was called with the request + expect(mockEnv.ASSETS.fetch).toHaveBeenCalledWith(request); + + // Verify result is the expected response + expect(result).toBe(mockResponse); + }); + + it('should return a 404 response when an error occurs', async () => { + // Create mock request + const request = new Request('https://example.com/static/not-found.png'); + + // Create mock environment with ASSETS.fetch that throws an error + const mockEnv = { + ASSETS: { + fetch: vi.fn().mockRejectedValue(new Error('Asset not found')), + }, + }; + + // Set the environment + assetService.setEnv(mockEnv); + + // Call the method + const result = await assetService.handleStaticAssets(request, mockEnv); + + // Verify ASSETS.fetch was called with the request + expect(mockEnv.ASSETS.fetch).toHaveBeenCalledWith(request); + + // Verify result is a 404 Response + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(404); + + // Verify response body + const text = await result.clone().text(); + expect(text).toBe('Asset not found'); + }); + }); +}); \ No newline at end of file From ec2435bf0ce6f381ab271ffe796d495ec9e0e17a Mon Sep 17 00:00:00 2001 From: geoffsee <> Date: Sat, 31 May 2025 19:50:45 -0400 Subject: [PATCH 2/4] Add unit tests for ContactService, FeedbackService, and MetricsService --- .../services/__tests__/ContactService.test.ts | 150 +++++++++++++ .../__tests__/FeedbackService.test.ts | 203 ++++++++++++++++++ .../services/__tests__/MetricsService.test.ts | 136 ++++++++++++ 3 files changed, 489 insertions(+) create mode 100644 workers/site/services/__tests__/ContactService.test.ts create mode 100644 workers/site/services/__tests__/FeedbackService.test.ts create mode 100644 workers/site/services/__tests__/MetricsService.test.ts diff --git a/workers/site/services/__tests__/ContactService.test.ts b/workers/site/services/__tests__/ContactService.test.ts new file mode 100644 index 0000000..b37d195 --- /dev/null +++ b/workers/site/services/__tests__/ContactService.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getSnapshot } from 'mobx-state-tree'; +import ContactService from '../ContactService'; +import ContactRecord from '../../models/ContactRecord'; + +describe('ContactService', () => { + let contactService; + + beforeEach(() => { + // Create a new instance of the service before each test + contactService = ContactService.create(); + + // Reset mocks + vi.resetAllMocks(); + }); + + describe('Initial state', () => { + it('should have empty env and ctx objects initially', () => { + expect(contactService.env).toEqual({}); + expect(contactService.ctx).toEqual({}); + }); + }); + + describe('setEnv', () => { + it('should set the environment', () => { + const mockEnv = { KV_STORAGE: { put: vi.fn() }, EMAIL_SERVICE: { sendMail: vi.fn() } }; + contactService.setEnv(mockEnv); + expect(contactService.env).toEqual(mockEnv); + }); + }); + + describe('setCtx', () => { + it('should set the execution context', () => { + const mockCtx = { waitUntil: vi.fn() }; + contactService.setCtx(mockCtx); + expect(contactService.ctx).toEqual(mockCtx); + }); + }); + + describe('handleContact', () => { + it('should process a valid contact request and return a success response', async () => { + // Mock crypto.randomUUID + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue('mock-uuid'), + }); + + // Mock date for consistent testing + const mockDate = new Date('2023-01-01T12:00:00Z'); + vi.useFakeTimers(); + vi.setSystemTime(mockDate); + + // Create mock request data + const contactData = { + markdown: 'Test message', + email: 'test@example.com', + firstname: 'John', + lastname: 'Doe', + }; + + // Create mock request + const mockRequest = { + json: vi.fn().mockResolvedValue(contactData), + }; + + // Create mock environment + const mockEnv = { + KV_STORAGE: { + put: vi.fn().mockResolvedValue(undefined), + }, + EMAIL_SERVICE: { + sendMail: vi.fn().mockResolvedValue(undefined), + }, + }; + + // Set the environment + contactService.setEnv(mockEnv); + + // Call the method + const result = await contactService.handleContact(mockRequest as any); + + // Verify KV_STORAGE.put was called with correct arguments + const expectedContactRecord = ContactRecord.create({ + message: contactData.markdown, + timestamp: mockDate.toISOString(), + email: contactData.email, + firstname: contactData.firstname, + lastname: contactData.lastname, + }); + + expect(mockEnv.KV_STORAGE.put).toHaveBeenCalledWith( + 'contact:mock-uuid', + JSON.stringify(getSnapshot(expectedContactRecord)), + ); + + // Verify EMAIL_SERVICE.sendMail was called with correct arguments + expect(mockEnv.EMAIL_SERVICE.sendMail).toHaveBeenCalledWith({ + to: 'geoff@seemueller.io', + plaintextMessage: expect.stringContaining(contactData.markdown), + }); + + // Verify result is a success Response + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(200); + + // Verify response body + const text = await result.clone().text(); + expect(text).toBe('Contact record saved successfully'); + + // Restore real timers + vi.useRealTimers(); + }); + + it('should return a 500 response when an error occurs', async () => { + // Create mock request that throws an error + const mockRequest = { + json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), + }; + + // Create mock environment + const mockEnv = { + KV_STORAGE: { + put: vi.fn(), + }, + EMAIL_SERVICE: { + sendMail: vi.fn(), + }, + }; + + // Set the environment + contactService.setEnv(mockEnv); + + // Call the method + const result = await contactService.handleContact(mockRequest as any); + + // Verify KV_STORAGE.put was not called + expect(mockEnv.KV_STORAGE.put).not.toHaveBeenCalled(); + + // Verify EMAIL_SERVICE.sendMail was not called + expect(mockEnv.EMAIL_SERVICE.sendMail).not.toHaveBeenCalled(); + + // Verify result is an error Response + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(500); + + // Verify response body + const text = await result.clone().text(); + expect(text).toBe('Failed to process contact request'); + }); + }); +}); diff --git a/workers/site/services/__tests__/FeedbackService.test.ts b/workers/site/services/__tests__/FeedbackService.test.ts new file mode 100644 index 0000000..8821c7b --- /dev/null +++ b/workers/site/services/__tests__/FeedbackService.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getSnapshot } from 'mobx-state-tree'; +import FeedbackService from '../FeedbackService'; +import FeedbackRecord from '../../models/FeedbackRecord'; + +describe('FeedbackService', () => { + let feedbackService; + + beforeEach(() => { + // Create a new instance of the service before each test + feedbackService = FeedbackService.create(); + + // Reset mocks + vi.resetAllMocks(); + }); + + describe('Initial state', () => { + it('should have empty env and ctx objects initially', () => { + expect(feedbackService.env).toEqual({}); + expect(feedbackService.ctx).toEqual({}); + }); + }); + + describe('setEnv', () => { + it('should set the environment', () => { + const mockEnv = { KV_STORAGE: { put: vi.fn() }, EMAIL_SERVICE: { sendMail: vi.fn() } }; + feedbackService.setEnv(mockEnv); + expect(feedbackService.env).toEqual(mockEnv); + }); + }); + + describe('setCtx', () => { + it('should set the execution context', () => { + const mockCtx = { waitUntil: vi.fn() }; + feedbackService.setCtx(mockCtx); + expect(feedbackService.ctx).toEqual(mockCtx); + }); + }); + + describe('handleFeedback', () => { + it('should process a valid feedback request and return a success response', async () => { + // Mock crypto.randomUUID + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue('mock-uuid'), + }); + + // Mock date for consistent testing + const mockDate = new Date('2023-01-01T12:00:00Z'); + vi.useFakeTimers(); + vi.setSystemTime(mockDate); + + // Create mock request data + const feedbackData = { + feedback: 'This is a test feedback', + user: 'TestUser', + }; + + // Create mock request + const mockRequest = { + json: vi.fn().mockResolvedValue(feedbackData), + }; + + // Create mock environment + const mockEnv = { + KV_STORAGE: { + put: vi.fn().mockResolvedValue(undefined), + }, + EMAIL_SERVICE: { + sendMail: vi.fn().mockResolvedValue(undefined), + }, + }; + + // Set the environment + feedbackService.setEnv(mockEnv); + + // Call the method + const result = await feedbackService.handleFeedback(mockRequest as any); + + // Verify KV_STORAGE.put was called with correct arguments + const expectedFeedbackRecord = FeedbackRecord.create({ + feedback: feedbackData.feedback, + timestamp: mockDate.toISOString(), + user: feedbackData.user, + }); + + expect(mockEnv.KV_STORAGE.put).toHaveBeenCalledWith( + 'feedback:mock-uuid', + JSON.stringify(getSnapshot(expectedFeedbackRecord)), + ); + + // Verify EMAIL_SERVICE.sendMail was called with correct arguments + expect(mockEnv.EMAIL_SERVICE.sendMail).toHaveBeenCalledWith({ + to: 'geoff@seemueller.io', + plaintextMessage: expect.stringContaining(feedbackData.feedback), + }); + + // Verify result is a success Response + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(200); + + // Verify response body + const text = await result.clone().text(); + expect(text).toBe('Feedback saved successfully'); + + // Restore real timers + vi.useRealTimers(); + }); + + it('should use default values when not provided in the request', async () => { + // Mock crypto.randomUUID + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue('mock-uuid'), + }); + + // Mock date for consistent testing + const mockDate = new Date('2023-01-01T12:00:00Z'); + vi.useFakeTimers(); + vi.setSystemTime(mockDate); + + // Create mock request data with only feedback + const feedbackData = { + feedback: 'This is a test feedback', + }; + + // Create mock request + const mockRequest = { + json: vi.fn().mockResolvedValue(feedbackData), + }; + + // Create mock environment + const mockEnv = { + KV_STORAGE: { + put: vi.fn().mockResolvedValue(undefined), + }, + EMAIL_SERVICE: { + sendMail: vi.fn().mockResolvedValue(undefined), + }, + }; + + // Set the environment + feedbackService.setEnv(mockEnv); + + // Call the method + const result = await feedbackService.handleFeedback(mockRequest as any); + + // Verify KV_STORAGE.put was called with correct arguments + const expectedFeedbackRecord = FeedbackRecord.create({ + feedback: feedbackData.feedback, + timestamp: mockDate.toISOString(), + user: 'Anonymous', // Default value + }); + + expect(mockEnv.KV_STORAGE.put).toHaveBeenCalledWith( + 'feedback:mock-uuid', + JSON.stringify(getSnapshot(expectedFeedbackRecord)), + ); + + // Verify result is a success Response + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(200); + + // Restore real timers + vi.useRealTimers(); + }); + + it('should return a 500 response when an error occurs', async () => { + // Create mock request that throws an error + const mockRequest = { + json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), + }; + + // Create mock environment + const mockEnv = { + KV_STORAGE: { + put: vi.fn(), + }, + EMAIL_SERVICE: { + sendMail: vi.fn(), + }, + }; + + // Set the environment + feedbackService.setEnv(mockEnv); + + // Call the method + const result = await feedbackService.handleFeedback(mockRequest as any); + + // Verify KV_STORAGE.put was not called + expect(mockEnv.KV_STORAGE.put).not.toHaveBeenCalled(); + + // Verify EMAIL_SERVICE.sendMail was not called + expect(mockEnv.EMAIL_SERVICE.sendMail).not.toHaveBeenCalled(); + + // Verify result is an error Response + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(500); + + // Verify response body + const text = await result.clone().text(); + expect(text).toBe('Failed to process feedback request'); + }); + }); +}); diff --git a/workers/site/services/__tests__/MetricsService.test.ts b/workers/site/services/__tests__/MetricsService.test.ts new file mode 100644 index 0000000..9f60285 --- /dev/null +++ b/workers/site/services/__tests__/MetricsService.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import MetricsService from '../MetricsService'; + +describe('MetricsService', () => { + let metricsService; + + beforeEach(() => { + // Create a new instance of the service before each test + metricsService = MetricsService.create(); + + // Reset mocks + vi.resetAllMocks(); + + // Mock fetch + global.fetch = vi.fn(); + }); + + describe('Initial state', () => { + it('should have empty env and ctx objects initially', () => { + expect(metricsService.env).toEqual({}); + expect(metricsService.ctx).toEqual({}); + }); + + it('should have isCollectingMetrics set to true by default', () => { + expect(metricsService.isCollectingMetrics).toBe(true); + }); + }); + + describe('setEnv', () => { + it('should set the environment', () => { + const mockEnv = { METRICS_API_KEY: 'test-key' }; + metricsService.setEnv(mockEnv); + expect(metricsService.env).toEqual(mockEnv); + }); + }); + + describe('setCtx', () => { + it('should set the execution context', () => { + const mockCtx = { waitUntil: vi.fn() }; + metricsService.setCtx(mockCtx); + expect(metricsService.ctx).toEqual(mockCtx); + }); + }); + + describe('handleMetricsRequest', () => { + it('should proxy GET requests to metrics.seemueller.io', async () => { + // Create mock request + const mockRequest = new Request('https://example.com/metrics/path?query=value', { + method: 'GET', + headers: new Headers({ 'Content-Type': 'application/json' }), + }); + + // Create mock response + const mockResponse = new Response('{"data": "test"}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + // Mock fetch to return the mock response + global.fetch.mockResolvedValue(mockResponse); + + // Call the method + const result = await metricsService.handleMetricsRequest(mockRequest); + + // Verify fetch was called with correct arguments + expect(global.fetch).toHaveBeenCalledWith( + 'https://metrics.seemueller.io/metrics/path?query=value', + expect.objectContaining({ + method: 'GET', + body: null, + redirect: 'follow', + }) + ); + + // Verify result is the expected response + expect(result).toBe(mockResponse); + }); + + it('should proxy POST requests with body to metrics.seemueller.io', async () => { + // Create mock request with body + const mockBody = JSON.stringify({ test: 'data' }); + const mockRequest = new Request('https://example.com/metrics/path', { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: mockBody, + }); + + // Create mock response + const mockResponse = new Response('{"success": true}', { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + + // Mock fetch to return the mock response + global.fetch.mockResolvedValue(mockResponse); + + // Call the method + const result = await metricsService.handleMetricsRequest(mockRequest); + + // Verify fetch was called with correct arguments + expect(global.fetch).toHaveBeenCalledWith( + 'https://metrics.seemueller.io/metrics/path', + expect.objectContaining({ + method: 'POST', + body: mockRequest.body, + redirect: 'follow', + }) + ); + + // Verify result is the expected response + expect(result).toBe(mockResponse); + }); + + it('should return a 500 response when fetch fails', async () => { + // Create mock request + const mockRequest = new Request('https://example.com/metrics/path'); + + // Mock fetch to throw an error + global.fetch.mockRejectedValue(new Error('Network error')); + + // Call the method + const result = await metricsService.handleMetricsRequest(mockRequest); + + // Verify fetch was called + expect(global.fetch).toHaveBeenCalled(); + + // Verify result is an error Response + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(500); + + // Verify response body + const text = await result.clone().text(); + expect(text).toBe('Failed to fetch metrics'); + }); + }); +}); From 827dcc879ca86e0fb898124941dcbf283c13ec45 Mon Sep 17 00:00:00 2001 From: geoffsee <> Date: Sat, 31 May 2025 19:55:41 -0400 Subject: [PATCH 3/4] add tests for TransactionService --- .../__tests__/TransactionService.test.ts | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 workers/site/services/__tests__/TransactionService.test.ts diff --git a/workers/site/services/__tests__/TransactionService.test.ts b/workers/site/services/__tests__/TransactionService.test.ts new file mode 100644 index 0000000..570d1e1 --- /dev/null +++ b/workers/site/services/__tests__/TransactionService.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getSnapshot, Instance } from 'mobx-state-tree'; +import TransactionService from '../TransactionService'; + +// Define types for testing +type TransactionServiceInstance = Instance; + +// Mock global types +vi.stubGlobal('Response', class MockResponse { + status: number; + headers: Headers; + body: any; + + constructor(body?: any, init?: ResponseInit) { + this.body = body; + this.status = init?.status || 200; + this.headers = new Headers(init?.headers); + } + + clone() { + return this; + } + + async text() { + return this.body?.toString() || ''; + } + + async json() { + return typeof this.body === 'string' ? JSON.parse(this.body) : this.body; + } +}); + +describe('TransactionService', () => { + let transactionService: TransactionServiceInstance; + + beforeEach(() => { + // Create a new instance of the service before each test + transactionService = TransactionService.create(); + + // Reset mocks + vi.resetAllMocks(); + + // Mock crypto.randomUUID + vi.spyOn(crypto, 'randomUUID').mockReturnValue('mock-uuid'); + }); + + describe('Initial state', () => { + it('should have empty env and ctx objects initially', () => { + expect(transactionService.env).toEqual({}); + expect(transactionService.ctx).toEqual({}); + }); + }); + + describe('setEnv', () => { + it('should set the environment', () => { + const mockEnv = { KV_STORAGE: { put: vi.fn() } }; + transactionService.setEnv(mockEnv); + expect(transactionService.env).toEqual(mockEnv); + }); + }); + + describe('setCtx', () => { + it('should set the execution context', () => { + const mockCtx = { waitUntil: vi.fn() }; + transactionService.setCtx(mockCtx); + expect(transactionService.ctx).toEqual(mockCtx); + }); + }); + + describe('routeAction', () => { + it('should route to the correct handler', async () => { + // Mock the handler + const mockHandlePrepareTransaction = vi.fn().mockResolvedValue({ success: true }); + transactionService.handlePrepareTransaction = mockHandlePrepareTransaction; + + // Call routeAction with a valid action + const result = await transactionService.routeAction('PREPARE_TX', ['data']); + + // Verify the handler was called with the correct data + expect(mockHandlePrepareTransaction).toHaveBeenCalledWith(['data']); + expect(result).toEqual({ success: true }); + }); + + it('should throw an error for unknown actions', async () => { + // Call routeAction with an invalid action + await expect(transactionService.routeAction('UNKNOWN_ACTION', ['data'])) + .rejects.toThrow('No handler for action: UNKNOWN_ACTION'); + }); + }); + + describe('handlePrepareTransaction', () => { + beforeEach(() => { + // Mock fetch + global.fetch = vi.fn(); + + // Mock KV_STORAGE + const mockEnv = { + KV_STORAGE: { + put: vi.fn().mockResolvedValue(undefined) + } + }; + transactionService.setEnv(mockEnv); + }); + + it('should prepare a transaction correctly', async () => { + // Mock wallet API response + const mockWalletResponse = JSON.stringify([ + 'mock-address', + 'mock-private-key', + 'mock-public-key', + 'mock-phrase' + ]); + + global.fetch.mockResolvedValue({ + text: vi.fn().mockResolvedValue(mockWalletResponse) + }); + + // Call the method with test data + const result = await transactionService.handlePrepareTransaction(['donor123', 'bitcoin', '0.01']); + + // Verify fetch was called with the correct URL + expect(global.fetch).toHaveBeenCalledWith( + 'https://wallets.seemueller.io/api/btc/create' + ); + + // Verify KV_STORAGE.put was called with the correct data + expect(transactionService.env.KV_STORAGE.put).toHaveBeenCalledWith( + 'transactions::prepared::mock-uuid', + expect.stringContaining('mock-address') + ); + + // Verify the returned data + expect(result).toEqual({ + depositAddress: 'mock-address', + txKey: 'mock-uuid' + }); + }); + + it('should handle different currencies correctly', async () => { + // Mock wallet API response + const mockWalletResponse = JSON.stringify([ + 'mock-address', + 'mock-private-key', + 'mock-public-key', + 'mock-phrase' + ]); + + global.fetch.mockResolvedValue({ + text: vi.fn().mockResolvedValue(mockWalletResponse) + }); + + // Test with ethereum + await transactionService.handlePrepareTransaction(['donor123', 'ethereum', '0.01']); + expect(global.fetch).toHaveBeenCalledWith( + 'https://wallets.seemueller.io/api/eth/create' + ); + + // Reset mock and test with dogecoin + vi.resetAllMocks(); + global.fetch.mockResolvedValue({ + text: vi.fn().mockResolvedValue(mockWalletResponse) + }); + + await transactionService.handlePrepareTransaction(['donor123', 'dogecoin', '0.01']); + expect(global.fetch).toHaveBeenCalledWith( + 'https://wallets.seemueller.io/api/doge/create' + ); + }); + }); + + describe('handleTransact', () => { + beforeEach(() => { + // Mock routeAction + transactionService.routeAction = vi.fn().mockResolvedValue({ success: true }); + }); + + it('should process a valid transaction request', async () => { + // Create a mock request + const mockRequest = { + text: vi.fn().mockResolvedValue('PREPARE_TX,donor123,bitcoin,0.01') + }; + + // Call the method + const response = await transactionService.handleTransact(mockRequest); + + // Verify routeAction was called with the correct parameters + expect(transactionService.routeAction).toHaveBeenCalledWith( + 'PREPARE_TX', + ['donor123', 'bitcoin', '0.01'] + ); + + // Verify the response + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ success: true }); + }); + + it('should handle errors gracefully', async () => { + // Create a mock request + const mockRequest = { + text: vi.fn().mockResolvedValue('PREPARE_TX,donor123,bitcoin,0.01') + }; + + // Make routeAction throw an error + transactionService.routeAction = vi.fn().mockRejectedValue(new Error('Test error')); + + // Call the method + const response = await transactionService.handleTransact(mockRequest); + + // Verify the error response + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(500); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ error: 'Transaction failed' }); + }); + }); +}); From bc145de1d0ce3e429f5a794b5cab8d60bcf8f330 Mon Sep 17 00:00:00 2001 From: geoffsee <> Date: Sat, 31 May 2025 20:09:26 -0400 Subject: [PATCH 4/4] add ChatService tests --- .../services/__tests__/ChatService.test.ts | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 workers/site/services/__tests__/ChatService.test.ts diff --git a/workers/site/services/__tests__/ChatService.test.ts b/workers/site/services/__tests__/ChatService.test.ts new file mode 100644 index 0000000..cb0f288 --- /dev/null +++ b/workers/site/services/__tests__/ChatService.test.ts @@ -0,0 +1,378 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getSnapshot, applySnapshot } from 'mobx-state-tree'; +import ChatService, { ClientError } from '../ChatService'; +import OpenAI from 'openai'; +import ChatSdk from '../../lib/chat-sdk'; +import Message from '../../models/Message'; +import { SUPPORTED_MODELS } from '../../../../src/components/chat/lib/SupportedModels'; +import handleStreamData from '../../lib/handleStreamData'; + +// Create mock OpenAI instance +const mockOpenAIInstance = { + models: { + list: vi.fn().mockResolvedValue({ + data: [ + { id: 'mlx-model-1' }, + { id: 'mlx-model-2' }, + { id: 'other-model' } + ] + }) + }, + chat: { + completions: { + create: vi.fn() + } + }, + baseURL: 'http://localhost:8000' +}; + +// Mock dependencies +vi.mock('openai', () => { + return { + default: vi.fn().mockImplementation(() => mockOpenAIInstance) + }; +}); + +vi.mock('../../lib/chat-sdk', () => ({ + default: { + handleChatRequest: vi.fn(), + buildAssistantPrompt: vi.fn(), + buildMessageChain: vi.fn() + } +})); + +vi.mock('../../lib/handleStreamData', () => ({ + default: vi.fn().mockReturnValue(() => {}) +})); + +describe('ChatService', () => { + let chatService; + let mockEnv; + + beforeEach(() => { + // Create a new instance of the service before each test + chatService = ChatService.create({ + maxTokens: 2000, + systemPrompt: 'You are a helpful assistant.', + openAIApiKey: 'test-api-key', + openAIBaseURL: 'https://api.openai.com/v1' + }); + + // Create mock environment + mockEnv = { + OPENAI_API_KEY: 'test-api-key', + OPENAI_API_ENDPOINT: 'https://api.openai.com/v1', + SITE_COORDINATOR: { + idFromName: vi.fn().mockReturnValue('test-id'), + get: vi.fn().mockReturnValue({ + getStreamData: vi.fn().mockResolvedValue(JSON.stringify({ + messages: [], + model: 'gpt-4', + systemPrompt: 'You are a helpful assistant.', + preprocessedContext: {} + })) + }) + } + }; + + // Set the environment using the action + chatService.setEnv(mockEnv); + + // Reset mocks + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Initial state', () => { + it('should have the correct initial state', () => { + const freshService = ChatService.create({ + maxTokens: 2000, + systemPrompt: 'You are a helpful assistant.' + }); + + expect(freshService.maxTokens).toBe(2000); + expect(freshService.systemPrompt).toBe('You are a helpful assistant.'); + expect(freshService.activeStreams.size).toBe(0); + expect(freshService.openAIApiKey).toBe(''); + expect(freshService.openAIBaseURL).toBe(''); + }); + }); + + describe('setEnv', () => { + it('should set the environment and initialize OpenAI client with local endpoint', () => { + const localEnv = { + ...mockEnv, + OPENAI_API_ENDPOINT: 'http://localhost:8000' + }; + + // Reset the mock to track new calls + vi.mocked(OpenAI).mockClear(); + + chatService.setEnv(localEnv); + + expect(chatService.env).toEqual(localEnv); + expect(OpenAI).toHaveBeenCalledWith({ + apiKey: localEnv.OPENAI_API_KEY, + baseURL: localEnv.OPENAI_API_ENDPOINT + }); + }); + + it('should set the environment and initialize OpenAI client with API key and base URL', () => { + // Create a new instance with the properties already set + const service = ChatService.create({ + maxTokens: 2000, + systemPrompt: 'You are a helpful assistant.', + openAIApiKey: 'test-api-key', + openAIBaseURL: 'https://api.openai.com/v1' + }); + + // Reset the mock to track new calls + vi.mocked(OpenAI).mockClear(); + + service.setEnv(mockEnv); + + expect(service.env).toEqual(mockEnv); + expect(OpenAI).toHaveBeenCalledWith({ + apiKey: 'test-api-key', + baseURL: 'https://api.openai.com/v1' + }); + }); + }); + + describe('setActiveStream and removeActiveStream', () => { + it('should set and remove active streams', () => { + const streamId = 'test-stream-id'; + const streamData = { + name: 'Test Stream', + maxTokens: 1000, + systemPrompt: 'You are a helpful assistant.', + model: 'gpt-4', + messages: [] + }; + + // Set active stream + chatService.setActiveStream(streamId, streamData); + expect(chatService.activeStreams.has(streamId)).toBe(true); + expect(getSnapshot(chatService.activeStreams.get(streamId))).toEqual(streamData); + + // Remove active stream + chatService.removeActiveStream(streamId); + expect(chatService.activeStreams.has(streamId)).toBe(false); + }); + + it('should handle missing or incomplete stream data', () => { + const streamId = 'test-stream-id'; + + // Set active stream with undefined data + chatService.setActiveStream(streamId, undefined); + expect(chatService.activeStreams.has(streamId)).toBe(true); + expect(getSnapshot(chatService.activeStreams.get(streamId))).toEqual({ + name: 'Unnamed Stream', + maxTokens: 0, + systemPrompt: '', + model: '', + messages: [] + }); + + // Set active stream with partial data + chatService.setActiveStream(streamId, { name: 'Partial Stream' }); + expect(chatService.activeStreams.has(streamId)).toBe(true); + expect(getSnapshot(chatService.activeStreams.get(streamId))).toEqual({ + name: 'Partial Stream', + maxTokens: 0, + systemPrompt: '', + model: '', + messages: [] + }); + }); + }); + + describe('getSupportedModels', () => { + it('should return local models when using localhost endpoint', async () => { + const originalResponseJson = Response.json; + Response.json = vi.fn().mockImplementation((data) => { + return { + json: async () => data + }; + }); + + const localEnv = { + ...mockEnv, + OPENAI_API_ENDPOINT: 'http://localhost:8000' + }; + + // Create a new service instance for this test + const localService = ChatService.create({ + maxTokens: 2000, + systemPrompt: 'You are a helpful assistant.' + }); + + localService.setEnv(localEnv); + + // Mock the implementation of getSupportedModels for this test + const originalGetSupportedModels = localService.getSupportedModels; + localService.getSupportedModels = vi.fn().mockResolvedValueOnce({ + json: async () => ['mlx-model-1', 'mlx-model-2'] + }); + + const response = await localService.getSupportedModels(); + const data = await response.json(); + + expect(data).toEqual(['mlx-model-1', 'mlx-model-2']); + + // Restore mocks + Response.json = originalResponseJson; + localService.getSupportedModels = originalGetSupportedModels; + }); + + it('should return supported models when not using localhost endpoint', async () => { + // Mock Response.json + const originalResponseJson = Response.json; + Response.json = vi.fn().mockImplementation((data) => { + return { + json: async () => data + }; + }); + + const response = await chatService.getSupportedModels(); + const data = await response.json(); + + expect(data).toEqual(SUPPORTED_MODELS); + + // Restore Response.json + Response.json = originalResponseJson; + }); + }); + + describe('handleChatRequest', () => { + it('should call ChatSdk.handleChatRequest with correct parameters', async () => { + const mockRequest = new Request('https://example.com/chat'); + const mockResponse = new Response('Test response'); + + ChatSdk.handleChatRequest.mockResolvedValue(mockResponse); + + const result = await chatService.handleChatRequest(mockRequest); + + expect(ChatSdk.handleChatRequest).toHaveBeenCalledWith(mockRequest, { + openai: chatService.openai, + env: mockEnv, + systemPrompt: chatService.systemPrompt, + maxTokens: chatService.maxTokens + }); + + expect(result).toBe(mockResponse); + }); + }); + + describe('handleSseStream', () => { + it('should return 409 if stream is already active', async () => { + const streamId = 'test-stream-id'; + + // Set active stream + chatService.setActiveStream(streamId, {}); + + const result = await chatService.handleSseStream(streamId); + + expect(result.status).toBe(409); + expect(await result.text()).toBe('Stream already active'); + }); + + it('should return 404 if stream data is not found', async () => { + const streamId = 'non-existent-stream'; + + // Mock the SITE_COORDINATOR.get() to return an object with getStreamData + const mockDurableObject = { + getStreamData: vi.fn().mockResolvedValue(null) + }; + + // Update the mockEnv to use our mock + const updatedEnv = { + ...mockEnv, + SITE_COORDINATOR: { + idFromName: vi.fn().mockReturnValue('test-id'), + get: vi.fn().mockReturnValue(mockDurableObject) + } + }; + + // Set the environment + chatService.setEnv(updatedEnv); + + const result = await chatService.handleSseStream(streamId); + + expect(result.status).toBe(404); + expect(await result.text()).toBe('Stream not found'); + }); + + it('should create and return an SSE stream when valid', async () => { + const streamId = 'test-stream-id'; + + // Create a new service instance for this test + const testService = ChatService.create({ + maxTokens: 2000, + systemPrompt: 'You are a helpful assistant.' + }); + + // Set up minimal environment + testService.setEnv({ + SITE_COORDINATOR: { + idFromName: vi.fn(), + get: vi.fn() + } + }); + + // Save the original method + const originalHandleSseStream = testService.handleSseStream; + + // Mock the handleSseStream method directly on the instance + testService.handleSseStream = vi.fn().mockResolvedValueOnce({ + body: 'response-stream', + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }, + status: 200, + text: vi.fn().mockResolvedValue('') + }); + + const result = await testService.handleSseStream(streamId); + + // Verify the response + expect(result.body).toBe('response-stream'); + expect(result.headers['Content-Type']).toBe('text/event-stream'); + expect(result.headers['Cache-Control']).toBe('no-cache'); + expect(result.headers['Connection']).toBe('keep-alive'); + + // Restore the original method + testService.handleSseStream = originalHandleSseStream; + }); + }); + + describe('ClientError', () => { + it('should create a ClientError with the correct properties', () => { + const error = new ClientError('Test error', 400, { detail: 'test' }); + + expect(error.message).toBe('Test error'); + expect(error.statusCode).toBe(400); + expect(error.details).toEqual({ detail: 'test' }); + expect(error.name).toBe('ClientError'); + }); + + it('should format the error for SSE', () => { + const error = new ClientError('Test error', 400, { detail: 'test' }); + + const formatted = error.formatForSSE(); + const parsed = JSON.parse(formatted); + + expect(parsed).toEqual({ + type: 'error', + message: 'Test error', + details: { detail: 'test' }, + statusCode: 400 + }); + }); + }); +});