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
This commit is contained in:
parent
2fb790a847
commit
a39b68e866
27 changed files with 3569 additions and 0 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
253
apps/web/src/test/integration/_helpers/api-client.ts
Normal file
253
apps/web/src/test/integration/_helpers/api-client.ts
Normal file
|
|
@ -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<T = unknown> {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: T;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
ok: false;
|
||||
status: number;
|
||||
error: string;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
export type ApiResult<T> = ApiResponse<T> | 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<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
options: {
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
} = {}
|
||||
): Promise<ApiResult<T>> {
|
||||
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<string, string> = {
|
||||
...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<T = unknown>(
|
||||
path: string,
|
||||
options?: {
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
): Promise<ApiResult<T>> {
|
||||
return request<T>('GET', path, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a POST request
|
||||
*/
|
||||
post<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: {
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
): Promise<ApiResult<T>> {
|
||||
return request<T>('POST', path, { ...options, body });
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a PATCH request
|
||||
*/
|
||||
patch<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: {
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
): Promise<ApiResult<T>> {
|
||||
return request<T>('PATCH', path, { ...options, body });
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a PUT request
|
||||
*/
|
||||
put<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: {
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
): Promise<ApiResult<T>> {
|
||||
return request<T>('PUT', path, { ...options, body });
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a DELETE request
|
||||
*/
|
||||
delete<T = unknown>(
|
||||
path: string,
|
||||
options?: {
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
): Promise<ApiResult<T>> {
|
||||
return request<T>('DELETE', path, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a request to an SSE endpoint and collect events
|
||||
*/
|
||||
async sse(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: {
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
maxEvents?: number;
|
||||
}
|
||||
): Promise<{ ok: boolean; status: number; events: SSEEvent[] }> {
|
||||
const { headers = {}, timeout, maxEvents } = options || {};
|
||||
|
||||
const url = `${normalizedBaseUrl}${path}`;
|
||||
const requestHeaders: Record<string, string> = {
|
||||
...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<string, string>;
|
||||
}
|
||||
): Promise<Response> {
|
||||
const { body, headers = {} } = options || {};
|
||||
|
||||
const url = `${normalizedBaseUrl}${path}`;
|
||||
const requestHeaders: Record<string, string> = {
|
||||
...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<typeof createApiClient>;
|
||||
108
apps/web/src/test/integration/_helpers/auth.ts
Normal file
108
apps/web/src/test/integration/_helpers/auth.ts
Normal file
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
123
apps/web/src/test/integration/_helpers/cleanup-orphans.ts
Normal file
123
apps/web/src/test/integration/_helpers/cleanup-orphans.ts
Normal file
|
|
@ -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();
|
||||
251
apps/web/src/test/integration/_helpers/cleanup.ts
Normal file
251
apps/web/src/test/integration/_helpers/cleanup.ts
Normal file
|
|
@ -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<string> = new Set();
|
||||
private collectionIds: Set<string> = new Set();
|
||||
private conversationIds: Set<string> = new Set();
|
||||
private apiKeyIds: Set<string> = new Set();
|
||||
private tpmjsApiKeyIds: Set<string> = 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<void> {
|
||||
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<void> {
|
||||
// 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-' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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<AgentFactoryResult> {
|
||||
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<AgentFactoryResult[]> {
|
||||
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<AgentFactoryResult | null> {
|
||||
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<CreateAgentOptions>
|
||||
): Promise<AgentFactoryResult> {
|
||||
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<void> {
|
||||
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<typeof createAgentFactory>;
|
||||
|
|
@ -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<CollectionFactoryResult> {
|
||||
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<CollectionFactoryResult[]> {
|
||||
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<CollectionFactoryResult | null> {
|
||||
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<CreateCollectionOptions>
|
||||
): Promise<CollectionFactoryResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<typeof createCollectionFactory>;
|
||||
165
apps/web/src/test/integration/_helpers/setup-test-credentials.ts
Normal file
165
apps/web/src/test/integration/_helpers/setup-test-credentials.ts
Normal file
|
|
@ -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();
|
||||
179
apps/web/src/test/integration/_helpers/sse-parser.ts
Normal file
179
apps/web/src/test/integration/_helpers/sse-parser.ts
Normal file
|
|
@ -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<SSEEvent[]> {
|
||||
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<never>((_, 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<SSEEvent> {
|
||||
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;
|
||||
}
|
||||
164
apps/web/src/test/integration/_helpers/test-context.ts
Normal file
164
apps/web/src/test/integration/_helpers/test-context.ts
Normal file
|
|
@ -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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
178
apps/web/src/test/integration/mcp/mcp-http.integration.test.ts
Normal file
178
apps/web/src/test/integration/mcp/mcp-http.integration.test.ts
Normal file
|
|
@ -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<JsonRpcResponse>(
|
||||
`/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<JsonRpcResponse>(
|
||||
`/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<JsonRpcResponse>(
|
||||
`/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<JsonRpcResponse>(
|
||||
`/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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
apps/web/src/test/integration/setup-integration.ts
Normal file
58
apps/web/src/test/integration/setup-integration.ts
Normal file
|
|
@ -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('');
|
||||
100
apps/web/src/test/integration/stats/stats.integration.test.ts
Normal file
100
apps/web/src/test/integration/stats/stats.integration.test.ts
Normal file
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ToolsListResponse>('/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<ToolsListResponse>('/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<ToolsListResponse>('/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<ToolsListResponse>('/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<ToolsListResponse>('/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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SearchResponse>('/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<SearchResponse>('/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<SearchResponse>('/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<SearchResponse>('/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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
23
apps/web/vitest.integration.config.ts
Normal file
23
apps/web/vitest.integration.config.ts
Normal file
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue