From a39b68e8661396b495607180fd77b474395c41b0 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 14 Jan 2026 00:50:55 +1000 Subject: [PATCH] feat: add comprehensive integration tests for GitHub Actions - Add Vitest integration test config with sequential execution - Create test helpers (auth, cleanup, SSE parser, API client) - Create test data factories for agents and collections - Add test context manager for unified test setup - Create integration tests for: - Health and stats endpoints - Tools list and search - Collections CRUD - Agents CRUD and conversations - MCP HTTP transport - User profile, API keys, and usage - Public agents and collections - Sync endpoints (cron-authenticated) - Add GitHub Actions workflow running on push to main - Add cleanup-orphans script for test data cleanup - Add setup-test-credentials script for generating auth tokens --- .github/workflows/integration-tests.yml | 121 +++++++++ apps/web/package.json | 3 + .../test/integration/_helpers/api-client.ts | 253 ++++++++++++++++++ .../web/src/test/integration/_helpers/auth.ts | 108 ++++++++ .../integration/_helpers/cleanup-orphans.ts | 123 +++++++++ .../src/test/integration/_helpers/cleanup.ts | 251 +++++++++++++++++ .../_helpers/factories/agent.factory.ts | 146 ++++++++++ .../_helpers/factories/collection.factory.ts | 155 +++++++++++ .../_helpers/setup-test-credentials.ts | 165 ++++++++++++ .../test/integration/_helpers/sse-parser.ts | 179 +++++++++++++ .../test/integration/_helpers/test-context.ts | 164 ++++++++++++ .../agents-conversations.integration.test.ts | 202 ++++++++++++++ .../agents/agents-crud.integration.test.ts | 184 +++++++++++++ .../collections-crud.integration.test.ts | 176 ++++++++++++ .../health/health.integration.test.ts | 73 +++++ .../mcp/mcp-http.integration.test.ts | 178 ++++++++++++ .../public/public-agents.integration.test.ts | 138 ++++++++++ .../public-collections.integration.test.ts | 139 ++++++++++ .../src/test/integration/setup-integration.ts | 58 ++++ .../stats/stats.integration.test.ts | 100 +++++++ .../sync/sync-endpoints.integration.test.ts | 115 ++++++++ .../tools/tools-list.integration.test.ts | 107 ++++++++ .../tools/tools-search.integration.test.ts | 91 +++++++ .../user/user-profile.integration.test.ts | 96 +++++++ .../user/user-tpmjs-keys.integration.test.ts | 134 ++++++++++ .../user/user-usage.integration.test.ts | 87 ++++++ apps/web/vitest.integration.config.ts | 23 ++ 27 files changed, 3569 insertions(+) create mode 100644 .github/workflows/integration-tests.yml create mode 100644 apps/web/src/test/integration/_helpers/api-client.ts create mode 100644 apps/web/src/test/integration/_helpers/auth.ts create mode 100644 apps/web/src/test/integration/_helpers/cleanup-orphans.ts create mode 100644 apps/web/src/test/integration/_helpers/cleanup.ts create mode 100644 apps/web/src/test/integration/_helpers/factories/agent.factory.ts create mode 100644 apps/web/src/test/integration/_helpers/factories/collection.factory.ts create mode 100644 apps/web/src/test/integration/_helpers/setup-test-credentials.ts create mode 100644 apps/web/src/test/integration/_helpers/sse-parser.ts create mode 100644 apps/web/src/test/integration/_helpers/test-context.ts create mode 100644 apps/web/src/test/integration/agents/agents-conversations.integration.test.ts create mode 100644 apps/web/src/test/integration/agents/agents-crud.integration.test.ts create mode 100644 apps/web/src/test/integration/collections/collections-crud.integration.test.ts create mode 100644 apps/web/src/test/integration/health/health.integration.test.ts create mode 100644 apps/web/src/test/integration/mcp/mcp-http.integration.test.ts create mode 100644 apps/web/src/test/integration/public/public-agents.integration.test.ts create mode 100644 apps/web/src/test/integration/public/public-collections.integration.test.ts create mode 100644 apps/web/src/test/integration/setup-integration.ts create mode 100644 apps/web/src/test/integration/stats/stats.integration.test.ts create mode 100644 apps/web/src/test/integration/sync/sync-endpoints.integration.test.ts create mode 100644 apps/web/src/test/integration/tools/tools-list.integration.test.ts create mode 100644 apps/web/src/test/integration/tools/tools-search.integration.test.ts create mode 100644 apps/web/src/test/integration/user/user-profile.integration.test.ts create mode 100644 apps/web/src/test/integration/user/user-tpmjs-keys.integration.test.ts create mode 100644 apps/web/src/test/integration/user/user-usage.integration.test.ts create mode 100644 apps/web/vitest.integration.config.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..e956d75 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,121 @@ +name: Integration Tests + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + verbose: + description: 'Run tests in verbose mode' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + +concurrency: + group: integration-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration-tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.14.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm build + + - name: Wait for API + run: | + echo "Checking if API is available at $TEST_BASE_URL..." + for i in {1..30}; do + if curl -sf "$TEST_BASE_URL/api/health" > /dev/null 2>&1; then + echo "โœ… API is available" + exit 0 + fi + echo "Attempt $i/30: API not ready yet, waiting..." + sleep 2 + done + echo "โŒ API is not available after 60 seconds" + exit 1 + env: + TEST_BASE_URL: ${{ secrets.TEST_BASE_URL }} + + - name: Run integration tests + env: + INTEGRATION_TESTS: 'true' + TEST_BASE_URL: ${{ secrets.TEST_BASE_URL }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + INTEGRATION_TEST_USER_ID: ${{ secrets.INTEGRATION_TEST_USER_ID }} + INTEGRATION_TEST_USERNAME: ${{ secrets.INTEGRATION_TEST_USERNAME }} + INTEGRATION_TEST_SESSION_TOKEN: ${{ secrets.INTEGRATION_TEST_SESSION_TOKEN }} + INTEGRATION_TEST_API_KEY: ${{ secrets.INTEGRATION_TEST_API_KEY }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + run: | + if [ "${{ github.event.inputs.verbose }}" = "true" ]; then + pnpm --filter=@tpmjs/web test:integration -- --reporter=verbose + else + pnpm --filter=@tpmjs/web test:integration + fi + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: integration-test-results + path: | + apps/web/test-results/ + apps/web/coverage/ + retention-days: 7 + + cleanup: + runs-on: ubuntu-latest + needs: integration-tests + if: always() + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.14.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build @tpmjs/db + run: pnpm --filter=@tpmjs/db build + + - name: Cleanup orphaned test data + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + INTEGRATION_TEST_USER_ID: ${{ secrets.INTEGRATION_TEST_USER_ID }} + run: pnpm --filter=@tpmjs/web tsx src/test/integration/_helpers/cleanup-orphans.ts diff --git a/apps/web/package.json b/apps/web/package.json index 8ecf192..454a761 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,9 @@ "clean": "rm -rf .next .turbo", "test": "vitest run", "test:watch": "vitest", + "test:integration": "INTEGRATION_TESTS=true vitest run --config vitest.integration.config.ts", + "test:integration:watch": "INTEGRATION_TESTS=true vitest --config vitest.integration.config.ts", + "test:setup-credentials": "tsx src/test/integration/_helpers/setup-test-credentials.ts", "generate-og": "tsx scripts/generate-og-images.ts" }, "dependencies": { diff --git a/apps/web/src/test/integration/_helpers/api-client.ts b/apps/web/src/test/integration/_helpers/api-client.ts new file mode 100644 index 0000000..9eeeada --- /dev/null +++ b/apps/web/src/test/integration/_helpers/api-client.ts @@ -0,0 +1,253 @@ +/** + * Type-safe API client for integration tests + * + * Provides convenient methods for making API requests + * with proper error handling and response parsing. + */ + +import { collectSSEEvents, type SSEEvent } from './sse-parser'; + +export interface ApiResponse { + ok: boolean; + status: number; + data: T; + headers: Headers; +} + +export interface ApiErrorResponse { + ok: false; + status: number; + error: string; + headers: Headers; +} + +export type ApiResult = ApiResponse | ApiErrorResponse; + +/** + * Create an API client with a base URL and optional custom fetch + */ +export function createApiClient(baseUrl: string, customFetch: typeof fetch = fetch) { + const normalizedBaseUrl = baseUrl.replace(/\/$/, ''); + + async function request( + method: string, + path: string, + options: { + body?: unknown; + headers?: Record; + query?: Record; + } = {} + ): Promise> { + const { body, headers = {}, query } = options; + + let url = `${normalizedBaseUrl}${path}`; + + // Add query parameters + if (query) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + params.set(key, String(value)); + } + } + const queryString = params.toString(); + if (queryString) { + url += `?${queryString}`; + } + } + + const requestHeaders: Record = { + ...headers, + }; + + if (body !== undefined) { + requestHeaders['Content-Type'] = 'application/json'; + } + + const response = await customFetch(url, { + method, + headers: requestHeaders, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}`; + try { + const errorData = await response.json(); + errorMessage = errorData.error || errorData.message || errorMessage; + } catch { + // Use default error message + } + + return { + ok: false, + status: response.status, + error: errorMessage, + headers: response.headers, + }; + } + + let data: T; + const contentType = response.headers.get('content-type'); + + if (contentType?.includes('application/json')) { + data = await response.json(); + } else { + data = (await response.text()) as unknown as T; + } + + return { + ok: true, + status: response.status, + data, + headers: response.headers, + }; + } + + return { + /** + * Make a GET request + */ + get( + path: string, + options?: { + headers?: Record; + query?: Record; + } + ): Promise> { + return request('GET', path, options); + }, + + /** + * Make a POST request + */ + post( + path: string, + body?: unknown, + options?: { + headers?: Record; + query?: Record; + } + ): Promise> { + return request('POST', path, { ...options, body }); + }, + + /** + * Make a PATCH request + */ + patch( + path: string, + body?: unknown, + options?: { + headers?: Record; + query?: Record; + } + ): Promise> { + return request('PATCH', path, { ...options, body }); + }, + + /** + * Make a PUT request + */ + put( + path: string, + body?: unknown, + options?: { + headers?: Record; + query?: Record; + } + ): Promise> { + return request('PUT', path, { ...options, body }); + }, + + /** + * Make a DELETE request + */ + delete( + path: string, + options?: { + headers?: Record; + query?: Record; + } + ): Promise> { + return request('DELETE', path, options); + }, + + /** + * Make a request to an SSE endpoint and collect events + */ + async sse( + path: string, + body?: unknown, + options?: { + headers?: Record; + timeout?: number; + maxEvents?: number; + } + ): Promise<{ ok: boolean; status: number; events: SSEEvent[] }> { + const { headers = {}, timeout, maxEvents } = options || {}; + + const url = `${normalizedBaseUrl}${path}`; + const requestHeaders: Record = { + ...headers, + }; + + if (body !== undefined) { + requestHeaders['Content-Type'] = 'application/json'; + } + + const response = await customFetch(url, { + method: body !== undefined ? 'POST' : 'GET', + headers: requestHeaders, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + return { + ok: false, + status: response.status, + events: [], + }; + } + + const events = await collectSSEEvents(response, { timeout, maxEvents }); + + return { + ok: true, + status: response.status, + events, + }; + }, + + /** + * Make a raw request and return the Response object + */ + async raw( + method: string, + path: string, + options?: { + body?: unknown; + headers?: Record; + } + ): Promise { + const { body, headers = {} } = options || {}; + + const url = `${normalizedBaseUrl}${path}`; + const requestHeaders: Record = { + ...headers, + }; + + if (body !== undefined) { + requestHeaders['Content-Type'] = 'application/json'; + } + + return customFetch(url, { + method, + headers: requestHeaders, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + }, + }; +} + +export type ApiClient = ReturnType; diff --git a/apps/web/src/test/integration/_helpers/auth.ts b/apps/web/src/test/integration/_helpers/auth.ts new file mode 100644 index 0000000..a03b597 --- /dev/null +++ b/apps/web/src/test/integration/_helpers/auth.ts @@ -0,0 +1,108 @@ +/** + * Authentication helpers for integration tests + * + * Provides utilities for creating authenticated fetch functions + * using session tokens, API keys, or cron secrets. + */ + +export interface TestAuthContext { + userId: string; + username: string; + sessionToken: string; + apiKey: string; +} + +/** + * Get test authentication context from environment variables + */ +export function getTestAuthContext(): TestAuthContext { + const sessionToken = process.env.INTEGRATION_TEST_SESSION_TOKEN; + const apiKey = process.env.INTEGRATION_TEST_API_KEY; + const userId = process.env.INTEGRATION_TEST_USER_ID || 'test-user-id'; + const username = process.env.INTEGRATION_TEST_USERNAME || 'tpmjs-integration-test'; + + if (!sessionToken) { + throw new Error( + 'INTEGRATION_TEST_SESSION_TOKEN not set. Run `pnpm --filter=@tpmjs/web test:setup-credentials` to generate.' + ); + } + + if (!apiKey) { + throw new Error( + 'INTEGRATION_TEST_API_KEY not set. Run `pnpm --filter=@tpmjs/web test:setup-credentials` to generate.' + ); + } + + return { + userId, + username, + sessionToken, + apiKey, + }; +} + +/** + * Create a fetch function with session cookie authentication + */ +export function createSessionFetch(sessionToken: string): typeof fetch { + return (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('Cookie', `better-auth.session_token=${sessionToken}`); + + return fetch(input, { + ...init, + headers, + }); + }; +} + +/** + * Create a fetch function with API key authentication + */ +export function createApiKeyFetch(apiKey: string): typeof fetch { + return (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${apiKey}`); + + return fetch(input, { + ...init, + headers, + }); + }; +} + +/** + * Create a fetch function with cron secret authentication + */ +export function createCronFetch(cronSecret?: string): typeof fetch { + const secret = cronSecret || process.env.CRON_SECRET; + + if (!secret) { + throw new Error('CRON_SECRET not set'); + } + + return (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${secret}`); + + return fetch(input, { + ...init, + headers, + }); + }; +} + +/** + * Create all authenticated fetch functions + */ +export function createAuthenticatedFetches(auth: TestAuthContext): { + sessionFetch: typeof fetch; + apiKeyFetch: typeof fetch; + cronFetch: typeof fetch; +} { + return { + sessionFetch: createSessionFetch(auth.sessionToken), + apiKeyFetch: createApiKeyFetch(auth.apiKey), + cronFetch: createCronFetch(), + }; +} diff --git a/apps/web/src/test/integration/_helpers/cleanup-orphans.ts b/apps/web/src/test/integration/_helpers/cleanup-orphans.ts new file mode 100644 index 0000000..f648d22 --- /dev/null +++ b/apps/web/src/test/integration/_helpers/cleanup-orphans.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env tsx +/** + * Cleanup script for orphaned test data + * + * This script is run after integration tests (or manually) to clean up + * any test data that wasn't properly cleaned up due to test failures. + * + * Test data is identified by: + * - Agent UIDs starting with 'test-' + * - Collection slugs starting with 'test-' + * + * Run with: pnpm --filter=@tpmjs/web tsx src/test/integration/_helpers/cleanup-orphans.ts + */ + +import { resolve } from 'node:path'; +import { config } from 'dotenv'; + +// Load environment variables +config({ path: resolve(__dirname, '../../../../.env.local') }); +config({ path: resolve(__dirname, '../../../../.env') }); + +// Check for required environment variables +if (!process.env.DATABASE_URL) { + console.error('โŒ DATABASE_URL not set'); + process.exit(1); +} + +// Import prisma after env is loaded +import { prisma } from '@tpmjs/db'; + +async function main() { + const userId = process.env.INTEGRATION_TEST_USER_ID; + + console.log('๐Ÿงน Cleaning up orphaned test data...\n'); + + try { + // Count existing test data + const testAgentCount = await prisma.agent.count({ + where: userId + ? { userId, uid: { startsWith: 'test-' } } + : { uid: { startsWith: 'test-' } }, + }); + + const testCollectionCount = await prisma.collection.count({ + where: userId + ? { userId, slug: { startsWith: 'test-' } } + : { slug: { startsWith: 'test-' } }, + }); + + console.log(`Found ${testAgentCount} test agent(s)`); + console.log(`Found ${testCollectionCount} test collection(s)`); + + if (testAgentCount === 0 && testCollectionCount === 0) { + console.log('\nโœ… No orphaned test data found'); + return; + } + + // Clean up test conversations (depend on agents) + const convDeleteResult = await prisma.conversation.deleteMany({ + where: { + agent: userId + ? { userId, uid: { startsWith: 'test-' } } + : { uid: { startsWith: 'test-' } }, + }, + }); + console.log(`Deleted ${convDeleteResult.count} conversation(s)`); + + // Clean up agent-tool associations + const agentToolResult = await prisma.agentTool.deleteMany({ + where: { + agent: userId + ? { userId, uid: { startsWith: 'test-' } } + : { uid: { startsWith: 'test-' } }, + }, + }); + console.log(`Deleted ${agentToolResult.count} agent-tool association(s)`); + + // Clean up agent-collection associations + const agentCollResult = await prisma.agentCollection.deleteMany({ + where: { + agent: userId + ? { userId, uid: { startsWith: 'test-' } } + : { uid: { startsWith: 'test-' } }, + }, + }); + console.log(`Deleted ${agentCollResult.count} agent-collection association(s)`); + + // Clean up test agents + const agentResult = await prisma.agent.deleteMany({ + where: userId + ? { userId, uid: { startsWith: 'test-' } } + : { uid: { startsWith: 'test-' } }, + }); + console.log(`Deleted ${agentResult.count} agent(s)`); + + // Clean up collection-tool associations + const collToolResult = await prisma.collectionTool.deleteMany({ + where: { + collection: userId + ? { userId, slug: { startsWith: 'test-' } } + : { slug: { startsWith: 'test-' } }, + }, + }); + console.log(`Deleted ${collToolResult.count} collection-tool association(s)`); + + // Clean up test collections + const collResult = await prisma.collection.deleteMany({ + where: userId + ? { userId, slug: { startsWith: 'test-' } } + : { slug: { startsWith: 'test-' } }, + }); + console.log(`Deleted ${collResult.count} collection(s)`); + + console.log('\nโœ… Cleanup complete!'); + } catch (error) { + console.error('โŒ Cleanup failed:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/apps/web/src/test/integration/_helpers/cleanup.ts b/apps/web/src/test/integration/_helpers/cleanup.ts new file mode 100644 index 0000000..b3d79c6 --- /dev/null +++ b/apps/web/src/test/integration/_helpers/cleanup.ts @@ -0,0 +1,251 @@ +/** + * Test data cleanup utilities + * + * Tracks created test data and provides cleanup functions + * to ensure a clean state after tests complete. + */ + +import { prisma } from '@tpmjs/db'; + +/** + * Tracks created test data IDs for cleanup + */ +export class TestDataTracker { + private agentIds: Set = new Set(); + private collectionIds: Set = new Set(); + private conversationIds: Set = new Set(); + private apiKeyIds: Set = new Set(); + private tpmjsApiKeyIds: Set = new Set(); + + trackAgent(id: string): void { + this.agentIds.add(id); + } + + trackCollection(id: string): void { + this.collectionIds.add(id); + } + + trackConversation(id: string): void { + this.conversationIds.add(id); + } + + trackApiKey(id: string): void { + this.apiKeyIds.add(id); + } + + trackTpmjsApiKey(id: string): void { + this.tpmjsApiKeyIds.add(id); + } + + getTrackedIds(): { + agents: string[]; + collections: string[]; + conversations: string[]; + apiKeys: string[]; + tpmjsApiKeys: string[]; + } { + return { + agents: Array.from(this.agentIds), + collections: Array.from(this.collectionIds), + conversations: Array.from(this.conversationIds), + apiKeys: Array.from(this.apiKeyIds), + tpmjsApiKeys: Array.from(this.tpmjsApiKeyIds), + }; + } + + /** + * Clean up all tracked test data + */ + async cleanup(): Promise { + const { agents, collections, conversations, apiKeys, tpmjsApiKeys } = this.getTrackedIds(); + + // Delete in order to respect foreign key constraints + // 1. Conversations (depend on agents) + if (conversations.length > 0) { + await prisma.conversation.deleteMany({ + where: { id: { in: conversations } }, + }); + } + + // 2. Agent tool associations + if (agents.length > 0) { + await prisma.agentTool.deleteMany({ + where: { agentId: { in: agents } }, + }); + await prisma.agentCollection.deleteMany({ + where: { agentId: { in: agents } }, + }); + } + + // 3. Collection tool associations + if (collections.length > 0) { + await prisma.collectionTool.deleteMany({ + where: { collectionId: { in: collections } }, + }); + } + + // 4. Agents + if (agents.length > 0) { + await prisma.agent.deleteMany({ + where: { id: { in: agents } }, + }); + } + + // 5. Collections + if (collections.length > 0) { + await prisma.collection.deleteMany({ + where: { id: { in: collections } }, + }); + } + + // 6. User API keys + if (apiKeys.length > 0) { + await prisma.userApiKey.deleteMany({ + where: { id: { in: apiKeys } }, + }); + } + + // 7. TPMJS API keys + if (tpmjsApiKeys.length > 0) { + await prisma.tpmjsApiKey.deleteMany({ + where: { id: { in: tpmjsApiKeys } }, + }); + } + + // Clear tracked IDs + this.agentIds.clear(); + this.collectionIds.clear(); + this.conversationIds.clear(); + this.apiKeyIds.clear(); + this.tpmjsApiKeyIds.clear(); + } +} + +/** + * Clean up orphaned test data from previous failed runs + * Test data is identified by the 'test-' prefix + */ +export async function cleanupOrphanedTestData(userId: string): Promise<{ + agents: number; + collections: number; + conversations: number; +}> { + // Delete test conversations + const conversationsResult = await prisma.conversation.deleteMany({ + where: { + agent: { + userId, + uid: { startsWith: 'test-' }, + }, + }, + }); + + // Delete test agent associations + await prisma.agentTool.deleteMany({ + where: { + agent: { + userId, + uid: { startsWith: 'test-' }, + }, + }, + }); + await prisma.agentCollection.deleteMany({ + where: { + agent: { + userId, + uid: { startsWith: 'test-' }, + }, + }, + }); + + // Delete test agents + const agentsResult = await prisma.agent.deleteMany({ + where: { + userId, + uid: { startsWith: 'test-' }, + }, + }); + + // Delete test collection associations + await prisma.collectionTool.deleteMany({ + where: { + collection: { + userId, + slug: { startsWith: 'test-' }, + }, + }, + }); + + // Delete test collections + const collectionsResult = await prisma.collection.deleteMany({ + where: { + userId, + slug: { startsWith: 'test-' }, + }, + }); + + return { + agents: agentsResult.count, + collections: collectionsResult.count, + conversations: conversationsResult.count, + }; +} + +/** + * Clean up all test data for a specific user + */ +export async function cleanupAllTestData(userId: string): Promise { + // Delete all conversations for test agents + await prisma.conversation.deleteMany({ + where: { + agent: { + userId, + uid: { startsWith: 'test-' }, + }, + }, + }); + + // Delete all agent associations + await prisma.agentTool.deleteMany({ + where: { + agent: { + userId, + uid: { startsWith: 'test-' }, + }, + }, + }); + await prisma.agentCollection.deleteMany({ + where: { + agent: { + userId, + uid: { startsWith: 'test-' }, + }, + }, + }); + + // Delete all test agents + await prisma.agent.deleteMany({ + where: { + userId, + uid: { startsWith: 'test-' }, + }, + }); + + // Delete all collection associations + await prisma.collectionTool.deleteMany({ + where: { + collection: { + userId, + slug: { startsWith: 'test-' }, + }, + }, + }); + + // Delete all test collections + await prisma.collection.deleteMany({ + where: { + userId, + slug: { startsWith: 'test-' }, + }, + }); +} diff --git a/apps/web/src/test/integration/_helpers/factories/agent.factory.ts b/apps/web/src/test/integration/_helpers/factories/agent.factory.ts new file mode 100644 index 0000000..4425c9d --- /dev/null +++ b/apps/web/src/test/integration/_helpers/factories/agent.factory.ts @@ -0,0 +1,146 @@ +/** + * Agent factory for integration tests + * + * Creates test agents with auto-generated test prefixes + * and tracks them for cleanup. + */ + +import type { ApiClient } from '../api-client'; +import type { TestDataTracker } from '../cleanup'; + +export interface CreateAgentOptions { + uid?: string; + name?: string; + description?: string; + provider?: 'OPENAI' | 'ANTHROPIC' | 'GOOGLE' | 'GROQ' | 'MISTRAL'; + modelId?: string; + systemPrompt?: string; + temperature?: number; + maxToolCallsPerTurn?: number; + maxMessagesInContext?: number; + isPublic?: boolean; + toolIds?: string[]; + collectionIds?: string[]; +} + +export interface AgentFactoryResult { + id: string; + uid: string; + name: string; + description: string | null; + provider: string; + modelId: string; + isPublic: boolean; +} + +/** + * Create an agent factory bound to a specific API client and tracker + */ +export function createAgentFactory(api: ApiClient, tracker: TestDataTracker) { + let counter = 0; + + return { + /** + * Create a new test agent + */ + async create(options: CreateAgentOptions = {}): Promise { + counter++; + const timestamp = Date.now(); + + const uid = options.uid || `test-agent-${timestamp}-${counter}`; + const name = options.name || `Test Agent ${timestamp}-${counter}`; + + const result = await api.post<{ success: boolean; data: AgentFactoryResult }>('/api/agents', { + uid, + name, + description: options.description ?? 'Integration test agent', + provider: options.provider ?? 'OPENAI', + modelId: options.modelId ?? 'gpt-4o-mini', + systemPrompt: options.systemPrompt ?? 'You are a helpful test agent.', + temperature: options.temperature ?? 0.7, + maxToolCallsPerTurn: options.maxToolCallsPerTurn ?? 5, + maxMessagesInContext: options.maxMessagesInContext ?? 10, + isPublic: options.isPublic ?? true, + }); + + if (!result.ok) { + throw new Error(`Failed to create agent: ${(result as { error: string }).error}`); + } + + const agent = result.data.data; + tracker.trackAgent(agent.id); + + // Add tools if specified + if (options.toolIds && options.toolIds.length > 0) { + for (const toolId of options.toolIds) { + await api.post(`/api/agents/${agent.id}/tools`, { toolId }); + } + } + + // Add collections if specified + if (options.collectionIds && options.collectionIds.length > 0) { + for (const collectionId of options.collectionIds) { + await api.post(`/api/agents/${agent.id}/collections`, { collectionId }); + } + } + + return agent; + }, + + /** + * Create multiple test agents + */ + async createMany(count: number, options: CreateAgentOptions = {}): Promise { + const agents: AgentFactoryResult[] = []; + for (let i = 0; i < count; i++) { + const agent = await this.create({ + ...options, + uid: options.uid ? `${options.uid}-${i}` : undefined, + name: options.name ? `${options.name} ${i}` : undefined, + }); + agents.push(agent); + } + return agents; + }, + + /** + * Get an agent by ID + */ + async get(id: string): Promise { + const result = await api.get<{ success: boolean; data: AgentFactoryResult }>(`/api/agents/${id}`); + if (!result.ok) { + return null; + } + return result.data.data; + }, + + /** + * Update an agent + */ + async update( + id: string, + updates: Partial + ): Promise { + const result = await api.patch<{ success: boolean; data: AgentFactoryResult }>( + `/api/agents/${id}`, + updates + ); + if (!result.ok) { + throw new Error(`Failed to update agent: ${(result as { error: string }).error}`); + } + return result.data.data; + }, + + /** + * Delete an agent + */ + async delete(id: string): Promise { + const result = await api.delete(`/api/agents/${id}`); + if (!result.ok) { + throw new Error(`Failed to delete agent: ${(result as { error: string }).error}`); + } + }, + }; +} + +export type AgentFactory = ReturnType; diff --git a/apps/web/src/test/integration/_helpers/factories/collection.factory.ts b/apps/web/src/test/integration/_helpers/factories/collection.factory.ts new file mode 100644 index 0000000..68029b8 --- /dev/null +++ b/apps/web/src/test/integration/_helpers/factories/collection.factory.ts @@ -0,0 +1,155 @@ +/** + * Collection factory for integration tests + * + * Creates test collections with auto-generated test prefixes + * and tracks them for cleanup. + */ + +import type { ApiClient } from '../api-client'; +import type { TestDataTracker } from '../cleanup'; + +export interface CreateCollectionOptions { + slug?: string; + name?: string; + description?: string; + isPublic?: boolean; + toolIds?: string[]; +} + +export interface CollectionFactoryResult { + id: string; + slug: string; + name: string; + description: string | null; + isPublic: boolean; + toolCount?: number; +} + +/** + * Create a collection factory bound to a specific API client and tracker + */ +export function createCollectionFactory(api: ApiClient, tracker: TestDataTracker) { + let counter = 0; + + return { + /** + * Create a new test collection + */ + async create(options: CreateCollectionOptions = {}): Promise { + counter++; + const timestamp = Date.now(); + + const slug = options.slug || `test-collection-${timestamp}-${counter}`; + const name = options.name || `Test Collection ${timestamp}-${counter}`; + + const result = await api.post<{ success: boolean; data: CollectionFactoryResult }>( + '/api/collections', + { + slug, + name, + description: options.description ?? 'Integration test collection', + isPublic: options.isPublic ?? true, + } + ); + + if (!result.ok) { + throw new Error(`Failed to create collection: ${(result as { error: string }).error}`); + } + + const collection = result.data.data; + tracker.trackCollection(collection.id); + + // Add tools if specified + if (options.toolIds && options.toolIds.length > 0) { + for (const toolId of options.toolIds) { + await api.post(`/api/collections/${collection.id}/tools`, { toolId }); + } + } + + return collection; + }, + + /** + * Create multiple test collections + */ + async createMany( + count: number, + options: CreateCollectionOptions = {} + ): Promise { + const collections: CollectionFactoryResult[] = []; + for (let i = 0; i < count; i++) { + const collection = await this.create({ + ...options, + slug: options.slug ? `${options.slug}-${i}` : undefined, + name: options.name ? `${options.name} ${i}` : undefined, + }); + collections.push(collection); + } + return collections; + }, + + /** + * Get a collection by ID + */ + async get(id: string): Promise { + const result = await api.get<{ success: boolean; data: CollectionFactoryResult }>( + `/api/collections/${id}` + ); + if (!result.ok) { + return null; + } + return result.data.data; + }, + + /** + * Update a collection + */ + async update( + id: string, + updates: Partial + ): Promise { + const result = await api.patch<{ success: boolean; data: CollectionFactoryResult }>( + `/api/collections/${id}`, + updates + ); + if (!result.ok) { + throw new Error(`Failed to update collection: ${(result as { error: string }).error}`); + } + return result.data.data; + }, + + /** + * Delete a collection + */ + async delete(id: string): Promise { + const result = await api.delete(`/api/collections/${id}`); + if (!result.ok) { + throw new Error(`Failed to delete collection: ${(result as { error: string }).error}`); + } + }, + + /** + * Add a tool to a collection + */ + async addTool(collectionId: string, toolId: string): Promise { + const result = await api.post(`/api/collections/${collectionId}/tools`, { toolId }); + if (!result.ok) { + throw new Error(`Failed to add tool to collection: ${(result as { error: string }).error}`); + } + }, + + /** + * Remove a tool from a collection + */ + async removeTool(collectionId: string, toolId: string): Promise { + const result = await api.delete(`/api/collections/${collectionId}/tools/${toolId}`); + if (!result.ok) { + throw new Error( + `Failed to remove tool from collection: ${(result as { error: string }).error}` + ); + } + }, + }; +} + +export type CollectionFactory = ReturnType; diff --git a/apps/web/src/test/integration/_helpers/setup-test-credentials.ts b/apps/web/src/test/integration/_helpers/setup-test-credentials.ts new file mode 100644 index 0000000..59de7d7 --- /dev/null +++ b/apps/web/src/test/integration/_helpers/setup-test-credentials.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env tsx +/** + * Setup script for integration test credentials + * + * This script creates or updates the test user and generates: + * - A session token for session-based authentication + * - A TPMJS API key for API key authentication + * + * Run with: pnpm --filter=@tpmjs/web test:setup-credentials + * + * The output should be added to: + * - .env.local for local testing + * - GitHub secrets for CI testing + */ + +import { randomBytes, createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import { config } from 'dotenv'; + +// Load environment variables +config({ path: resolve(__dirname, '../../../../.env.local') }); +config({ path: resolve(__dirname, '../../../../.env') }); + +// Check for DATABASE_URL +if (!process.env.DATABASE_URL) { + console.error('โŒ DATABASE_URL not set. Please configure your .env.local file.'); + process.exit(1); +} + +// Import prisma after env is loaded +import { prisma } from '@tpmjs/db'; + +const TEST_EMAIL = 'integration-tests@tpmjs.com'; +const TEST_USERNAME = 'tpmjs-integration-test'; +const TEST_NAME = 'Integration Test User'; + +async function main() { + console.log('๐Ÿ”ง Setting up integration test credentials...\n'); + + try { + // 1. Find or create test user + let user = await prisma.user.findUnique({ + where: { email: TEST_EMAIL }, + }); + + if (!user) { + console.log('๐Ÿ“ Creating test user...'); + user = await prisma.user.create({ + data: { + email: TEST_EMAIL, + username: TEST_USERNAME, + name: TEST_NAME, + emailVerified: true, + }, + }); + console.log(` Created user: ${user.id}`); + } else { + console.log(`โœ… Test user exists: ${user.id}`); + + // Ensure username is set + if (!user.username) { + await prisma.user.update({ + where: { id: user.id }, + data: { username: TEST_USERNAME }, + }); + console.log(' Updated username'); + } + } + + // 2. Create or refresh session token + console.log('\n๐Ÿ“ Creating session token...'); + + // Delete old sessions + await prisma.session.deleteMany({ + where: { userId: user.id }, + }); + + // Create new session (valid for 30 days) + const sessionToken = randomBytes(32).toString('hex'); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + + await prisma.session.create({ + data: { + userId: user.id, + token: sessionToken, + expiresAt, + }, + }); + console.log(' Session created (expires in 30 days)'); + + // 3. Create or refresh TPMJS API key + console.log('\n๐Ÿ“ Creating TPMJS API key...'); + + // Delete old test API keys + await prisma.tpmjsApiKey.deleteMany({ + where: { + userId: user.id, + name: { startsWith: 'Integration Test' }, + }, + }); + + // Generate API key + const apiKeyRaw = `tpmjs_sk_${randomBytes(24).toString('hex')}`; + const keyHash = createHash('sha256').update(apiKeyRaw).digest('hex'); + const keyPrefix = apiKeyRaw.slice(0, 16); + + await prisma.tpmjsApiKey.create({ + data: { + userId: user.id, + name: 'Integration Test Key', + keyHash, + keyPrefix, + scopes: ['mcp:execute', 'agent:chat', 'bridge:connect', 'usage:read'], + isActive: true, + }, + }); + console.log(' API key created'); + + // 4. Output credentials + console.log('\n' + '='.repeat(60)); + console.log('๐ŸŽ‰ Integration test credentials generated!\n'); + console.log('Add these to your .env.local or GitHub secrets:\n'); + console.log('```'); + console.log(`INTEGRATION_TEST_USER_ID=${user.id}`); + console.log(`INTEGRATION_TEST_USERNAME=${TEST_USERNAME}`); + console.log(`INTEGRATION_TEST_SESSION_TOKEN=${sessionToken}`); + console.log(`INTEGRATION_TEST_API_KEY=${apiKeyRaw}`); + console.log('```'); + console.log('\n' + '='.repeat(60)); + + // 5. Verify credentials work + console.log('\n๐Ÿ” Verifying credentials...'); + + const session = await prisma.session.findUnique({ + where: { token: sessionToken }, + include: { user: true }, + }); + + if (session && session.user.id === user.id) { + console.log(' โœ… Session token is valid'); + } else { + console.log(' โŒ Session token verification failed'); + } + + const apiKey = await prisma.tpmjsApiKey.findUnique({ + where: { keyHash }, + }); + + if (apiKey && apiKey.userId === user.id) { + console.log(' โœ… API key is valid'); + } else { + console.log(' โŒ API key verification failed'); + } + + console.log('\nโœ… Setup complete!\n'); + } catch (error) { + console.error('โŒ Setup failed:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/apps/web/src/test/integration/_helpers/sse-parser.ts b/apps/web/src/test/integration/_helpers/sse-parser.ts new file mode 100644 index 0000000..f9888bb --- /dev/null +++ b/apps/web/src/test/integration/_helpers/sse-parser.ts @@ -0,0 +1,179 @@ +/** + * SSE (Server-Sent Events) parsing utilities for integration tests + * + * Used for testing streaming endpoints like agent conversations. + */ + +export interface SSEEvent { + event: string; + data: unknown; + id?: string; +} + +/** + * Parse SSE response text into array of events + */ +export function parseSSEResponse(text: string): SSEEvent[] { + const events: SSEEvent[] = []; + const lines = text.split('\n'); + + let currentEvent = ''; + let currentData = ''; + let currentId: string | undefined; + + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.slice(7); + } else if (line.startsWith('data: ')) { + currentData = line.slice(6); + if (currentEvent && currentData) { + try { + events.push({ + event: currentEvent, + data: JSON.parse(currentData), + id: currentId, + }); + } catch { + events.push({ + event: currentEvent, + data: currentData, + id: currentId, + }); + } + currentEvent = ''; + currentData = ''; + currentId = undefined; + } + } else if (line.startsWith('id: ')) { + currentId = line.slice(4); + } + } + + return events; +} + +/** + * Collect SSE events from a streaming response + */ +export async function collectSSEEvents( + response: Response, + options: { + timeout?: number; + maxEvents?: number; + } = {} +): Promise { + const { timeout = 30000, maxEvents = 1000 } = options; + + if (!response.body) { + throw new Error('Response has no body'); + } + + const events: SSEEvent[] = []; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`SSE collection timed out after ${timeout}ms`)), timeout); + }); + + try { + await Promise.race([ + (async () => { + while (events.length < maxEvents) { + const { done, value } = await reader.read(); + + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete events in buffer + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + let currentEvent = ''; + let currentData = ''; + let currentId: string | undefined; + + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.slice(7); + } else if (line.startsWith('data: ')) { + currentData = line.slice(6); + if (currentEvent && currentData) { + try { + events.push({ + event: currentEvent, + data: JSON.parse(currentData), + id: currentId, + }); + } catch { + events.push({ + event: currentEvent, + data: currentData, + id: currentId, + }); + } + currentEvent = ''; + currentData = ''; + currentId = undefined; + } + } else if (line.startsWith('id: ')) { + currentId = line.slice(4); + } + } + } + })(), + timeoutPromise, + ]); + } finally { + reader.releaseLock(); + } + + return events; +} + +/** + * Find an SSE event by event type + */ +export function findSSEEvent(events: SSEEvent[], eventType: string): SSEEvent | undefined { + return events.find((e) => e.event === eventType); +} + +/** + * Filter SSE events by event type + */ +export function filterSSEEvents(events: SSEEvent[], eventType: string): SSEEvent[] { + return events.filter((e) => e.event === eventType); +} + +/** + * Extract text content from chunk events + */ +export function extractTextFromChunks(events: SSEEvent[]): string { + return events + .filter((e) => e.event === 'chunk') + .map((e) => { + const data = e.data as { text?: string }; + return data.text || ''; + }) + .join(''); +} + +/** + * Wait for a specific event type in the stream + */ +export async function waitForEvent( + response: Response, + eventType: string, + timeout = 30000 +): Promise { + const events = await collectSSEEvents(response, { timeout }); + const event = findSSEEvent(events, eventType); + + if (!event) { + throw new Error(`Event '${eventType}' not found in SSE stream`); + } + + return event; +} diff --git a/apps/web/src/test/integration/_helpers/test-context.ts b/apps/web/src/test/integration/_helpers/test-context.ts new file mode 100644 index 0000000..76bbcb5 --- /dev/null +++ b/apps/web/src/test/integration/_helpers/test-context.ts @@ -0,0 +1,164 @@ +/** + * Integration test context manager + * + * Provides a unified context object for integration tests with: + * - Authenticated fetch functions (session, API key, cron) + * - API client + * - Data factories + * - Cleanup tracker + */ + +import { createApiClient, type ApiClient } from './api-client'; +import { + createApiKeyFetch, + createCronFetch, + createSessionFetch, + getTestAuthContext, + type TestAuthContext, +} from './auth'; +import { TestDataTracker } from './cleanup'; +import { createAgentFactory, type AgentFactory } from './factories/agent.factory'; +import { createCollectionFactory, type CollectionFactory } from './factories/collection.factory'; + +export interface IntegrationTestContext { + /** Base URL for API requests */ + baseUrl: string; + + /** Test user authentication context */ + auth: TestAuthContext; + + /** API client with session authentication */ + api: ApiClient; + + /** API client with API key authentication */ + apiKeyClient: ApiClient; + + /** API client with cron authentication */ + cronClient: ApiClient; + + /** Unauthenticated API client */ + publicClient: ApiClient; + + /** Fetch with session cookie */ + sessionFetch: typeof fetch; + + /** Fetch with API key header */ + apiKeyFetch: typeof fetch; + + /** Fetch with cron secret header */ + cronFetch: typeof fetch; + + /** Data factories */ + factories: { + agent: AgentFactory; + collection: CollectionFactory; + }; + + /** Test data tracker for cleanup */ + tracker: TestDataTracker; +} + +let cachedContext: IntegrationTestContext | null = null; + +/** + * Get or create the integration test context + * + * This is a singleton that's reused across all tests in a run. + * The tracker and factories are reset for each test file. + */ +export function getTestContext(): IntegrationTestContext { + if (cachedContext) { + return cachedContext; + } + + const baseUrl = process.env.TEST_BASE_URL || 'https://tpmjs.com'; + const auth = getTestAuthContext(); + + // Create authenticated fetch functions + const sessionFetch = createSessionFetch(auth.sessionToken); + const apiKeyFetch = createApiKeyFetch(auth.apiKey); + const cronFetch = createCronFetch(); + + // Create API clients + const api = createApiClient(baseUrl, sessionFetch); + const apiKeyClient = createApiClient(baseUrl, apiKeyFetch); + const cronClient = createApiClient(baseUrl, cronFetch); + const publicClient = createApiClient(baseUrl); + + // Create tracker + const tracker = new TestDataTracker(); + + // Create factories + const factories = { + agent: createAgentFactory(api, tracker), + collection: createCollectionFactory(api, tracker), + }; + + cachedContext = { + baseUrl, + auth, + api, + apiKeyClient, + cronClient, + publicClient, + sessionFetch, + apiKeyFetch, + cronFetch, + factories, + tracker, + }; + + return cachedContext; +} + +/** + * Reset the test context tracker + * + * Call this in afterAll() to clean up test data. + */ +export async function cleanupTestContext(): Promise { + if (cachedContext) { + await cachedContext.tracker.cleanup(); + } +} + +/** + * Create a fresh test context with its own tracker + * + * Use this when you need isolated test data that won't + * interfere with other tests. + */ +export function createFreshTestContext(): IntegrationTestContext { + const baseUrl = process.env.TEST_BASE_URL || 'https://tpmjs.com'; + const auth = getTestAuthContext(); + + const sessionFetch = createSessionFetch(auth.sessionToken); + const apiKeyFetch = createApiKeyFetch(auth.apiKey); + const cronFetch = createCronFetch(); + + const api = createApiClient(baseUrl, sessionFetch); + const apiKeyClient = createApiClient(baseUrl, apiKeyFetch); + const cronClient = createApiClient(baseUrl, cronFetch); + const publicClient = createApiClient(baseUrl); + + const tracker = new TestDataTracker(); + + const factories = { + agent: createAgentFactory(api, tracker), + collection: createCollectionFactory(api, tracker), + }; + + return { + baseUrl, + auth, + api, + apiKeyClient, + cronClient, + publicClient, + sessionFetch, + apiKeyFetch, + cronFetch, + factories, + tracker, + }; +} diff --git a/apps/web/src/test/integration/agents/agents-conversations.integration.test.ts b/apps/web/src/test/integration/agents/agents-conversations.integration.test.ts new file mode 100644 index 0000000..a189e70 --- /dev/null +++ b/apps/web/src/test/integration/agents/agents-conversations.integration.test.ts @@ -0,0 +1,202 @@ +/** + * Agent conversations endpoint integration tests + * + * Tests the agent chat/conversation endpoints with streaming responses. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; +import { extractTextFromChunks, findSSEEvent, filterSSEEvents } from '../_helpers/sse-parser'; + +describe('Agent Conversations Endpoints', () => { + let ctx: IntegrationTestContext; + let testAgent: { id: string; uid: string; name: string } | null = null; + + beforeAll(async () => { + ctx = getTestContext(); + + // Create a test agent for conversation tests + testAgent = await ctx.factories.agent.create({ + name: 'Conversation Test Agent', + description: 'Agent for testing conversations', + systemPrompt: 'You are a helpful assistant. Keep responses brief.', + maxToolCallsPerTurn: 5, + }); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('POST /api/:username/agents/:uid/conversation/:conversationId', () => { + it('should create a conversation and receive streaming response', async () => { + if (!testAgent) { + console.log('Skipping: No test agent available'); + return; + } + + const conversationSlug = `test-conv-${Date.now()}`; + const { ok, status, events } = await ctx.apiKeyClient.sse( + `/api/${ctx.auth.username}/agents/${testAgent.uid}/conversation/${conversationSlug}`, + { message: 'Hello, what is 2 + 2?' }, + { timeout: 60000 } + ); + + expect(ok).toBe(true); + expect(status).toBe(200); + expect(events.length).toBeGreaterThan(0); + + // Should have chunk events + const chunks = filterSSEEvents(events, 'chunk'); + expect(chunks.length).toBeGreaterThan(0); + + // Should have a complete event + const completeEvent = findSSEEvent(events, 'complete'); + expect(completeEvent).toBeDefined(); + + // Should have token usage + const tokensEvent = findSSEEvent(events, 'tokens'); + if (tokensEvent) { + const tokens = tokensEvent.data as { inputTokens?: number; outputTokens?: number }; + expect(tokens.inputTokens).toBeGreaterThan(0); + expect(tokens.outputTokens).toBeGreaterThan(0); + } + }); + + it('should maintain conversation context on follow-up messages', async () => { + if (!testAgent) { + console.log('Skipping: No test agent available'); + return; + } + + const conversationSlug = `test-context-${Date.now()}`; + + // First message + await ctx.apiKeyClient.sse( + `/api/${ctx.auth.username}/agents/${testAgent.uid}/conversation/${conversationSlug}`, + { message: 'Remember this number: 42' }, + { timeout: 60000 } + ); + + // Follow-up message + const { ok, events } = await ctx.apiKeyClient.sse( + `/api/${ctx.auth.username}/agents/${testAgent.uid}/conversation/${conversationSlug}`, + { message: 'What number did I ask you to remember?' }, + { timeout: 60000 } + ); + + expect(ok).toBe(true); + + // The response should mention 42 + const textContent = extractTextFromChunks(events); + expect(textContent.toLowerCase()).toMatch(/42|forty.?two/i); + }); + + it('should reject requests without API key', async () => { + if (!testAgent) { + console.log('Skipping: No test agent available'); + return; + } + + const conversationSlug = `test-unauth-${Date.now()}`; + const { ok, status } = await ctx.publicClient.sse( + `/api/${ctx.auth.username}/agents/${testAgent.uid}/conversation/${conversationSlug}`, + { message: 'Hello' } + ); + + expect(ok).toBe(false); + expect(status).toBe(401); + }); + }); + + describe('GET /api/:username/agents/:uid/conversation/:conversationId', () => { + it('should retrieve conversation history', async () => { + if (!testAgent) { + console.log('Skipping: No test agent available'); + return; + } + + const conversationSlug = `test-history-${Date.now()}`; + + // Create a conversation first + await ctx.apiKeyClient.sse( + `/api/${ctx.auth.username}/agents/${testAgent.uid}/conversation/${conversationSlug}`, + { message: 'Hello' }, + { timeout: 60000 } + ); + + // Get conversation history + const result = await ctx.apiKeyClient.get<{ + success: boolean; + data: { + id: string; + slug: string; + messages: Array<{ + role: string; + content: string; + }>; + }; + }>(`/api/${ctx.auth.username}/agents/${testAgent.uid}/conversation/${conversationSlug}`); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(result.data.data.slug).toBe(conversationSlug); + expect(result.data.data.messages.length).toBeGreaterThanOrEqual(2); // User + Assistant + } + }); + + it('should return 404 for non-existent conversation', async () => { + if (!testAgent) { + console.log('Skipping: No test agent available'); + return; + } + + const result = await ctx.apiKeyClient.get( + `/api/${ctx.auth.username}/agents/${testAgent.uid}/conversation/nonexistent-conv-12345` + ); + + expect(result.ok).toBe(false); + expect(result.status).toBe(404); + }); + }); + + describe('GET /api/:username/agents/:uid/conversations', () => { + it('should list all conversations for an agent', async () => { + if (!testAgent) { + console.log('Skipping: No test agent available'); + return; + } + + // Create a conversation first + const conversationSlug = `test-list-${Date.now()}`; + await ctx.apiKeyClient.sse( + `/api/${ctx.auth.username}/agents/${testAgent.uid}/conversation/${conversationSlug}`, + { message: 'Hello' }, + { timeout: 60000 } + ); + + // List conversations + const result = await ctx.apiKeyClient.get<{ + success: boolean; + data: Array<{ + id: string; + slug: string; + messageCount: number; + }>; + pagination: { + limit: number; + offset: number; + total?: number; + }; + }>(`/api/${ctx.auth.username}/agents/${testAgent.uid}/conversations`); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + expect(result.data.pagination).toBeDefined(); + } + }); + }); +}); diff --git a/apps/web/src/test/integration/agents/agents-crud.integration.test.ts b/apps/web/src/test/integration/agents/agents-crud.integration.test.ts new file mode 100644 index 0000000..9e4bcd5 --- /dev/null +++ b/apps/web/src/test/integration/agents/agents-crud.integration.test.ts @@ -0,0 +1,184 @@ +/** + * Agents CRUD endpoint integration tests + * + * Tests creating, reading, updating, and deleting agents. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +interface AgentResponse { + id: string; + uid: string; + name: string; + description: string | null; + provider: string; + modelId: string; + isPublic: boolean; +} + +describe('Agents CRUD Endpoints', () => { + let ctx: IntegrationTestContext; + + beforeAll(() => { + ctx = getTestContext(); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('POST /api/agents', () => { + it('should create a new agent with session auth', async () => { + const agent = await ctx.factories.agent.create({ + name: 'Test CRUD Agent', + description: 'Created via integration test', + provider: 'OPENAI', + modelId: 'gpt-4o-mini', + isPublic: true, + }); + + expect(agent.id).toBeDefined(); + expect(agent.name).toBe('Test CRUD Agent'); + expect(agent.description).toBe('Created via integration test'); + expect(agent.provider).toBe('OPENAI'); + expect(agent.isPublic).toBe(true); + }); + + it('should reject creation without auth', async () => { + const result = await ctx.publicClient.post<{ success: boolean }>('/api/agents', { + uid: `test-unauth-${Date.now()}`, + name: 'Unauthorized Agent', + provider: 'OPENAI', + modelId: 'gpt-4o-mini', + }); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + + it('should reject duplicate uids', async () => { + const uid = `test-duplicate-uid-${Date.now()}`; + + // Create first agent + await ctx.factories.agent.create({ uid }); + + // Try to create second with same uid + const result = await ctx.api.post<{ success: boolean; error?: string }>('/api/agents', { + uid, + name: 'Duplicate UID Agent', + provider: 'OPENAI', + modelId: 'gpt-4o-mini', + }); + + expect(result.ok).toBe(false); + }); + }); + + describe('GET /api/agents', () => { + it('should list user agents with session auth', async () => { + // Create a test agent first + await ctx.factories.agent.create(); + + const result = await ctx.api.get<{ + success: boolean; + data: AgentResponse[]; + }>('/api/agents'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + } + }); + + it('should reject without auth', async () => { + const result = await ctx.publicClient.get('/api/agents'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('GET /api/agents/:id', () => { + it('should get a specific agent', async () => { + const created = await ctx.factories.agent.create({ + name: 'Specific Agent', + }); + + const result = await ctx.api.get<{ + success: boolean; + data: AgentResponse; + }>(`/api/agents/${created.id}`); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.id).toBe(created.id); + expect(result.data.data.name).toBe('Specific Agent'); + } + }); + + it('should return 404 for non-existent agent', async () => { + const result = await ctx.api.get('/api/agents/nonexistent-id-12345'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(404); + }); + }); + + describe('PATCH /api/agents/:id', () => { + it('should update an agent', async () => { + const created = await ctx.factories.agent.create({ + name: 'Before Update', + description: 'Original description', + }); + + const result = await ctx.api.patch<{ + success: boolean; + data: AgentResponse; + }>(`/api/agents/${created.id}`, { + name: 'After Update', + description: 'Updated description', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.name).toBe('After Update'); + expect(result.data.data.description).toBe('Updated description'); + } + }); + + it('should reject update without auth', async () => { + const created = await ctx.factories.agent.create(); + + const result = await ctx.publicClient.patch(`/api/agents/${created.id}`, { + name: 'Unauthorized Update', + }); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('DELETE /api/agents/:id', () => { + it('should delete an agent', async () => { + const created = await ctx.factories.agent.create(); + + const deleteResult = await ctx.api.delete(`/api/agents/${created.id}`); + expect(deleteResult.ok).toBe(true); + + // Verify it's deleted + const getResult = await ctx.api.get(`/api/agents/${created.id}`); + expect(getResult.ok).toBe(false); + expect(getResult.status).toBe(404); + }); + + it('should reject delete without auth', async () => { + const created = await ctx.factories.agent.create(); + + const result = await ctx.publicClient.delete(`/api/agents/${created.id}`); + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); +}); diff --git a/apps/web/src/test/integration/collections/collections-crud.integration.test.ts b/apps/web/src/test/integration/collections/collections-crud.integration.test.ts new file mode 100644 index 0000000..915821d --- /dev/null +++ b/apps/web/src/test/integration/collections/collections-crud.integration.test.ts @@ -0,0 +1,176 @@ +/** + * Collections CRUD endpoint integration tests + * + * Tests creating, reading, updating, and deleting collections. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +interface CollectionResponse { + id: string; + slug: string; + name: string; + description: string | null; + isPublic: boolean; + toolCount?: number; +} + +describe('Collections CRUD Endpoints', () => { + let ctx: IntegrationTestContext; + + beforeAll(() => { + ctx = getTestContext(); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('POST /api/collections', () => { + it('should create a new collection with session auth', async () => { + const collection = await ctx.factories.collection.create({ + name: 'Test CRUD Collection', + description: 'Created via integration test', + isPublic: true, + }); + + expect(collection.id).toBeDefined(); + expect(collection.name).toBe('Test CRUD Collection'); + expect(collection.description).toBe('Created via integration test'); + expect(collection.isPublic).toBe(true); + }); + + it('should reject creation without auth', async () => { + const result = await ctx.publicClient.post<{ success: boolean }>('/api/collections', { + name: 'Unauthorized Collection', + description: 'Should fail', + }); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + + it('should reject duplicate slugs for same user', async () => { + const slug = `test-duplicate-${Date.now()}`; + + // Create first collection + await ctx.factories.collection.create({ slug }); + + // Try to create second with same slug + const result = await ctx.api.post<{ success: boolean; error?: string }>('/api/collections', { + slug, + name: 'Duplicate Slug', + }); + + expect(result.ok).toBe(false); + }); + }); + + describe('GET /api/collections', () => { + it('should list user collections with session auth', async () => { + // Create a test collection first + await ctx.factories.collection.create(); + + const result = await ctx.api.get<{ + success: boolean; + data: CollectionResponse[]; + }>('/api/collections'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + } + }); + + it('should reject without auth', async () => { + const result = await ctx.publicClient.get('/api/collections'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('GET /api/collections/:id', () => { + it('should get a specific collection', async () => { + const created = await ctx.factories.collection.create({ + name: 'Specific Collection', + }); + + const result = await ctx.api.get<{ + success: boolean; + data: CollectionResponse; + }>(`/api/collections/${created.id}`); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.id).toBe(created.id); + expect(result.data.data.name).toBe('Specific Collection'); + } + }); + + it('should return 404 for non-existent collection', async () => { + const result = await ctx.api.get('/api/collections/nonexistent-id-12345'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(404); + }); + }); + + describe('PATCH /api/collections/:id', () => { + it('should update a collection', async () => { + const created = await ctx.factories.collection.create({ + name: 'Before Update', + description: 'Original description', + }); + + const result = await ctx.api.patch<{ + success: boolean; + data: CollectionResponse; + }>(`/api/collections/${created.id}`, { + name: 'After Update', + description: 'Updated description', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.name).toBe('After Update'); + expect(result.data.data.description).toBe('Updated description'); + } + }); + + it('should reject update without auth', async () => { + const created = await ctx.factories.collection.create(); + + const result = await ctx.publicClient.patch(`/api/collections/${created.id}`, { + name: 'Unauthorized Update', + }); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('DELETE /api/collections/:id', () => { + it('should delete a collection', async () => { + const created = await ctx.factories.collection.create(); + + const deleteResult = await ctx.api.delete(`/api/collections/${created.id}`); + expect(deleteResult.ok).toBe(true); + + // Verify it's deleted + const getResult = await ctx.api.get(`/api/collections/${created.id}`); + expect(getResult.ok).toBe(false); + expect(getResult.status).toBe(404); + }); + + it('should reject delete without auth', async () => { + const created = await ctx.factories.collection.create(); + + const result = await ctx.publicClient.delete(`/api/collections/${created.id}`); + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); +}); diff --git a/apps/web/src/test/integration/health/health.integration.test.ts b/apps/web/src/test/integration/health/health.integration.test.ts new file mode 100644 index 0000000..a3ecd1c --- /dev/null +++ b/apps/web/src/test/integration/health/health.integration.test.ts @@ -0,0 +1,73 @@ +/** + * Health endpoint integration tests + * + * Tests the /api/health endpoint which provides system status information. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +describe('Health Endpoint', () => { + let ctx: IntegrationTestContext; + + beforeAll(() => { + ctx = getTestContext(); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('GET /api/health', () => { + it('should return health status', async () => { + const result = await ctx.publicClient.get<{ + status: string; + timestamp: string; + build?: { + commitSha: string; + commitMessage: string; + deploymentUrl: string; + }; + env?: { + hasDatabase: boolean; + nodeEnv: string; + }; + }>('/api/health'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.status).toBe('ok'); + expect(result.data.timestamp).toBeDefined(); + expect(new Date(result.data.timestamp).getTime()).toBeGreaterThan(0); + } + }); + + it('should include build information in production', async () => { + const result = await ctx.publicClient.get<{ + status: string; + timestamp: string; + build?: { + commitSha: string; + commitMessage: string; + deploymentUrl: string; + }; + }>('/api/health'); + + expect(result.ok).toBe(true); + if (result.ok && result.data.build) { + // In production, build info should be present + expect(result.data.build.commitSha).toBeDefined(); + expect(typeof result.data.build.commitSha).toBe('string'); + } + }); + + it('should respond quickly (within 5 seconds)', async () => { + const startTime = Date.now(); + const result = await ctx.publicClient.get('/api/health'); + const duration = Date.now() - startTime; + + expect(result.ok).toBe(true); + expect(duration).toBeLessThan(5000); + }); + }); +}); diff --git a/apps/web/src/test/integration/mcp/mcp-http.integration.test.ts b/apps/web/src/test/integration/mcp/mcp-http.integration.test.ts new file mode 100644 index 0000000..5625286 --- /dev/null +++ b/apps/web/src/test/integration/mcp/mcp-http.integration.test.ts @@ -0,0 +1,178 @@ +/** + * MCP HTTP endpoint integration tests + * + * Tests the MCP HTTP transport endpoint for JSON-RPC communication. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +interface JsonRpcResponse { + jsonrpc: string; + id: number; + result?: unknown; + error?: { + code: number; + message: string; + }; +} + +describe('MCP HTTP Endpoint', () => { + let ctx: IntegrationTestContext; + let testCollection: { id: string; slug: string } | null = null; + + beforeAll(async () => { + ctx = getTestContext(); + + // Create a test collection for MCP tests + testCollection = await ctx.factories.collection.create({ + name: 'MCP Test Collection', + description: 'Collection for testing MCP endpoints', + isPublic: true, + }); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('GET /api/mcp/:username/:slug/http', () => { + it('should return server info', async () => { + if (!testCollection) { + console.log('Skipping: No test collection available'); + return; + } + + const result = await ctx.apiKeyClient.get<{ + name: string; + version: string; + protocolVersion: string; + }>(`/api/mcp/${ctx.auth.username}/${testCollection.slug}/http`); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.name).toBeDefined(); + expect(result.data.protocolVersion).toBeDefined(); + } + }); + }); + + describe('POST /api/mcp/:username/:slug/http - initialize', () => { + it('should initialize MCP session', async () => { + if (!testCollection) { + console.log('Skipping: No test collection available'); + return; + } + + const result = await ctx.apiKeyClient.post( + `/api/mcp/${ctx.auth.username}/${testCollection.slug}/http`, + { + jsonrpc: '2.0', + method: 'initialize', + id: 1, + } + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.jsonrpc).toBe('2.0'); + expect(result.data.id).toBe(1); + expect(result.data.result).toBeDefined(); + } + }); + + it('should reject without API key', async () => { + if (!testCollection) { + console.log('Skipping: No test collection available'); + return; + } + + const result = await ctx.publicClient.post( + `/api/mcp/${ctx.auth.username}/${testCollection.slug}/http`, + { + jsonrpc: '2.0', + method: 'initialize', + id: 1, + } + ); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('POST /api/mcp/:username/:slug/http - tools/list', () => { + it('should list available tools', async () => { + if (!testCollection) { + console.log('Skipping: No test collection available'); + return; + } + + const result = await ctx.apiKeyClient.post( + `/api/mcp/${ctx.auth.username}/${testCollection.slug}/http`, + { + jsonrpc: '2.0', + method: 'tools/list', + id: 2, + } + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.jsonrpc).toBe('2.0'); + expect(result.data.id).toBe(2); + expect(result.data.result).toBeDefined(); + + const listResult = result.data.result as { tools: unknown[] }; + expect(Array.isArray(listResult.tools)).toBe(true); + } + }); + }); + + describe('POST /api/mcp/:username/:slug/http - invalid method', () => { + it('should return error for unknown method', async () => { + if (!testCollection) { + console.log('Skipping: No test collection available'); + return; + } + + const result = await ctx.apiKeyClient.post( + `/api/mcp/${ctx.auth.username}/${testCollection.slug}/http`, + { + jsonrpc: '2.0', + method: 'nonexistent/method', + id: 3, + } + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.error).toBeDefined(); + } + }); + }); + + describe('Collection ID as slug (backwards compatibility)', () => { + it('should accept collection ID instead of slug', async () => { + if (!testCollection) { + console.log('Skipping: No test collection available'); + return; + } + + // Use collection ID instead of slug + const result = await ctx.apiKeyClient.post( + `/api/mcp/${ctx.auth.username}/${testCollection.id}/http`, + { + jsonrpc: '2.0', + method: 'initialize', + id: 1, + } + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.result).toBeDefined(); + } + }); + }); +}); diff --git a/apps/web/src/test/integration/public/public-agents.integration.test.ts b/apps/web/src/test/integration/public/public-agents.integration.test.ts new file mode 100644 index 0000000..250d87b --- /dev/null +++ b/apps/web/src/test/integration/public/public-agents.integration.test.ts @@ -0,0 +1,138 @@ +/** + * Public agents endpoint integration tests + * + * Tests the public agent listing endpoints (no auth required). + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +interface PublicAgent { + id: string; + uid: string; + name: string; + description: string | null; + provider: string; + modelId: string; + isPublic: boolean; + likeCount: number; + createdBy: { + username: string; + name: string; + image: string | null; + }; +} + +describe('Public Agents Endpoints', () => { + let ctx: IntegrationTestContext; + + beforeAll(async () => { + ctx = getTestContext(); + + // Create a public test agent + await ctx.factories.agent.create({ + name: 'Public Test Agent', + description: 'A public agent for testing', + isPublic: true, + }); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('GET /api/public/agents', () => { + it('should list public agents without auth', async () => { + const result = await ctx.publicClient.get<{ + success: boolean; + data: PublicAgent[]; + pagination: { + limit: number; + offset: number; + hasMore: boolean; + }; + }>('/api/public/agents'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + expect(result.data.pagination).toBeDefined(); + + // All returned agents should be public + for (const agent of result.data.data) { + expect(agent.isPublic).toBe(true); + } + } + }); + + it('should support pagination', async () => { + const result = await ctx.publicClient.get<{ + success: boolean; + data: PublicAgent[]; + pagination: { + limit: number; + offset: number; + hasMore: boolean; + }; + }>('/api/public/agents', { + query: { limit: 5, offset: 0 }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.length).toBeLessThanOrEqual(5); + expect(result.data.pagination.limit).toBe(5); + } + }); + + it('should include creator information', async () => { + const result = await ctx.publicClient.get<{ + success: boolean; + data: PublicAgent[]; + }>('/api/public/agents', { + query: { limit: 1 }, + }); + + expect(result.ok).toBe(true); + if (result.ok && result.data.data.length > 0) { + const agent = result.data.data[0]!; + expect(agent.createdBy).toBeDefined(); + expect(agent.createdBy.username).toBeDefined(); + } + }); + }); + + describe('GET /api/public/agents/:username/:uid', () => { + it('should get a specific public agent', async () => { + // First, get list to find a public agent + const listResult = await ctx.publicClient.get<{ + success: boolean; + data: PublicAgent[]; + }>('/api/public/agents', { query: { limit: 1 } }); + + if (!listResult.ok || listResult.data.data.length === 0) { + console.log('Skipping: No public agents available'); + return; + } + + const agent = listResult.data.data[0]!; + const result = await ctx.publicClient.get<{ + success: boolean; + data: PublicAgent; + }>(`/api/public/agents/${agent.createdBy.username}/${agent.uid}`); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.uid).toBe(agent.uid); + } + }); + + it('should return 404 for non-existent agent', async () => { + const result = await ctx.publicClient.get('/api/public/agents/nonexistent-user/nonexistent-agent'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(404); + }); + }); +}); diff --git a/apps/web/src/test/integration/public/public-collections.integration.test.ts b/apps/web/src/test/integration/public/public-collections.integration.test.ts new file mode 100644 index 0000000..2252429 --- /dev/null +++ b/apps/web/src/test/integration/public/public-collections.integration.test.ts @@ -0,0 +1,139 @@ +/** + * Public collections endpoint integration tests + * + * Tests the public collection listing endpoints (no auth required). + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +interface PublicCollection { + id: string; + slug: string; + name: string; + description: string | null; + isPublic: boolean; + likeCount: number; + toolCount: number; + createdBy: { + username: string; + name: string; + image: string | null; + }; +} + +describe('Public Collections Endpoints', () => { + let ctx: IntegrationTestContext; + + beforeAll(async () => { + ctx = getTestContext(); + + // Create a public test collection + await ctx.factories.collection.create({ + name: 'Public Test Collection', + description: 'A public collection for testing', + isPublic: true, + }); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('GET /api/public/collections', () => { + it('should list public collections without auth', async () => { + const result = await ctx.publicClient.get<{ + success: boolean; + data: PublicCollection[]; + pagination: { + limit: number; + offset: number; + hasMore: boolean; + }; + }>('/api/public/collections'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + expect(result.data.pagination).toBeDefined(); + + // All returned collections should be public + for (const collection of result.data.data) { + expect(collection.isPublic).toBe(true); + } + } + }); + + it('should support pagination', async () => { + const result = await ctx.publicClient.get<{ + success: boolean; + data: PublicCollection[]; + pagination: { + limit: number; + offset: number; + hasMore: boolean; + }; + }>('/api/public/collections', { + query: { limit: 5, offset: 0 }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.length).toBeLessThanOrEqual(5); + expect(result.data.pagination.limit).toBe(5); + } + }); + + it('should include creator information', async () => { + const result = await ctx.publicClient.get<{ + success: boolean; + data: PublicCollection[]; + }>('/api/public/collections', { + query: { limit: 1 }, + }); + + expect(result.ok).toBe(true); + if (result.ok && result.data.data.length > 0) { + const collection = result.data.data[0]!; + expect(collection.createdBy).toBeDefined(); + expect(collection.createdBy.username).toBeDefined(); + } + }); + }); + + describe('GET /api/public/collections/:username/:slug', () => { + it('should get a specific public collection', async () => { + // First, get list to find a public collection + const listResult = await ctx.publicClient.get<{ + success: boolean; + data: PublicCollection[]; + }>('/api/public/collections', { query: { limit: 1 } }); + + if (!listResult.ok || listResult.data.data.length === 0) { + console.log('Skipping: No public collections available'); + return; + } + + const collection = listResult.data.data[0]!; + const result = await ctx.publicClient.get<{ + success: boolean; + data: PublicCollection; + }>(`/api/public/collections/${collection.createdBy.username}/${collection.slug}`); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.slug).toBe(collection.slug); + } + }); + + it('should return 404 for non-existent collection', async () => { + const result = await ctx.publicClient.get( + '/api/public/collections/nonexistent-user/nonexistent-collection' + ); + + expect(result.ok).toBe(false); + expect(result.status).toBe(404); + }); + }); +}); diff --git a/apps/web/src/test/integration/setup-integration.ts b/apps/web/src/test/integration/setup-integration.ts new file mode 100644 index 0000000..aa88edf --- /dev/null +++ b/apps/web/src/test/integration/setup-integration.ts @@ -0,0 +1,58 @@ +/** + * Integration test setup + * + * This file runs before all integration tests to: + * - Load environment variables + * - Validate required configuration + * - Set up global test utilities + */ + +import { resolve } from 'node:path'; +import { config } from 'dotenv'; + +// Load environment variables +config({ path: resolve(__dirname, '../../../.env.local') }); +config({ path: resolve(__dirname, '../../../.env') }); + +// Validate required environment variables +const requiredEnvVars = [ + 'TEST_BASE_URL', + 'INTEGRATION_TEST_SESSION_TOKEN', + 'INTEGRATION_TEST_API_KEY', +]; + +const missingVars: string[] = []; + +for (const envVar of requiredEnvVars) { + if (!process.env[envVar]) { + missingVars.push(envVar); + } +} + +if (missingVars.length > 0) { + console.error('โŒ Missing required environment variables for integration tests:'); + for (const varName of missingVars) { + console.error(` - ${varName}`); + } + console.error('\nTo set up integration tests:'); + console.error('1. Run: pnpm --filter=@tpmjs/web test:setup-credentials'); + console.error('2. Add the generated values to your .env.local or GitHub secrets'); + process.exit(1); +} + +// Optional environment variables (warn if missing) +const optionalEnvVars = ['CRON_SECRET']; + +for (const envVar of optionalEnvVars) { + if (!process.env[envVar]) { + console.warn(`โš ๏ธ Warning: ${envVar} not set. Cron endpoint tests may be skipped.`); + } +} + +// Log configuration (without sensitive values) +console.log('โœ… Integration test configuration:'); +console.log(` Base URL: ${process.env.TEST_BASE_URL}`); +console.log(` Session token: ${process.env.INTEGRATION_TEST_SESSION_TOKEN?.slice(0, 10)}...`); +console.log(` API key: ${process.env.INTEGRATION_TEST_API_KEY?.slice(0, 15)}...`); +console.log(` Cron secret: ${process.env.CRON_SECRET ? 'configured' : 'not set'}`); +console.log(''); diff --git a/apps/web/src/test/integration/stats/stats.integration.test.ts b/apps/web/src/test/integration/stats/stats.integration.test.ts new file mode 100644 index 0000000..b442b24 --- /dev/null +++ b/apps/web/src/test/integration/stats/stats.integration.test.ts @@ -0,0 +1,100 @@ +/** + * Stats endpoint integration tests + * + * Tests the /api/stats/* endpoints which provide registry statistics. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +describe('Stats Endpoints', () => { + let ctx: IntegrationTestContext; + + beforeAll(() => { + ctx = getTestContext(); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('GET /api/stats', () => { + it('should return current statistics', async () => { + const result = await ctx.publicClient.get<{ + success: boolean; + data: { + totalTools: number; + totalPackages: number; + officialTools: number; + toolsWithSchema: number; + healthyTools: number; + brokenTools: number; + }; + }>('/api/stats'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(result.data.data).toBeDefined(); + expect(result.data.data.totalTools).toBeGreaterThanOrEqual(0); + expect(result.data.data.totalPackages).toBeGreaterThanOrEqual(0); + } + }); + }); + + describe('GET /api/stats/history', () => { + it('should return historical statistics', async () => { + const result = await ctx.publicClient.get<{ + success: boolean; + data: Array<{ + date: string; + totalTools: number; + totalPackages: number; + }>; + }>('/api/stats/history'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + } + }); + + it('should support limit parameter', async () => { + const result = await ctx.publicClient.get<{ + success: boolean; + data: Array<{ + date: string; + totalTools: number; + }>; + }>('/api/stats/history', { query: { limit: 7 } }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.length).toBeLessThanOrEqual(7); + } + }); + }); + + describe('GET /api/stats/categories', () => { + it('should return category breakdown', async () => { + const result = await ctx.publicClient.get<{ + success: boolean; + data: Array<{ + category: string; + count: number; + }>; + }>('/api/stats/categories'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + if (result.data.data.length > 0) { + expect(result.data.data[0]).toHaveProperty('category'); + expect(result.data.data[0]).toHaveProperty('count'); + } + } + }); + }); +}); diff --git a/apps/web/src/test/integration/sync/sync-endpoints.integration.test.ts b/apps/web/src/test/integration/sync/sync-endpoints.integration.test.ts new file mode 100644 index 0000000..82389be --- /dev/null +++ b/apps/web/src/test/integration/sync/sync-endpoints.integration.test.ts @@ -0,0 +1,115 @@ +/** + * Sync endpoints integration tests + * + * Tests the cron-triggered sync endpoints that require CRON_SECRET auth. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +// Skip if CRON_SECRET is not configured +const CRON_SECRET_CONFIGURED = !!process.env.CRON_SECRET; + +describe.skipIf(!CRON_SECRET_CONFIGURED)('Sync Endpoints', () => { + let ctx: IntegrationTestContext; + + beforeAll(() => { + ctx = getTestContext(); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('POST /api/sync/changes', () => { + it('should execute changes feed sync with cron auth', async () => { + const result = await ctx.cronClient.post<{ + success: boolean; + data: { + processed: number; + skipped: number; + errors: number; + lastSeq?: string; + durationMs: number; + }; + }>('/api/sync/changes'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(result.data.data.processed).toBeGreaterThanOrEqual(0); + expect(result.data.data.durationMs).toBeGreaterThan(0); + } + }); + + it('should reject without cron auth', async () => { + const result = await ctx.publicClient.post('/api/sync/changes'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + + it('should reject with regular API key', async () => { + const result = await ctx.apiKeyClient.post('/api/sync/changes'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('POST /api/sync/keyword', () => { + it('should execute keyword search sync with cron auth', async () => { + const result = await ctx.cronClient.post<{ + success: boolean; + data: { + processed: number; + skipped: number; + errors: number; + packagesFound?: number; + durationMs: number; + }; + }>('/api/sync/keyword'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(result.data.data.processed).toBeGreaterThanOrEqual(0); + } + }); + + it('should reject without cron auth', async () => { + const result = await ctx.publicClient.post('/api/sync/keyword'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('POST /api/sync/metrics', () => { + it('should execute metrics sync with cron auth', async () => { + const result = await ctx.cronClient.post<{ + success: boolean; + data: { + processed: number; + skipped: number; + errors: number; + totalTools?: number; + durationMs: number; + }; + }>('/api/sync/metrics'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(result.data.data.processed).toBeGreaterThanOrEqual(0); + } + }); + + it('should reject without cron auth', async () => { + const result = await ctx.publicClient.post('/api/sync/metrics'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); +}); diff --git a/apps/web/src/test/integration/tools/tools-list.integration.test.ts b/apps/web/src/test/integration/tools/tools-list.integration.test.ts new file mode 100644 index 0000000..25b59e4 --- /dev/null +++ b/apps/web/src/test/integration/tools/tools-list.integration.test.ts @@ -0,0 +1,107 @@ +/** + * Tools list endpoint integration tests + * + * Tests the /api/tools endpoint for listing and searching tools. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +interface ToolResponse { + id: string; + name: string; + description: string; + package: { + npmPackageName: string; + npmVersion: string; + category: string; + }; +} + +interface ToolsListResponse { + success: boolean; + data: ToolResponse[]; + pagination: { + limit: number; + offset: number; + hasMore: boolean; + }; +} + +describe('Tools List Endpoint', () => { + let ctx: IntegrationTestContext; + + beforeAll(() => { + ctx = getTestContext(); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('GET /api/tools', () => { + it('should return a list of tools', async () => { + const result = await ctx.publicClient.get('/api/tools'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + expect(result.data.pagination).toBeDefined(); + } + }); + + it('should support pagination with limit and offset', async () => { + const result = await ctx.publicClient.get('/api/tools', { + query: { limit: 5, offset: 0 }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.length).toBeLessThanOrEqual(5); + expect(result.data.pagination.limit).toBe(5); + expect(result.data.pagination.offset).toBe(0); + } + }); + + it('should filter by category', async () => { + const result = await ctx.publicClient.get('/api/tools', { + query: { category: 'ai' }, + }); + + expect(result.ok).toBe(true); + if (result.ok && result.data.data.length > 0) { + for (const tool of result.data.data) { + expect(tool.package.category).toBe('ai'); + } + } + }); + + it('should filter by official status', async () => { + const result = await ctx.publicClient.get('/api/tools', { + query: { isOfficial: true }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + } + }); + + it('should return tools with required fields', async () => { + const result = await ctx.publicClient.get('/api/tools', { + query: { limit: 1 }, + }); + + expect(result.ok).toBe(true); + if (result.ok && result.data.data.length > 0) { + const tool = result.data.data[0]!; + expect(tool.id).toBeDefined(); + expect(tool.name).toBeDefined(); + expect(tool.description).toBeDefined(); + expect(tool.package).toBeDefined(); + expect(tool.package.npmPackageName).toBeDefined(); + } + }); + }); +}); diff --git a/apps/web/src/test/integration/tools/tools-search.integration.test.ts b/apps/web/src/test/integration/tools/tools-search.integration.test.ts new file mode 100644 index 0000000..0d00da0 --- /dev/null +++ b/apps/web/src/test/integration/tools/tools-search.integration.test.ts @@ -0,0 +1,91 @@ +/** + * Tools search endpoint integration tests + * + * Tests the /api/tools/search endpoint for searching tools. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +interface ToolSearchResult { + id: string; + name: string; + description: string; + package: { + npmPackageName: string; + category: string; + }; + score?: number; +} + +interface SearchResponse { + success: boolean; + data: ToolSearchResult[]; + pagination?: { + limit: number; + offset: number; + hasMore: boolean; + }; +} + +describe('Tools Search Endpoint', () => { + let ctx: IntegrationTestContext; + + beforeAll(() => { + ctx = getTestContext(); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('GET /api/tools/search', () => { + it('should search tools by query', async () => { + const result = await ctx.publicClient.get('/api/tools/search', { + query: { q: 'file' }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + } + }); + + it('should return empty results for non-matching query', async () => { + const result = await ctx.publicClient.get('/api/tools/search', { + query: { q: 'xyznonexistent123456' }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.length).toBe(0); + } + }); + + it('should support limit parameter', async () => { + const result = await ctx.publicClient.get('/api/tools/search', { + query: { q: 'tool', limit: 3 }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data.length).toBeLessThanOrEqual(3); + } + }); + + it('should return tools with required search fields', async () => { + const result = await ctx.publicClient.get('/api/tools/search', { + query: { q: 'tool', limit: 1 }, + }); + + expect(result.ok).toBe(true); + if (result.ok && result.data.data.length > 0) { + const tool = result.data.data[0]!; + expect(tool.id).toBeDefined(); + expect(tool.name).toBeDefined(); + expect(tool.description).toBeDefined(); + } + }); + }); +}); diff --git a/apps/web/src/test/integration/user/user-profile.integration.test.ts b/apps/web/src/test/integration/user/user-profile.integration.test.ts new file mode 100644 index 0000000..7b4352a --- /dev/null +++ b/apps/web/src/test/integration/user/user-profile.integration.test.ts @@ -0,0 +1,96 @@ +/** + * User profile endpoint integration tests + * + * Tests the user profile GET and PATCH endpoints. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +interface UserProfile { + id: string; + name: string; + email: string; + username: string | null; + image: string | null; + tier: string; +} + +describe('User Profile Endpoints', () => { + let ctx: IntegrationTestContext; + + beforeAll(() => { + ctx = getTestContext(); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('GET /api/user/profile', () => { + it('should return user profile with session auth', async () => { + const result = await ctx.api.get<{ + success: boolean; + data: UserProfile; + }>('/api/user/profile'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(result.data.data.id).toBeDefined(); + expect(result.data.data.email).toBeDefined(); + expect(result.data.data.username).toBe(ctx.auth.username); + } + }); + + it('should reject without auth', async () => { + const result = await ctx.publicClient.get('/api/user/profile'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('PATCH /api/user/profile', () => { + it('should update user profile', async () => { + // First, get current profile + const profileResult = await ctx.api.get<{ + success: boolean; + data: UserProfile; + }>('/api/user/profile'); + + expect(profileResult.ok).toBe(true); + + if (profileResult.ok) { + const originalName = profileResult.data.data.name; + + // Update name + const updateResult = await ctx.api.patch<{ + success: boolean; + data: UserProfile; + }>('/api/user/profile', { + name: 'Updated Test Name', + }); + + expect(updateResult.ok).toBe(true); + if (updateResult.ok) { + expect(updateResult.data.data.name).toBe('Updated Test Name'); + } + + // Restore original name + await ctx.api.patch('/api/user/profile', { + name: originalName, + }); + } + }); + + it('should reject profile update without auth', async () => { + const result = await ctx.publicClient.patch('/api/user/profile', { + name: 'Unauthorized Update', + }); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); +}); diff --git a/apps/web/src/test/integration/user/user-tpmjs-keys.integration.test.ts b/apps/web/src/test/integration/user/user-tpmjs-keys.integration.test.ts new file mode 100644 index 0000000..ad76815 --- /dev/null +++ b/apps/web/src/test/integration/user/user-tpmjs-keys.integration.test.ts @@ -0,0 +1,134 @@ +/** + * User TPMJS API keys endpoint integration tests + * + * Tests the TPMJS API key management endpoints. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +interface TpmjsApiKey { + id: string; + name: string; + keyPrefix: string; + scopes: string[]; + isActive: boolean; + lastUsedAt: string | null; + createdAt: string; +} + +describe('User TPMJS API Keys Endpoints', () => { + let ctx: IntegrationTestContext; + let createdKeyId: string | null = null; + + beforeAll(() => { + ctx = getTestContext(); + }); + + afterAll(async () => { + // Clean up created key + if (createdKeyId) { + await ctx.api.delete(`/api/user/tpmjs-api-keys/${createdKeyId}`); + } + await cleanupTestContext(); + }); + + describe('GET /api/user/tpmjs-api-keys', () => { + it('should list user TPMJS API keys with session auth', async () => { + const result = await ctx.api.get<{ + success: boolean; + data: TpmjsApiKey[]; + }>('/api/user/tpmjs-api-keys'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + } + }); + + it('should reject without auth', async () => { + const result = await ctx.publicClient.get('/api/user/tpmjs-api-keys'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('POST /api/user/tpmjs-api-keys', () => { + it('should create a new TPMJS API key', async () => { + const keyName = `Test Key ${Date.now()}`; + const result = await ctx.api.post<{ + success: boolean; + data: TpmjsApiKey & { key?: string }; + }>('/api/user/tpmjs-api-keys', { + name: keyName, + scopes: ['mcp:execute'], + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(result.data.data.name).toBe(keyName); + expect(result.data.data.scopes).toContain('mcp:execute'); + expect(result.data.data.isActive).toBe(true); + + // Store for cleanup + createdKeyId = result.data.data.id; + + // The raw key should only be returned once at creation + expect(result.data.data.key || result.data.data.keyPrefix).toBeDefined(); + } + }); + + it('should reject creation without auth', async () => { + const result = await ctx.publicClient.post('/api/user/tpmjs-api-keys', { + name: 'Unauthorized Key', + scopes: ['mcp:execute'], + }); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('DELETE /api/user/tpmjs-api-keys/:id', () => { + it('should delete a TPMJS API key', async () => { + // Create a key to delete + const createResult = await ctx.api.post<{ + success: boolean; + data: TpmjsApiKey; + }>('/api/user/tpmjs-api-keys', { + name: `Delete Test Key ${Date.now()}`, + scopes: ['mcp:execute'], + }); + + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + const keyId = createResult.data.data.id; + + // Delete the key + const deleteResult = await ctx.api.delete(`/api/user/tpmjs-api-keys/${keyId}`); + expect(deleteResult.ok).toBe(true); + + // Verify it's deleted by listing keys + const listResult = await ctx.api.get<{ + success: boolean; + data: TpmjsApiKey[]; + }>('/api/user/tpmjs-api-keys'); + + if (listResult.ok) { + const deletedKey = listResult.data.data.find((k) => k.id === keyId); + expect(deletedKey).toBeUndefined(); + } + }); + + it('should reject delete without auth', async () => { + const result = await ctx.publicClient.delete('/api/user/tpmjs-api-keys/some-id'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); +}); diff --git a/apps/web/src/test/integration/user/user-usage.integration.test.ts b/apps/web/src/test/integration/user/user-usage.integration.test.ts new file mode 100644 index 0000000..ca70763 --- /dev/null +++ b/apps/web/src/test/integration/user/user-usage.integration.test.ts @@ -0,0 +1,87 @@ +/** + * User usage endpoint integration tests + * + * Tests the user API usage tracking and reporting endpoints. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestContext, getTestContext, type IntegrationTestContext } from '../_helpers/test-context'; + +interface UsageSummary { + periodType: string; + periodStart: string; + totalRequests: number; + successRequests: number; + errorRequests: number; + totalTokensIn: number; + totalTokensOut: number; +} + +describe('User Usage Endpoints', () => { + let ctx: IntegrationTestContext; + + beforeAll(() => { + ctx = getTestContext(); + }); + + afterAll(async () => { + await cleanupTestContext(); + }); + + describe('GET /api/user/usage', () => { + it('should return usage summary with session auth', async () => { + const result = await ctx.api.get<{ + success: boolean; + data: { + today: UsageSummary | null; + thisMonth: UsageSummary | null; + recentActivity: unknown[]; + }; + }>('/api/user/usage'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(result.data.data).toBeDefined(); + } + }); + + it('should reject without auth', async () => { + const result = await ctx.publicClient.get('/api/user/usage'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + }); + }); + + describe('GET /api/user/usage/history', () => { + it('should return usage history', async () => { + const result = await ctx.api.get<{ + success: boolean; + data: UsageSummary[]; + }>('/api/user/usage/history', { + query: { period: 'daily', limit: 7 }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.success).toBe(true); + expect(Array.isArray(result.data.data)).toBe(true); + } + }); + + it('should support period filter', async () => { + const result = await ctx.api.get<{ + success: boolean; + data: UsageSummary[]; + }>('/api/user/usage/history', { + query: { period: 'monthly' }, + }); + + expect(result.ok).toBe(true); + if (result.ok && result.data.data.length > 0) { + expect(result.data.data[0]!.periodType).toBe('monthly'); + } + }); + }); +}); diff --git a/apps/web/vitest.integration.config.ts b/apps/web/vitest.integration.config.ts new file mode 100644 index 0000000..bd2b964 --- /dev/null +++ b/apps/web/vitest.integration.config.ts @@ -0,0 +1,23 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/test/integration/**/*.integration.test.ts'], + setupFiles: ['./src/test/integration/setup-integration.ts'], + testTimeout: 60000, // 60s for API calls to production + // Run tests sequentially to avoid race conditions on shared test data + sequence: { + concurrent: false, + }, + // Fail fast on integration tests + bail: 5, + }, + resolve: { + alias: { + '~': resolve(__dirname, './src'), + }, + }, +});