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:
Ajax Davis 2026-01-14 00:50:55 +10:00
parent 2fb790a847
commit a39b68e866
27 changed files with 3569 additions and 0 deletions

View file

@ -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": {

View 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>;

View 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(),
};
}

View 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();

View 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-' },
},
});
}

View file

@ -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>;

View file

@ -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>;

View 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();

View 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;
}

View 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,
};
}

View file

@ -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();
}
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View 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();
}
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View 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('');

View 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');
}
}
});
});
});

View file

@ -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);
});
});
});

View file

@ -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();
}
});
});
});

View file

@ -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();
}
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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');
}
});
});
});

View 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'),
},
},
});