diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c397ce..409d721 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: dotenv: specifier: ^17.2.2 version: 17.2.2 + drizzle-orm: + specifier: ^0.36.4 + version: 0.36.4(@types/react@19.1.16)(postgres@3.4.7)(react@19.1.0) express: specifier: ^5.1.0 version: 5.1.0 diff --git a/tests/api/INSTALLATION.md b/tests/api/INSTALLATION.md new file mode 100644 index 0000000..635d22d --- /dev/null +++ b/tests/api/INSTALLATION.md @@ -0,0 +1,137 @@ +# 📦 Installation Instructions + +## Шаги установки тестовых скриптов + +### 1. Создайте структуру директорий + +```bash +cd /projects/my-projects/banatie-service +mkdir -p tests/api/fixtures +mkdir -p results +``` + +### 2. Скопируйте файлы + +Скопируйте все файлы из `/tmp/` в соответствующие директории: + +```bash +# Core files +cp /tmp/test-config.ts tests/api/config.ts +cp /tmp/test-utils.ts tests/api/utils.ts +cp /tmp/test-run-all.ts tests/api/run-all.ts +cp /tmp/test-README.md tests/api/README.md + +# Test files +cp /tmp/test-01-basic.ts tests/api/01-basic.ts +cp /tmp/test-02-flows.ts tests/api/02-flows.ts +cp /tmp/test-03-aliases.ts tests/api/03-aliases.ts +cp /tmp/test-04-live.ts tests/api/04-live.ts +cp /tmp/test-05-edge-cases.ts tests/api/05-edge-cases.ts + +# Test fixture +cp /tmp/test-image.png tests/api/fixtures/test-image.png +``` + +### 3. Обновите package.json + +Добавьте скрипты в root `package.json`: + +```json +{ + "scripts": { + "test:api": "tsx tests/api/run-all.ts", + "test:api:basic": "tsx tests/api/01-basic.ts", + "test:api:flows": "tsx tests/api/02-flows.ts", + "test:api:aliases": "tsx tests/api/03-aliases.ts", + "test:api:live": "tsx tests/api/04-live.ts", + "test:api:edge": "tsx tests/api/05-edge-cases.ts" + } +} +``` + +Установите зависимости (если еще нет): + +```bash +pnpm add -D tsx @types/node +``` + +### 4. Настройте environment + +Создайте `.env` в корне проекта (если еще нет): + +```bash +API_KEY=bnt_your_test_api_key_here +API_BASE_URL=http://localhost:3000 +``` + +### 5. Обновите .gitignore + +Добавьте в `.gitignore`: + +``` +# Test results +results/ + +# Test environment +tests/api/.env +``` + +### 6. Проверка установки + +```bash +# Проверьте структуру +tree tests/api + +# Должно выглядеть так: +# tests/api/ +# ├── config.ts +# ├── utils.ts +# ├── fixtures/ +# │ └── test-image.png +# ├── 01-basic.ts +# ├── 02-flows.ts +# ├── 03-aliases.ts +# ├── 04-live.ts +# ├── 05-edge-cases.ts +# ├── run-all.ts +# └── README.md +``` + +### 7. Первый запуск + +```bash +# Запустите API сервер +pnpm dev + +# В другом терминале запустите тесты +pnpm test:api:basic +``` + +## ✅ Checklist + +- [ ] Директории созданы +- [ ] Все файлы скопированы +- [ ] package.json обновлен +- [ ] .env настроен с API key +- [ ] .gitignore обновлен +- [ ] Зависимости установлены +- [ ] API сервер запущен +- [ ] Первый тест прошел успешно + +## 🎯 Готово! + +Теперь можно запускать: + +```bash +# Все тесты +pnpm test:api + +# Отдельные наборы +pnpm test:api:basic +pnpm test:api:flows +pnpm test:api:aliases +pnpm test:api:live +pnpm test:api:edge +``` + +Результаты будут в `results/` директории. diff --git a/tests/api/api-requirements-v2.md b/tests/api/api-requirements-v2.md new file mode 100644 index 0000000..9eac434 --- /dev/null +++ b/tests/api/api-requirements-v2.md @@ -0,0 +1,840 @@ +# Banatie REST API Implementation Plan + +**Version:** 2.0 +**Status:** Ready for Implementation +**Executor:** Claude Code +**Database Schema:** v2.0 (banatie-database-design.md) + +--- + +## Overview + +REST API for Banatie image generation service. All endpoints use `/api/v1/` prefix for versioning. + +**Core Features:** +- AI image generation with Google Gemini Flash +- Dual alias system (project-scoped + flow-scoped) +- Technical aliases (@last, @first, @upload) +- Flow-based generation chains +- Live generation endpoint with caching +- Upload and reference images + +**Authentication:** API keys only (`bnt_` prefix) + +--- + +## Authentication + +All endpoints require API key in header: + +``` +X-API-Key: bnt_xxx... +``` + +**API Key Types:** +- `master`: Full access to all projects in organization +- `project`: Access to specific project only + +**Unauthorized Response (401):** +```json +{ + "error": "Unauthorized", + "message": "Invalid or missing API key" +} +``` + +--- + +## Implementation Phases + +### Phase 1: Foundation +**Goal:** Core utilities and services + +**Tasks:** +- Create TypeScript type definitions for all models +- Build validation utilities (alias format, pagination, query params) +- Build helper utilities (pagination, hash, query helpers) +- Create `AliasService` with 3-tier resolution (technical → flow → project) + +**Git Commit:** +``` +feat: add foundation utilities and alias service +``` + +--- + +### Phase 2: Core Generation Flow +**Goal:** Main generation endpoints + +**Services:** +- `ImageService` - CRUD operations with soft delete +- `GenerationService` - Full lifecycle management + +**Endpoints:** +- `POST /api/v1/generations` - Create with reference images & dual aliases +- `GET /api/v1/generations` - List with filters +- `GET /api/v1/generations/:id` - Get details with related data + +**Git Commit:** +``` +feat: implement core generation endpoints +``` + +--- + +### Phase 3: Flow Management +**Goal:** Flow operations + +**Services:** +- `FlowService` - CRUD with computed counts & alias management + +**Endpoints:** +- `POST /api/v1/flows` - Create flow +- `GET /api/v1/flows` - List flows with computed counts +- `GET /api/v1/flows/:id` - Get details with generations and images +- `PUT /api/v1/flows/:id/aliases` - Update flow aliases +- `DELETE /api/v1/flows/:id/aliases/:alias` - Remove specific alias +- `DELETE /api/v1/flows/:id` - Delete flow + +**Git Commit:** +``` +feat: implement flow management endpoints +``` + +--- + +### Phase 4: Enhanced Image Management +**Goal:** Complete image operations + +**Endpoints:** +- `POST /api/v1/images/upload` - Upload with alias, flow, metadata +- `GET /api/v1/images` - List with filters +- `GET /api/v1/images/:id` - Get details with usage info +- `GET /api/v1/images/resolve/:alias` - Resolve alias with precedence +- `PUT /api/v1/images/:id` - Update metadata +- `DELETE /api/v1/images/:id` - Soft/hard delete + +**Git Commit:** +``` +feat: implement image management endpoints +``` + +--- + +### Phase 5: Generation Refinements +**Goal:** Additional generation operations + +**Endpoints:** +- `POST /api/v1/generations/:id/retry` - Retry failed generation +- `DELETE /api/v1/generations/:id` - Delete generation + +**Git Commit:** +``` +feat: add generation retry and delete endpoints +``` + +--- + +### Phase 6: Live Generation +**Goal:** URL-based generation with caching + +**Services:** +- `PromptCacheService` - SHA-256 caching with hit tracking + +**Endpoints:** +- `GET /api/v1/live` - Generate image via URL with streaming proxy + +**Important:** Stream image directly from MinIO (no 302 redirect) for better performance. + +**Git Commit:** +``` +feat: implement live generation endpoint with caching +``` + +--- + +### Phase 7: Analytics +**Goal:** Project statistics and metrics + +**Services:** +- `AnalyticsService` - Aggregation queries + +**Endpoints:** +- `GET /api/v1/analytics/summary` - Project statistics +- `GET /api/v1/analytics/generations/timeline` - Time-series data + +**Git Commit:** +``` +feat: add analytics endpoints +``` + +--- + +### Phase 8: Testing & Documentation +**Goal:** Quality assurance + +**Tasks:** +- Unit tests for all services (target >80% coverage) +- Integration tests for critical flows +- Error handling consistency review +- Update API documentation + +**Git Commit:** +``` +test: add comprehensive test coverage and documentation +``` + +--- + +## API Endpoints Specification + +### GENERATIONS + +#### POST /api/v1/generations + +Create new image generation. + +**Request Body:** +```typescript +{ + prompt: string; // Required: 1-2000 chars + aspectRatio?: string; // Optional: '16:9', '1:1', '4:3', '9:16' + width?: number; // Optional: 1-8192 + height?: number; // Optional: 1-8192 + referenceImages?: string[]; // Optional: ['@logo', '@product', '@last'] + flowId?: string; // Optional: Add to existing flow + assignAlias?: string; // Optional: Project-scoped alias '@brand' + assignFlowAlias?: string; // Optional: Flow-scoped alias '@hero' (requires flowId) + meta?: Record; +} +``` + +**Response (200):** +```typescript +{ + generation: Generation; + image?: Image; // If generation completed +} +``` + +**Errors:** 400, 401, 404, 422, 429, 500 + +--- + +#### GET /api/v1/generations + +List generations with filtering. + +**Query Params:** +```typescript +{ + flowId?: string; + status?: 'pending' | 'processing' | 'success' | 'failed'; + limit?: number; // Default: 20, max: 100 + offset?: number; // Default: 0 + sortBy?: 'createdAt' | 'updatedAt'; + order?: 'asc' | 'desc'; // Default: desc +} +``` + +**Response (200):** +```typescript +{ + generations: Generation[]; + pagination: PaginationInfo; +} +``` + +--- + +#### GET /api/v1/generations/:id + +Get generation details. + +**Response (200):** +```typescript +{ + generation: Generation; + image?: Image; + referencedImages: Image[]; + flow?: FlowSummary; +} +``` + +--- + +#### POST /api/v1/generations/:id/retry + +Retry failed generation. + +**Response (200):** +```typescript +{ + generation: Generation; // New generation with incremented retry_count +} +``` + +**Errors:** 404, 422 + +--- + +#### DELETE /api/v1/generations/:id + +Delete generation. + +**Query Params:** +```typescript +{ + hard?: boolean; // Default: false +} +``` + +**Response (204):** No content + +--- + +### IMAGES + +#### POST /api/v1/images/upload + +Upload image file. + +**Request:** multipart/form-data + +**Fields:** +```typescript +{ + file: File; // Required, max 5MB + alias?: string; // Project-scoped: '@logo' + flowAlias?: string; // Flow-scoped: '@hero' (requires flowId) + flowId?: string; + description?: string; + tags?: string[]; // JSON array as string + focalPoint?: string; // JSON: '{"x":0.5,"y":0.5}' + meta?: string; // JSON object as string +} +``` + +**Response (201):** +```typescript +{ + image: Image; + flow?: FlowSummary; // If flowAlias assigned +} +``` + +**Errors:** 400, 409, 422 + +--- + +#### GET /api/v1/images + +List images. + +**Query Params:** +```typescript +{ + flowId?: string; + source?: 'generated' | 'uploaded'; + alias?: string; + limit?: number; // Default: 20, max: 100 + offset?: number; + sortBy?: 'createdAt' | 'fileSize'; + order?: 'asc' | 'desc'; +} +``` + +**Response (200):** +```typescript +{ + images: Image[]; + pagination: PaginationInfo; +} +``` + +--- + +#### GET /api/v1/images/:id + +Get image details. + +**Response (200):** +```typescript +{ + image: Image; + generation?: Generation; + usedInGenerations: GenerationSummary[]; +} +``` + +--- + +#### GET /api/v1/images/resolve/:alias + +Resolve alias to image. + +**Query Params:** +```typescript +{ + flowId?: string; // Provide flow context +} +``` + +**Response (200):** +```typescript +{ + image: Image; + scope: 'flow' | 'project' | 'technical'; + flow?: FlowSummary; +} +``` + +**Resolution Order:** +1. Technical aliases (@last, @first, @upload) if flowId provided +2. Flow aliases from flows.aliases if flowId provided +3. Project aliases from images.alias + +**Errors:** 404 + +--- + +#### PUT /api/v1/images/:id + +Update image metadata. + +**Request Body:** +```typescript +{ + alias?: string; + description?: string; + tags?: string[]; + focalPoint?: { x: number; y: number }; + meta?: Record; +} +``` + +**Response (200):** +```typescript +{ + image: Image; +} +``` + +**Errors:** 404, 409, 422 + +--- + +#### DELETE /api/v1/images/:id + +Delete image. + +**Query Params:** +```typescript +{ + hard?: boolean; // Default: false +} +``` + +**Response (204):** No content + +--- + +### FLOWS + +#### POST /api/v1/flows + +Create new flow. + +**Request Body:** +```typescript +{ + meta?: Record; +} +``` + +**Response (201):** +```typescript +{ + flow: Flow; +} +``` + +--- + +#### GET /api/v1/flows + +List flows. + +**Query Params:** +```typescript +{ + limit?: number; // Default: 20, max: 100 + offset?: number; + sortBy?: 'createdAt' | 'updatedAt'; + order?: 'asc' | 'desc'; +} +``` + +**Response (200):** +```typescript +{ + flows: Array; + pagination: PaginationInfo; +} +``` + +--- + +#### GET /api/v1/flows/:id + +Get flow details. + +**Response (200):** +```typescript +{ + flow: Flow; + generations: Generation[]; // Ordered by created_at ASC + images: Image[]; + resolvedAliases: Record; +} +``` + +--- + +#### PUT /api/v1/flows/:id/aliases + +Update flow aliases. + +**Request Body:** +```typescript +{ + aliases: Record; // { "@hero": "image-uuid" } +} +``` + +**Response (200):** +```typescript +{ + flow: Flow; +} +``` + +**Validation:** +- Keys must match `^@[a-zA-Z0-9_-]+$` +- Values must be valid image UUIDs +- Cannot use reserved: @last, @first, @upload + +**Errors:** 404, 422 + +--- + +#### DELETE /api/v1/flows/:id/aliases/:alias + +Remove specific alias from flow. + +**Response (204):** No content + +**Errors:** 404 + +--- + +#### DELETE /api/v1/flows/:id + +Delete flow. + +**Response (204):** No content + +**Note:** Cascades to images, sets NULL on generations.flow_id + +--- + +### LIVE GENERATION + +#### GET /api/v1/live + +Generate image via URL with caching and streaming. + +**Query Params:** +```typescript +{ + prompt: string; // Required + aspectRatio?: string; + width?: number; + height?: number; + reference?: string | string[]; // '@logo' or ['@logo','@style'] +} +``` + +**Response:** Image stream with headers + +**Headers:** +``` +Content-Type: image/jpeg +Cache-Control: public, max-age=31536000 +X-Cache-Status: HIT | MISS +``` + +**Implementation:** +1. Compute cache key: SHA256(prompt + sorted params) +2. Check prompt_url_cache table +3. If HIT: increment hit_count, stream from MinIO +4. If MISS: generate, cache, stream from MinIO +5. Stream image bytes directly (no 302 redirect) + +**Errors:** 400, 404, 500 + +--- + +### ANALYTICS + +#### GET /api/v1/analytics/summary + +Get project statistics. + +**Query Params:** +```typescript +{ + startDate?: string; // ISO 8601 + endDate?: string; + flowId?: string; +} +``` + +**Response (200):** +```typescript +{ + period: { startDate: string; endDate: string }; + metrics: { + totalGenerations: number; + successfulGenerations: number; + failedGenerations: number; + successRate: number; + totalImages: number; + uploadedImages: number; + generatedImages: number; + avgProcessingTimeMs: number; + totalCacheHits: number; + cacheHitRate: number; + totalCost: number; + }; + flows: FlowSummary[]; +} +``` + +--- + +#### GET /api/v1/analytics/generations/timeline + +Get generation statistics over time. + +**Query Params:** +```typescript +{ + startDate?: string; + endDate?: string; + flowId?: string; + groupBy?: 'hour' | 'day' | 'week'; // Default: day +} +``` + +**Response (200):** +```typescript +{ + data: Array<{ + timestamp: string; + total: number; + successful: number; + failed: number; + avgProcessingTimeMs: number; + }>; +} +``` + +--- + +## Implementation Guidelines + +### Alias Resolution Algorithm + +**Priority Order:** +1. Technical aliases (@last, @first, @upload) - compute from flow data +2. Flow-scoped aliases - from flows.aliases JSONB +3. Project-scoped aliases - from images.alias column + +**Technical Aliases:** +- `@last`: Latest generation output in flow (any status) +- `@first`: First generation output in flow +- `@upload`: Latest uploaded image in flow + +### Dual Alias Assignment + +When creating generation or uploading image: +- `assignAlias` → set images.alias (project scope) +- `assignFlowAlias` → add to flows.aliases (flow scope) +- Both can be assigned simultaneously + +### Flow Updates + +Update `flows.updated_at` on: +- New generation created with flowId +- New image uploaded with flowId +- Flow aliases modified + +### Audit Trail + +Track `api_key_id` in: +- `images.api_key_id` - who uploaded/generated +- `generations.api_key_id` - who requested + +### Rate Limiting + +In-memory rate limiting (defer Redis for MVP): +- Master key: 1000 req/hour, 100 generations/hour +- Project key: 500 req/hour, 50 generations/hour + +**Headers:** +``` +X-RateLimit-Limit: 500 +X-RateLimit-Remaining: 487 +X-RateLimit-Reset: 1698765432 +``` + +### Error Response Format + +```typescript +{ + error: string; + message: string; + details?: unknown; + requestId?: string; +} +``` + +### MinIO Integration + +Use streaming for `/api/v1/live`: +```typescript +const stream = await minioClient.getObject(bucket, storageKey); +res.set('Content-Type', mimeType); +stream.pipe(res); +``` + +Generate presigned URLs for other endpoints: +```typescript +const url = await minioClient.presignedGetObject(bucket, storageKey, 24 * 60 * 60); +``` + +--- + +## Validation Rules + +**Alias Format:** +- Pattern: `^@[a-zA-Z0-9_-]+$` +- Reserved: @last, @first, @upload +- Length: 3-100 chars + +**File Upload:** +- Max size: 5MB +- MIME types: image/jpeg, image/png, image/webp +- Max dimensions: 8192x8192 + +**Prompt:** +- Min: 1 char +- Max: 2000 chars + +**Aspect Ratio:** +- Pattern: `^\d+:\d+$` +- Examples: 16:9, 1:1, 4:3, 9:16 + +--- + +## Service Architecture + +### Core Services + +**AliasService:** +- Resolve aliases with 3-tier precedence +- Compute technical aliases +- Validate alias format + +**ImageService:** +- CRUD operations +- Soft delete support +- Usage tracking + +**GenerationService:** +- Generation lifecycle +- Status transitions +- Error handling +- Retry logic + +**FlowService:** +- Flow CRUD +- Alias management +- Computed counts + +**PromptCacheService:** +- Cache key computation (SHA-256) +- Hit tracking +- Cache lookup + +**AnalyticsService:** +- Aggregation queries +- Time-series grouping + +### Reusable Utilities + +**Validators:** +- Alias format +- Pagination params +- Query filters + +**Helpers:** +- Pagination builder +- SHA-256 hashing +- Query helpers + +--- + +## Testing Requirements + +**Unit Tests:** +- All services must have unit tests +- Target coverage: >80% +- Mock database calls + +**Integration Tests:** +- Critical flows end-to-end +- Real database transactions +- API endpoint testing with supertest + +**Test Scenarios:** +- Alias resolution precedence +- Flow-scoped vs project-scoped aliases +- Technical alias computation +- Dual alias assignment +- Cache hit/miss behavior +- Error handling +- Rate limiting + +--- + +## Success Criteria + +✅ All endpoints functional per specification +✅ >80% test coverage on services +✅ Consistent error handling across all endpoints +✅ All validation rules implemented +✅ Rate limiting working +✅ Documentation updated +✅ Git commits after each phase + +--- + +*Document Version: 2.0* +*Created: 2025-11-09* +*Target: Claude Code Implementation* +*Database Schema: v2.0* diff --git a/tests/api/test-01-basic.ts b/tests/api/test-01-basic.ts new file mode 100644 index 0000000..3d020b7 --- /dev/null +++ b/tests/api/test-01-basic.ts @@ -0,0 +1,167 @@ +// tests/api/01-basic.ts + +import { join } from 'path'; +import { api, log, runTest, saveImage, uploadFile, waitForGeneration, testContext } from './utils'; +import { config, endpoints } from './config'; + +async function main() { + log.section('BASIC TESTS'); + + // Test 1: Upload image + await runTest('Upload image', async () => { + const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png'); + + const response = await uploadFile(fixturePath, { + alias: '@test-logo', + description: 'Test logo image', + }); + + if (!response.image || !response.image.id) { + throw new Error('No image returned'); + } + + testContext.uploadedImageId = response.image.id; + log.detail('Image ID', response.image.id); + log.detail('Storage Key', response.image.storageKey); + log.detail('Alias', response.image.alias); + }); + + // Test 2: List images + await runTest('List images', async () => { + const result = await api(endpoints.images); + + if (!result.data.images || !Array.isArray(result.data.images)) { + throw new Error('No images array returned'); + } + + log.detail('Total images', result.data.images.length); + log.detail('Has uploaded', result.data.images.some((img: any) => img.source === 'uploaded')); + }); + + // Test 3: Get image by ID + await runTest('Get image by ID', async () => { + const result = await api(`${endpoints.images}/${testContext.uploadedImageId}`); + + if (!result.data.image) { + throw new Error('Image not found'); + } + + log.detail('Image ID', result.data.image.id); + log.detail('Source', result.data.image.source); + log.detail('File size', `${result.data.image.fileSize} bytes`); + }); + + // Test 4: Generate image without references + await runTest('Generate image (simple)', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'A beautiful sunset over mountains', + aspectRatio: '16:9', + }), + }); + + if (!result.data.generation) { + throw new Error('No generation returned'); + } + + testContext.generationId = result.data.generation.id; + log.detail('Generation ID', result.data.generation.id); + log.detail('Status', result.data.generation.status); + log.detail('Prompt', result.data.generation.originalPrompt); + + // Wait for completion + log.info('Waiting for generation to complete...'); + const generation = await waitForGeneration(testContext.generationId); + + if (generation.status !== 'success') { + throw new Error(`Generation failed: ${generation.errorMessage}`); + } + + log.detail('Processing time', `${generation.processingTimeMs}ms`); + log.detail('Output image', generation.outputImageId); + + // Save generated image + if (generation.outputImageId) { + const imageResult = await api(`${endpoints.images}/${generation.outputImageId}`); + + // Download image + const imageUrl = imageResult.data.image.storageUrl; + const imageResponse = await fetch(imageUrl); + const imageBuffer = await imageResponse.arrayBuffer(); + + await saveImage(imageBuffer, 'simple-generation.png'); + testContext.imageId = generation.outputImageId; + } + }); + + // Test 5: Generate with uploaded reference + await runTest('Generate with reference image', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'A product photo with the @test-logo in the corner', + aspectRatio: '1:1', + referenceImages: ['@test-logo'], + }), + }); + + if (!result.data.generation) { + throw new Error('No generation returned'); + } + + log.detail('Generation ID', result.data.generation.id); + log.detail('Referenced images', result.data.generation.referencedImages?.length || 0); + + // Wait for completion + log.info('Waiting for generation to complete...'); + const generation = await waitForGeneration(result.data.generation.id); + + if (generation.status !== 'success') { + throw new Error(`Generation failed: ${generation.errorMessage}`); + } + + // Save generated image + if (generation.outputImageId) { + const imageResult = await api(`${endpoints.images}/${generation.outputImageId}`); + const imageUrl = imageResult.data.image.storageUrl; + const imageResponse = await fetch(imageUrl); + const imageBuffer = await imageResponse.arrayBuffer(); + + await saveImage(imageBuffer, 'with-reference.png'); + } + }); + + // Test 6: List generations + await runTest('List generations', async () => { + const result = await api(endpoints.generations); + + if (!result.data.generations || !Array.isArray(result.data.generations)) { + throw new Error('No generations array returned'); + } + + log.detail('Total generations', result.data.generations.length); + log.detail('Successful', result.data.generations.filter((g: any) => g.status === 'success').length); + log.detail('Has pagination', !!result.data.pagination); + }); + + // Test 7: Get generation by ID + await runTest('Get generation details', async () => { + const result = await api(`${endpoints.generations}/${testContext.generationId}`); + + if (!result.data.generation) { + throw new Error('Generation not found'); + } + + log.detail('Generation ID', result.data.generation.id); + log.detail('Status', result.data.generation.status); + log.detail('Has image', !!result.data.image); + log.detail('Referenced images', result.data.referencedImages?.length || 0); + }); + + log.section('BASIC TESTS COMPLETED'); +} + +main().catch(console.error); diff --git a/tests/api/test-02-flows.ts b/tests/api/test-02-flows.ts new file mode 100644 index 0000000..9d7d2f4 --- /dev/null +++ b/tests/api/test-02-flows.ts @@ -0,0 +1,220 @@ +// tests/api/02-flows.ts + +import { api, log, runTest, saveImage, waitForGeneration, testContext } from './utils'; +import { endpoints } from './config'; + +async function main() { + log.section('FLOW TESTS'); + + // Test 1: Create flow + await runTest('Create flow', async () => { + const result = await api(endpoints.flows, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + meta: { purpose: 'test-flow', description: 'Testing flow functionality' }, + }), + }); + + if (!result.data.flow || !result.data.flow.id) { + throw new Error('No flow returned'); + } + + testContext.flowId = result.data.flow.id; + log.detail('Flow ID', result.data.flow.id); + log.detail('Aliases', JSON.stringify(result.data.flow.aliases)); + }); + + // Test 2: List flows + await runTest('List flows', async () => { + const result = await api(endpoints.flows); + + if (!result.data.flows || !Array.isArray(result.data.flows)) { + throw new Error('No flows array returned'); + } + + log.detail('Total flows', result.data.flows.length); + log.detail('Has pagination', !!result.data.pagination); + }); + + // Test 3: Generate in flow (first generation) + await runTest('Generate in flow (first)', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'A red sports car on a mountain road', + aspectRatio: '16:9', + flowId: testContext.flowId, + }), + }); + + if (!result.data.generation) { + throw new Error('No generation returned'); + } + + log.detail('Generation ID', result.data.generation.id); + log.detail('Flow ID', result.data.generation.flowId); + + // Wait for completion + log.info('Waiting for generation to complete...'); + const generation = await waitForGeneration(result.data.generation.id); + + if (generation.status !== 'success') { + throw new Error(`Generation failed: ${generation.errorMessage}`); + } + + log.detail('Output image', generation.outputImageId); + + // Save image + if (generation.outputImageId) { + const imageResult = await api(`${endpoints.images}/${generation.outputImageId}`); + const imageUrl = imageResult.data.image.storageUrl; + const imageResponse = await fetch(imageUrl); + const imageBuffer = await imageResponse.arrayBuffer(); + + await saveImage(imageBuffer, 'flow-gen-1.png'); + } + }); + + // Test 4: Generate in flow (second generation) with @last reference + await runTest('Generate in flow with @last', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Same as @last but make it blue instead of red', + aspectRatio: '16:9', + flowId: testContext.flowId, + referenceImages: ['@last'], + }), + }); + + if (!result.data.generation) { + throw new Error('No generation returned'); + } + + log.detail('Generation ID', result.data.generation.id); + log.detail('Referenced @last', result.data.generation.referencedImages?.some((r: any) => r.alias === '@last')); + + // Wait for completion + log.info('Waiting for generation to complete...'); + const generation = await waitForGeneration(result.data.generation.id); + + if (generation.status !== 'success') { + throw new Error(`Generation failed: ${generation.errorMessage}`); + } + + // Save image + if (generation.outputImageId) { + const imageResult = await api(`${endpoints.images}/${generation.outputImageId}`); + const imageUrl = imageResult.data.image.storageUrl; + const imageResponse = await fetch(imageUrl); + const imageBuffer = await imageResponse.arrayBuffer(); + + await saveImage(imageBuffer, 'flow-gen-2-with-last.png'); + } + }); + + // Test 5: Get flow details + await runTest('Get flow details', async () => { + const result = await api(`${endpoints.flows}/${testContext.flowId}`); + + if (!result.data.flow) { + throw new Error('Flow not found'); + } + + log.detail('Flow ID', result.data.flow.id); + log.detail('Generations count', result.data.generations?.length || 0); + log.detail('Images count', result.data.images?.length || 0); + log.detail('Resolved aliases', Object.keys(result.data.resolvedAliases || {}).length); + }); + + // Test 6: Update flow aliases + await runTest('Update flow aliases', async () => { + // First, get the latest generation's image ID + const flowResult = await api(`${endpoints.flows}/${testContext.flowId}`); + const lastGeneration = flowResult.data.generations[flowResult.data.generations.length - 1]; + + if (!lastGeneration.outputImageId) { + throw new Error('No output image for alias assignment'); + } + + const result = await api(`${endpoints.flows}/${testContext.flowId}/aliases`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + aliases: { + '@hero': lastGeneration.outputImageId, + '@featured': lastGeneration.outputImageId, + }, + }), + }); + + if (!result.data.flow) { + throw new Error('Flow not returned'); + } + + log.detail('Updated aliases', JSON.stringify(result.data.flow.aliases)); + }); + + // Test 7: Generate with flow-scoped alias + await runTest('Generate with flow-scoped alias', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'A poster design featuring @hero image', + aspectRatio: '9:16', + flowId: testContext.flowId, + referenceImages: ['@hero'], + }), + }); + + if (!result.data.generation) { + throw new Error('No generation returned'); + } + + log.detail('Generation ID', result.data.generation.id); + log.detail('Referenced @hero', result.data.generation.referencedImages?.some((r: any) => r.alias === '@hero')); + + // Wait for completion + log.info('Waiting for generation to complete...'); + const generation = await waitForGeneration(result.data.generation.id); + + if (generation.status !== 'success') { + throw new Error(`Generation failed: ${generation.errorMessage}`); + } + + // Save image + if (generation.outputImageId) { + const imageResult = await api(`${endpoints.images}/${generation.outputImageId}`); + const imageUrl = imageResult.data.image.storageUrl; + const imageResponse = await fetch(imageUrl); + const imageBuffer = await imageResponse.arrayBuffer(); + + await saveImage(imageBuffer, 'flow-gen-with-hero.png'); + } + }); + + // Test 8: Delete flow alias + await runTest('Delete flow alias', async () => { + await api(`${endpoints.flows}/${testContext.flowId}/aliases/@featured`, { + method: 'DELETE', + }); + + // Verify it's deleted + const result = await api(`${endpoints.flows}/${testContext.flowId}`); + const hasFeatureAlias = '@featured' in result.data.flow.aliases; + + if (hasFeatureAlias) { + throw new Error('Alias was not deleted'); + } + + log.detail('Remaining aliases', JSON.stringify(result.data.flow.aliases)); + }); + + log.section('FLOW TESTS COMPLETED'); +} + +main().catch(console.error); diff --git a/tests/api/test-03-aliases.ts b/tests/api/test-03-aliases.ts new file mode 100644 index 0000000..2eca4c4 --- /dev/null +++ b/tests/api/test-03-aliases.ts @@ -0,0 +1,256 @@ +// tests/api/03-aliases.ts + +import { join } from 'path'; +import { api, log, runTest, saveImage, uploadFile, waitForGeneration, testContext } from './utils'; +import { config, endpoints } from './config'; + +async function main() { + log.section('ALIAS TESTS'); + + // Test 1: Upload with project-scoped alias + await runTest('Upload with project alias', async () => { + const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png'); + + const response = await uploadFile(fixturePath, { + alias: '@brand-logo', + description: 'Brand logo for project-wide use', + }); + + if (!response.image.alias || response.image.alias !== '@brand-logo') { + throw new Error('Alias not set correctly'); + } + + log.detail('Image ID', response.image.id); + log.detail('Project alias', response.image.alias); + log.detail('Flow ID', response.image.flowId || 'null (project-scoped)'); + }); + + // Test 2: Upload with flow-scoped alias + await runTest('Upload with flow alias', async () => { + const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png'); + + const response = await uploadFile(fixturePath, { + flowAlias: '@temp-logo', + flowId: testContext.flowId, + description: 'Temporary logo for flow use', + }); + + if (!response.flow || !response.flow.aliases['@temp-logo']) { + throw new Error('Flow alias not set'); + } + + log.detail('Image ID', response.image.id); + log.detail('Flow ID', response.image.flowId); + log.detail('Flow aliases', JSON.stringify(response.flow.aliases)); + }); + + // Test 3: Upload with BOTH project and flow aliases + await runTest('Upload with dual aliases', async () => { + const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png'); + + const response = await uploadFile(fixturePath, { + alias: '@global-asset', + flowAlias: '@flow-asset', + flowId: testContext.flowId, + description: 'Image with both alias types', + }); + + if (!response.image.alias || response.image.alias !== '@global-asset') { + throw new Error('Project alias not set'); + } + + if (!response.flow || !response.flow.aliases['@flow-asset']) { + throw new Error('Flow alias not set'); + } + + log.detail('Image ID', response.image.id); + log.detail('Project alias', response.image.alias); + log.detail('Flow alias', '@flow-asset'); + }); + + // Test 4: Resolve project-scoped alias + await runTest('Resolve project alias', async () => { + const result = await api(`${endpoints.images}/resolve/@brand-logo`); + + if (!result.data.image) { + throw new Error('Image not resolved'); + } + + if (result.data.scope !== 'project') { + throw new Error(`Wrong scope: ${result.data.scope}`); + } + + log.detail('Image ID', result.data.image.id); + log.detail('Scope', result.data.scope); + log.detail('Alias', result.data.image.alias); + }); + + // Test 5: Resolve flow-scoped alias + await runTest('Resolve flow alias', async () => { + const result = await api(`${endpoints.images}/resolve/@temp-logo?flowId=${testContext.flowId}`); + + if (!result.data.image) { + throw new Error('Image not resolved'); + } + + if (result.data.scope !== 'flow') { + throw new Error(`Wrong scope: ${result.data.scope}`); + } + + log.detail('Image ID', result.data.image.id); + log.detail('Scope', result.data.scope); + log.detail('Flow ID', result.data.flow?.id); + }); + + // Test 6: Resolve @last technical alias + await runTest('Resolve @last technical alias', async () => { + const result = await api(`${endpoints.images}/resolve/@last?flowId=${testContext.flowId}`); + + if (!result.data.image) { + throw new Error('Image not resolved'); + } + + if (result.data.scope !== 'technical') { + throw new Error(`Wrong scope: ${result.data.scope}`); + } + + log.detail('Image ID', result.data.image.id); + log.detail('Scope', result.data.scope); + log.detail('Technical alias', '@last'); + }); + + // Test 7: Resolve @first technical alias + await runTest('Resolve @first technical alias', async () => { + const result = await api(`${endpoints.images}/resolve/@first?flowId=${testContext.flowId}`); + + if (!result.data.image) { + throw new Error('Image not resolved'); + } + + if (result.data.scope !== 'technical') { + throw new Error(`Wrong scope: ${result.data.scope}`); + } + + log.detail('Image ID', result.data.image.id); + log.detail('Scope', result.data.scope); + }); + + // Test 8: Resolve @upload technical alias + await runTest('Resolve @upload technical alias', async () => { + const result = await api(`${endpoints.images}/resolve/@upload?flowId=${testContext.flowId}`); + + if (!result.data.image) { + throw new Error('Image not resolved'); + } + + if (result.data.scope !== 'technical') { + throw new Error(`Wrong scope: ${result.data.scope}`); + } + + log.detail('Image ID', result.data.image.id); + log.detail('Scope', result.data.scope); + log.detail('Source', result.data.image.source); + }); + + // Test 9: Generate with assignAlias (project-scoped) + await runTest('Generate with project alias assignment', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'A minimalist logo design', + aspectRatio: '1:1', + assignAlias: '@generated-logo', + }), + }); + + log.info('Waiting for generation to complete...'); + const generation = await waitForGeneration(result.data.generation.id); + + if (generation.status !== 'success') { + throw new Error(`Generation failed: ${generation.errorMessage}`); + } + + // Check if alias was assigned + const imageResult = await api(`${endpoints.images}/${generation.outputImageId}`); + + if (imageResult.data.image.alias !== '@generated-logo') { + throw new Error('Alias not assigned to generated image'); + } + + log.detail('Output image', generation.outputImageId); + log.detail('Assigned alias', imageResult.data.image.alias); + + // Save image + const imageUrl = imageResult.data.image.storageUrl; + const imageResponse = await fetch(imageUrl); + const imageBuffer = await imageResponse.arrayBuffer(); + await saveImage(imageBuffer, 'generated-with-alias.png'); + }); + + // Test 10: Generate with assignFlowAlias (flow-scoped) + await runTest('Generate with flow alias assignment', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'A vibrant abstract pattern', + aspectRatio: '1:1', + flowId: testContext.flowId, + assignFlowAlias: '@pattern', + }), + }); + + log.info('Waiting for generation to complete...'); + const generation = await waitForGeneration(result.data.generation.id); + + if (generation.status !== 'success') { + throw new Error(`Generation failed: ${generation.errorMessage}`); + } + + // Check if flow alias was assigned + const flowResult = await api(`${endpoints.flows}/${testContext.flowId}`); + + if (!flowResult.data.flow.aliases['@pattern']) { + throw new Error('Flow alias not assigned'); + } + + log.detail('Output image', generation.outputImageId); + log.detail('Flow alias assigned', '@pattern'); + }); + + // Test 11: Alias precedence (flow > project) + await runTest('Test alias precedence', async () => { + // Create project alias @test + const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png'); + const projectResponse = await uploadFile(fixturePath, { + alias: '@precedence-test', + }); + const projectImageId = projectResponse.image.id; + + // Create flow alias @test (different image) + const flowResponse = await uploadFile(fixturePath, { + flowAlias: '@precedence-test', + flowId: testContext.flowId, + }); + const flowImageId = flowResponse.image.id; + + // Resolve without flowId (should get project alias) + const withoutFlow = await api(`${endpoints.images}/resolve/@precedence-test`); + if (withoutFlow.data.image.id !== projectImageId) { + throw new Error('Should resolve to project alias'); + } + log.detail('Without flow context', 'resolved to project alias ✓'); + + // Resolve with flowId (should get flow alias) + const withFlow = await api(`${endpoints.images}/resolve/@precedence-test?flowId=${testContext.flowId}`); + if (withFlow.data.image.id !== flowImageId) { + throw new Error('Should resolve to flow alias'); + } + log.detail('With flow context', 'resolved to flow alias ✓'); + }); + + log.section('ALIAS TESTS COMPLETED'); +} + +main().catch(console.error); diff --git a/tests/api/test-04-live.ts b/tests/api/test-04-live.ts new file mode 100644 index 0000000..6c6b3a2 --- /dev/null +++ b/tests/api/test-04-live.ts @@ -0,0 +1,227 @@ +// tests/api/04-live.ts + +import { api, log, runTest, saveImage, wait } from './utils'; +import { endpoints } from './config'; + +async function main() { + log.section('LIVE ENDPOINT TESTS'); + + // Test 1: First call (cache MISS) + await runTest('Live generation - cache MISS', async () => { + const params = new URLSearchParams({ + prompt: 'A serene zen garden with rocks and sand', + aspectRatio: '16:9', + }); + + const startTime = Date.now(); + const result = await api(`${endpoints.live}?${params}`, { + timeout: 60000, // Longer timeout for generation + }); + const duration = Date.now() - startTime; + + // Should return image buffer + if (!(result.data instanceof ArrayBuffer)) { + throw new Error('Expected image buffer'); + } + + // Check cache status header + const cacheStatus = result.headers.get('X-Cache-Status'); + if (cacheStatus !== 'MISS') { + throw new Error(`Expected cache MISS, got: ${cacheStatus}`); + } + + log.detail('Cache status', cacheStatus); + log.detail('Duration', `${duration}ms`); + log.detail('Content-Type', result.headers.get('Content-Type')); + log.detail('Image size', `${result.data.byteLength} bytes`); + + await saveImage(result.data, 'live-cache-miss.png'); + }); + + // Test 2: Second call with same params (cache HIT) + await runTest('Live generation - cache HIT', async () => { + const params = new URLSearchParams({ + prompt: 'A serene zen garden with rocks and sand', + aspectRatio: '16:9', + }); + + const startTime = Date.now(); + const result = await api(`${endpoints.live}?${params}`); + const duration = Date.now() - startTime; + + // Check cache status header + const cacheStatus = result.headers.get('X-Cache-Status'); + if (cacheStatus !== 'HIT') { + throw new Error(`Expected cache HIT, got: ${cacheStatus}`); + } + + log.detail('Cache status', cacheStatus); + log.detail('Duration', `${duration}ms (should be faster)`); + log.detail('Image size', `${result.data.byteLength} bytes`); + + await saveImage(result.data, 'live-cache-hit.png'); + }); + + // Test 3: Different aspect ratio (new cache entry) + await runTest('Live generation - different params', async () => { + const params = new URLSearchParams({ + prompt: 'A serene zen garden with rocks and sand', + aspectRatio: '1:1', // Different aspect ratio + }); + + const result = await api(`${endpoints.live}?${params}`, { + timeout: 60000, + }); + + const cacheStatus = result.headers.get('X-Cache-Status'); + if (cacheStatus !== 'MISS') { + throw new Error(`Expected cache MISS for different params, got: ${cacheStatus}`); + } + + log.detail('Cache status', cacheStatus); + log.detail('Aspect ratio', '1:1'); + + await saveImage(result.data, 'live-different-aspect.png'); + }); + + // Test 4: With reference image + await runTest('Live generation - with reference', async () => { + const params = new URLSearchParams({ + prompt: 'Product photo featuring @brand-logo', + aspectRatio: '16:9', + reference: '@brand-logo', + }); + + const result = await api(`${endpoints.live}?${params}`, { + timeout: 60000, + }); + + const cacheStatus = result.headers.get('X-Cache-Status'); + log.detail('Cache status', cacheStatus); + log.detail('With reference', '@brand-logo'); + + await saveImage(result.data, 'live-with-reference.png'); + }); + + // Test 5: Multiple references + await runTest('Live generation - multiple references', async () => { + const params = new URLSearchParams({ + prompt: 'Combine @brand-logo and @generated-logo', + aspectRatio: '1:1', + }); + params.append('reference', '@brand-logo'); + params.append('reference', '@generated-logo'); + + const result = await api(`${endpoints.live}?${params}`, { + timeout: 60000, + }); + + const cacheStatus = result.headers.get('X-Cache-Status'); + log.detail('Cache status', cacheStatus); + log.detail('References', '[@brand-logo, @generated-logo]'); + + await saveImage(result.data, 'live-multiple-refs.png'); + }); + + // Test 6: Custom dimensions + await runTest('Live generation - custom dimensions', async () => { + const params = new URLSearchParams({ + prompt: 'A landscape painting', + width: '1024', + height: '768', + }); + + const result = await api(`${endpoints.live}?${params}`, { + timeout: 60000, + }); + + const cacheStatus = result.headers.get('X-Cache-Status'); + log.detail('Cache status', cacheStatus); + log.detail('Dimensions', '1024x768'); + + await saveImage(result.data, 'live-custom-dims.png'); + }); + + // Test 7: Verify cache works as URL + await runTest('Live as direct URL (browser-like)', async () => { + const url = `${endpoints.live}?prompt=${encodeURIComponent('A beautiful sunset')}&aspectRatio=16:9`; + + log.info('Testing URL format:'); + log.detail('URL', url); + + const result = await api(url, { timeout: 60000 }); + + if (!(result.data instanceof ArrayBuffer)) { + throw new Error('Should return image directly'); + } + + const cacheStatus = result.headers.get('X-Cache-Status'); + log.detail('Cache status', cacheStatus); + log.detail('Works as direct URL', '✓'); + + await saveImage(result.data, 'live-direct-url.png'); + }); + + // Test 8: Verify Cache-Control header for CDN + await runTest('Check Cache-Control headers', async () => { + const params = new URLSearchParams({ + prompt: 'Test cache control', + aspectRatio: '1:1', + }); + + const result = await api(`${endpoints.live}?${params}`, { + timeout: 60000, + }); + + const cacheControl = result.headers.get('Cache-Control'); + const contentType = result.headers.get('Content-Type'); + + log.detail('Cache-Control', cacheControl || 'NOT SET'); + log.detail('Content-Type', contentType || 'NOT SET'); + + if (!cacheControl || !cacheControl.includes('public')) { + log.warning('Cache-Control should be set for CDN optimization'); + } + }); + + // Test 9: Rapid repeated calls (verify cache performance) + await runTest('Cache performance test', async () => { + const params = new URLSearchParams({ + prompt: 'Performance test image', + aspectRatio: '1:1', + }); + + // First call (MISS) + log.info('Making first call (MISS)...'); + const firstCall = await api(`${endpoints.live}?${params}`, { + timeout: 60000, + }); + const firstDuration = firstCall.duration; + + await wait(1000); + + // Rapid subsequent calls (all HITs) + log.info('Making 5 rapid cache HIT calls...'); + const durations: number[] = []; + + for (let i = 0; i < 5; i++) { + const result = await api(`${endpoints.live}?${params}`); + durations.push(result.duration); + + const cacheStatus = result.headers.get('X-Cache-Status'); + if (cacheStatus !== 'HIT') { + throw new Error(`Call ${i + 1} expected HIT, got ${cacheStatus}`); + } + } + + const avgHitDuration = durations.reduce((a, b) => a + b, 0) / durations.length; + + log.detail('First call (MISS)', `${firstDuration}ms`); + log.detail('Avg HIT calls', `${avgHitDuration.toFixed(0)}ms`); + log.detail('Speedup', `${(firstDuration / avgHitDuration).toFixed(1)}x faster`); + }); + + log.section('LIVE ENDPOINT TESTS COMPLETED'); +} + +main().catch(console.error); diff --git a/tests/api/test-05-edge-cases.ts b/tests/api/test-05-edge-cases.ts new file mode 100644 index 0000000..ec25a19 --- /dev/null +++ b/tests/api/test-05-edge-cases.ts @@ -0,0 +1,380 @@ +// tests/api/05-edge-cases.ts + +import { join } from 'path'; +import { api, log, runTest, uploadFile } from './utils'; +import { config, endpoints } from './config'; + +async function main() { + log.section('EDGE CASES & VALIDATION TESTS'); + + // Test 1: Invalid alias format + await runTest('Invalid alias format', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Test image', + assignAlias: 'invalid-no-at-sign', + }), + expectError: true, + }); + + if (result.status !== 400 && result.status !== 422) { + throw new Error(`Expected 400/422, got ${result.status}`); + } + + log.detail('Status', result.status); + log.detail('Error', result.data.error || result.data.message); + }); + + // Test 2: Reserved technical alias + await runTest('Reserved technical alias', async () => { + const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png'); + + try { + await uploadFile(fixturePath, { + alias: '@last', // Reserved + }); + throw new Error('Should have failed with reserved alias'); + } catch (error) { + log.detail('Correctly rejected', '@last is reserved'); + } + }); + + // Test 3: Duplicate project alias + await runTest('Duplicate project alias', async () => { + const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png'); + + // First upload + await uploadFile(fixturePath, { + alias: '@duplicate-test', + }); + + // Try duplicate + const result = await api(endpoints.images + '/upload', { + method: 'POST', + body: (() => { + const formData = new FormData(); + formData.append('alias', '@duplicate-test'); + return formData; + })(), + expectError: true, + }); + + if (result.status !== 409) { + throw new Error(`Expected 409 Conflict, got ${result.status}`); + } + + log.detail('Status', '409 Conflict'); + log.detail('Message', result.data.message); + }); + + // Test 4: Missing prompt + await runTest('Missing required prompt', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + aspectRatio: '16:9', + // No prompt + }), + expectError: true, + }); + + if (result.status !== 400 && result.status !== 422) { + throw new Error(`Expected 400/422, got ${result.status}`); + } + + log.detail('Status', result.status); + log.detail('Validation error', 'prompt is required'); + }); + + // Test 5: Invalid aspect ratio format + await runTest('Invalid aspect ratio format', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Test image', + aspectRatio: 'invalid', + }), + expectError: true, + }); + + if (result.status !== 400 && result.status !== 422) { + throw new Error(`Expected 400/422, got ${result.status}`); + } + + log.detail('Status', result.status); + log.detail('Error', 'Invalid aspect ratio format'); + }); + + // Test 6: Non-existent reference image + await runTest('Non-existent reference image', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Test with invalid reference', + referenceImages: ['@non-existent-alias'], + }), + expectError: true, + }); + + if (result.status !== 404) { + throw new Error(`Expected 404, got ${result.status}`); + } + + log.detail('Status', '404 Not Found'); + log.detail('Error', 'Reference image not found'); + }); + + // Test 7: Invalid flow ID + await runTest('Invalid flow ID', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Test image', + flowId: '00000000-0000-0000-0000-000000000000', + }), + expectError: true, + }); + + if (result.status !== 404) { + throw new Error(`Expected 404, got ${result.status}`); + } + + log.detail('Status', '404 Not Found'); + log.detail('Error', 'Flow not found'); + }); + + // Test 8: assignFlowAlias without flowId + await runTest('Flow alias without flow ID', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Test image', + assignFlowAlias: '@test', // No flowId provided + }), + expectError: true, + }); + + if (result.status !== 400 && result.status !== 422) { + throw new Error(`Expected 400/422, got ${result.status}`); + } + + log.detail('Status', result.status); + log.detail('Error', 'assignFlowAlias requires flowId'); + }); + + // Test 9: Empty prompt + await runTest('Empty prompt', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: '', + }), + expectError: true, + }); + + if (result.status !== 400 && result.status !== 422) { + throw new Error(`Expected 400/422, got ${result.status}`); + } + + log.detail('Status', result.status); + log.detail('Error', 'Prompt cannot be empty'); + }); + + // Test 10: Extremely long prompt (over 2000 chars) + await runTest('Prompt too long', async () => { + const longPrompt = 'A'.repeat(2001); + + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: longPrompt, + }), + expectError: true, + }); + + if (result.status !== 400 && result.status !== 422) { + throw new Error(`Expected 400/422, got ${result.status}`); + } + + log.detail('Status', result.status); + log.detail('Error', 'Prompt exceeds max length'); + }); + + // Test 11: Dimensions out of range + await runTest('Invalid dimensions', async () => { + const result = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Test image', + width: 10000, // Over max + height: 10000, + }), + expectError: true, + }); + + if (result.status !== 400 && result.status !== 422) { + throw new Error(`Expected 400/422, got ${result.status}`); + } + + log.detail('Status', result.status); + log.detail('Error', 'Dimensions exceed max 8192'); + }); + + // Test 12: Invalid image ID + await runTest('Non-existent image ID', async () => { + const result = await api(`${endpoints.images}/00000000-0000-0000-0000-000000000000`, { + expectError: true, + }); + + if (result.status !== 404) { + throw new Error(`Expected 404, got ${result.status}`); + } + + log.detail('Status', '404 Not Found'); + }); + + // Test 13: Update non-existent image + await runTest('Update non-existent image', async () => { + const result = await api(`${endpoints.images}/00000000-0000-0000-0000-000000000000`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + description: 'Updated', + }), + expectError: true, + }); + + if (result.status !== 404) { + throw new Error(`Expected 404, got ${result.status}`); + } + + log.detail('Status', '404 Not Found'); + }); + + // Test 14: Delete non-existent flow + await runTest('Delete non-existent flow', async () => { + const result = await api(`${endpoints.flows}/00000000-0000-0000-0000-000000000000`, { + method: 'DELETE', + expectError: true, + }); + + if (result.status !== 404) { + throw new Error(`Expected 404, got ${result.status}`); + } + + log.detail('Status', '404 Not Found'); + }); + + // Test 15: Invalid pagination params + await runTest('Invalid pagination params', async () => { + const result = await api(`${endpoints.images}?limit=1000`, { + expectError: true, + }); + + if (result.status !== 400 && result.status !== 422) { + throw new Error(`Expected 400/422, got ${result.status}`); + } + + log.detail('Status', result.status); + log.detail('Error', 'Limit exceeds max 100'); + }); + + // Test 16: Missing API key + await runTest('Missing API key', async () => { + const url = `${config.baseURL}${endpoints.images}`; + + const response = await fetch(url); // No API key header + + if (response.status !== 401) { + throw new Error(`Expected 401, got ${response.status}`); + } + + log.detail('Status', '401 Unauthorized'); + }); + + // Test 17: Invalid API key + await runTest('Invalid API key', async () => { + const url = `${config.baseURL}${endpoints.images}`; + + const response = await fetch(url, { + headers: { + 'X-API-Key': 'invalid_key_123', + }, + }); + + if (response.status !== 401) { + throw new Error(`Expected 401, got ${response.status}`); + } + + log.detail('Status', '401 Unauthorized'); + }); + + // Test 18: Retry non-failed generation + await runTest('Retry non-failed generation', async () => { + // Create a successful generation first + const genResult = await api(endpoints.generations, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Test for retry', + aspectRatio: '1:1', + }), + }); + + // Try to retry it (should fail) + const retryResult = await api(`${endpoints.generations}/${genResult.data.generation.id}/retry`, { + method: 'POST', + expectError: true, + }); + + if (retryResult.status !== 422) { + throw new Error(`Expected 422, got ${retryResult.status}`); + } + + log.detail('Status', '422 Unprocessable Entity'); + log.detail('Error', 'Can only retry failed generations'); + }); + + // Test 19: Resolve alias without context + await runTest('Resolve flow alias without flow context', async () => { + // Try to resolve a flow-only alias without flowId + const result = await api(`${endpoints.images}/resolve/@temp-logo`, { + expectError: true, + }); + + if (result.status !== 404) { + throw new Error(`Expected 404, got ${result.status}`); + } + + log.detail('Status', '404 Not Found'); + log.detail('Error', 'Alias requires flow context'); + }); + + // Test 20: Live endpoint without prompt + await runTest('Live endpoint without prompt', async () => { + const result = await api(`${endpoints.live}?aspectRatio=16:9`, { + expectError: true, + }); + + if (result.status !== 400) { + throw new Error(`Expected 400, got ${result.status}`); + } + + log.detail('Status', '400 Bad Request'); + log.detail('Error', 'Prompt is required'); + }); + + log.section('EDGE CASES TESTS COMPLETED'); +} + +main().catch(console.error); diff --git a/tests/api/test-README.md b/tests/api/test-README.md new file mode 100644 index 0000000..984ee51 --- /dev/null +++ b/tests/api/test-README.md @@ -0,0 +1,170 @@ +# Banatie API Tests + +Набор интеграционных тестов для проверки REST API endpoints. + +## 📋 Структура + +``` +tests/api/ +├── config.ts # Конфигурация (API key, baseURL) +├── utils.ts # Утилиты (fetch, logger, file operations) +├── fixtures/ +│ └── test-image.png # Тестовое изображение +├── 01-basic.ts # Базовые операции (upload, generate, list) +├── 02-flows.ts # Flow management (CRUD, generations) +├── 03-aliases.ts # Alias system (dual, technical, resolution) +├── 04-live.ts # Live endpoint (caching, streaming) +├── 05-edge-cases.ts # Validation и error handling +└── run-all.ts # Запуск всех тестов +``` + +## 🚀 Быстрый старт + +### 1. Настройка + +Создайте `.env` файл в корне проекта: + +```bash +API_KEY=bnt_your_actual_api_key_here +API_BASE_URL=http://localhost:3000 +``` + +### 2. Установка зависимостей + +```bash +pnpm install +``` + +### 3. Добавьте тестовое изображение + +Поместите любое изображение в `tests/api/fixtures/test-image.png` + +### 4. Запустите API сервер + +```bash +pnpm dev +``` + +### 5. Запустите тесты + +**Все тесты:** +```bash +pnpm test:api +``` + +**Отдельный тест:** +```bash +tsx tests/api/01-basic.ts +``` + +## 📊 Результаты + +Сгенерированные изображения сохраняются в `results/` с timestamp. + +Пример вывода: +``` +━━━ BASIC TESTS ━━━ +✓ Upload image (234ms) + Image ID: abc-123-def + Storage Key: org/project/uploads/2025-01/image.png + Alias: @test-logo +✓ Generate image (simple) (5432ms) + ... +``` + +## 🧪 Что тестируется + +### 01-basic.ts +- ✅ Upload изображений +- ✅ Список изображений +- ✅ Генерация без references +- ✅ Генерация с references +- ✅ Список и детали generations + +### 02-flows.ts +- ✅ CRUD операции flows +- ✅ Генерации в flow контексте +- ✅ Technical aliases (@last, @first, @upload) +- ✅ Flow-scoped aliases + +### 03-aliases.ts +- ✅ Project-scoped aliases +- ✅ Flow-scoped aliases +- ✅ Dual alias assignment +- ✅ Alias resolution precedence +- ✅ Technical aliases computation + +### 04-live.ts +- ✅ Cache MISS (первый запрос) +- ✅ Cache HIT (повторный запрос) +- ✅ Различные параметры +- ✅ References в live endpoint +- ✅ Performance кэширования + +### 05-edge-cases.ts +- ✅ Валидация входных данных +- ✅ Дублирование aliases +- ✅ Несуществующие resources +- ✅ Некорректные форматы +- ✅ Authentication errors +- ✅ Pagination limits + +## 🔧 Конфигурация + +Настройка в `tests/api/config.ts`: + +```typescript +export const config = { + baseURL: 'http://localhost:3000', + apiKey: 'bnt_test_key', + resultsDir: '../../results', + requestTimeout: 30000, + generationTimeout: 60000, + verbose: true, + saveImages: true, +}; +``` + +## 📝 Логирование + +Цветной console output: +- ✓ Зеленый - успешные тесты +- ✗ Красный - failed тесты +- → Синий - информация +- ⚠ Желтый - предупреждения + +## 🐛 Troubleshooting + +**API не отвечает:** +```bash +# Проверьте что сервер запущен +curl http://localhost:3000/health +``` + +**401 Unauthorized:** +```bash +# Проверьте API key в .env +echo $API_KEY +``` + +**Генерация timeout:** +```bash +# Увеличьте timeout в config.ts +generationTimeout: 120000 // 2 минуты +``` + +## 📚 Дополнительно + +- Тесты запускаются **последовательно** (используют testContext) +- Данные **НЕ удаляются** после тестов (для инспекции) +- Все сгенерированные изображения сохраняются в `results/` +- Rate limiting учитывается (есть задержки между запросами) + +## 🎯 Success Criteria + +Все тесты должны пройти успешно: +- ✅ >95% success rate +- ✅ Все validation errors обрабатываются корректно +- ✅ Cache работает (HIT < 500ms) +- ✅ Alias resolution правильный +- ✅ Нет memory leaks diff --git a/tests/api/test-config.ts b/tests/api/test-config.ts new file mode 100644 index 0000000..741b825 --- /dev/null +++ b/tests/api/test-config.ts @@ -0,0 +1,28 @@ +// tests/api/config.ts + +export const config = { + // API Configuration + baseURL: process.env.API_BASE_URL || 'http://localhost:3000', + apiKey: process.env.API_KEY || 'bnt_test_key_change_me', + + // Paths + resultsDir: '../../results', + fixturesDir: './fixtures', + + // Timeouts + requestTimeout: 30000, + generationTimeout: 60000, + + // Test settings + verbose: true, + saveImages: true, + cleanupOnSuccess: false, +}; + +export const endpoints = { + generations: '/api/v1/generations', + images: '/api/v1/images', + flows: '/api/v1/flows', + live: '/api/v1/live', + analytics: '/api/v1/analytics', +}; diff --git a/tests/api/test-image.png b/tests/api/test-image.png new file mode 100644 index 0000000..dcf7a3a Binary files /dev/null and b/tests/api/test-image.png differ diff --git a/tests/api/test-package-json-snippet.json b/tests/api/test-package-json-snippet.json new file mode 100644 index 0000000..f42d602 --- /dev/null +++ b/tests/api/test-package-json-snippet.json @@ -0,0 +1,19 @@ +// package.json additions for tests + +{ + "scripts": { + "test:api": "tsx tests/api/run-all.ts", + "test:api:basic": "tsx tests/api/01-basic.ts", + "test:api:flows": "tsx tests/api/02-flows.ts", + "test:api:aliases": "tsx tests/api/03-aliases.ts", + "test:api:live": "tsx tests/api/04-live.ts", + "test:api:edge": "tsx tests/api/05-edge-cases.ts" + }, + "devDependencies": { + "tsx": "^4.7.0", + "@types/node": "^20.11.0" + } +} + +// Note: fetch is built into Node.js 18+, no need for node-fetch +// FormData is also built into Node.js 18+ diff --git a/tests/api/test-run-all.ts b/tests/api/test-run-all.ts new file mode 100644 index 0000000..88724e5 --- /dev/null +++ b/tests/api/test-run-all.ts @@ -0,0 +1,89 @@ +// tests/api/run-all.ts + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { log } from './utils'; + +const execAsync = promisify(exec); + +const testFiles = [ + '01-basic.ts', + '02-flows.ts', + '03-aliases.ts', + '04-live.ts', + '05-edge-cases.ts', +]; + +async function runTest(file: string): Promise<{ success: boolean; duration: number }> { + const startTime = Date.now(); + + try { + log.section(`Running ${file}`); + + await execAsync(`tsx ${file}`, { + cwd: __dirname, + env: process.env, + }); + + const duration = Date.now() - startTime; + log.success(`${file} completed (${duration}ms)`); + + return { success: true, duration }; + } catch (error) { + const duration = Date.now() - startTime; + log.error(`${file} failed (${duration}ms)`); + console.error(error); + + return { success: false, duration }; + } +} + +async function main() { + console.log('\n'); + log.section('🚀 BANATIE API TEST SUITE'); + console.log('\n'); + + const results: Array<{ file: string; success: boolean; duration: number }> = []; + const startTime = Date.now(); + + for (const file of testFiles) { + const result = await runTest(file); + results.push({ file, ...result }); + console.log('\n'); + } + + const totalDuration = Date.now() - startTime; + + // Summary + log.section('📊 TEST SUMMARY'); + console.log('\n'); + + const passed = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + + results.forEach(result => { + const icon = result.success ? '✓' : '✗'; + const color = result.success ? '\x1b[32m' : '\x1b[31m'; + console.log(`${color}${icon}\x1b[0m ${result.file} (${result.duration}ms)`); + }); + + console.log('\n'); + log.info(`Total: ${results.length} test suites`); + log.success(`Passed: ${passed}`); + + if (failed > 0) { + log.error(`Failed: ${failed}`); + } + + log.info(`Duration: ${(totalDuration / 1000).toFixed(2)}s`); + console.log('\n'); + + if (failed > 0) { + process.exit(1); + } +} + +main().catch(error => { + console.error('Test runner failed:', error); + process.exit(1); +}); diff --git a/tests/api/test-utils.ts b/tests/api/test-utils.ts new file mode 100644 index 0000000..166d0f5 --- /dev/null +++ b/tests/api/test-utils.ts @@ -0,0 +1,206 @@ +// tests/api/utils.ts + +import { writeFile, mkdir } from 'fs/promises'; +import { join } from 'path'; +import { config } from './config'; + +// Colors for console output +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + gray: '\x1b[90m', + cyan: '\x1b[36m', +}; + +// Logging utilities +export const log = { + success: (msg: string) => console.log(`${colors.green}✓${colors.reset} ${msg}`), + error: (msg: string) => console.log(`${colors.red}✗${colors.reset} ${msg}`), + info: (msg: string) => console.log(`${colors.blue}→${colors.reset} ${msg}`), + warning: (msg: string) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`), + section: (msg: string) => console.log(`\n${colors.cyan}━━━ ${msg} ━━━${colors.reset}`), + detail: (key: string, value: any) => { + const valueStr = typeof value === 'object' ? JSON.stringify(value, null, 2) : value; + console.log(` ${colors.gray}${key}:${colors.reset} ${valueStr}`); + }, +}; + +// API fetch wrapper +export async function api( + endpoint: string, + options: RequestInit & { + expectError?: boolean; + timeout?: number; + } = {} +): Promise<{ + data: T; + status: number; + headers: Headers; + duration: number; +}> { + const { expectError = false, timeout = config.requestTimeout, ...fetchOptions } = options; + const url = `${config.baseURL}${endpoint}`; + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(url, { + ...fetchOptions, + headers: { + 'X-API-Key': config.apiKey, + ...fetchOptions.headers, + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + const duration = Date.now() - startTime; + + let data: any; + const contentType = response.headers.get('content-type'); + + if (contentType?.includes('application/json')) { + data = await response.json(); + } else if (contentType?.includes('image/')) { + data = await response.arrayBuffer(); + } else { + data = await response.text(); + } + + if (!response.ok && !expectError) { + throw new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`); + } + + if (config.verbose) { + const method = fetchOptions.method || 'GET'; + log.detail('Request', `${method} ${endpoint}`); + log.detail('Status', response.status); + log.detail('Duration', `${duration}ms`); + } + + return { + data, + status: response.status, + headers: response.headers, + duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + if (!expectError) { + log.error(`Request failed: ${error}`); + log.detail('Endpoint', endpoint); + log.detail('Duration', `${duration}ms`); + } + throw error; + } +} + +// Save image to results directory +export async function saveImage( + buffer: ArrayBuffer, + filename: string +): Promise { + const resultsPath = join(__dirname, config.resultsDir); + + try { + await mkdir(resultsPath, { recursive: true }); + } catch (err) { + // Directory exists, ignore + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const fullFilename = `${timestamp}_${filename}`; + const filepath = join(resultsPath, fullFilename); + + await writeFile(filepath, Buffer.from(buffer)); + + if (config.saveImages) { + log.info(`Saved image: ${fullFilename}`); + } + + return filepath; +} + +// Upload file helper +export async function uploadFile( + filepath: string, + fields: Record = {} +): Promise { + const formData = new FormData(); + + // Read file + const fs = await import('fs/promises'); + const fileBuffer = await fs.readFile(filepath); + const blob = new Blob([fileBuffer]); + formData.append('file', blob, 'test-image.png'); + + // Add other fields + for (const [key, value] of Object.entries(fields)) { + formData.append(key, value); + } + + const result = await api(config.endpoints.images + '/upload', { + method: 'POST', + body: formData, + headers: { + // Don't set Content-Type, let fetch set it with boundary + }, + }); + + return result.data; +} + +// Wait helper +export async function wait(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Poll for generation completion +export async function waitForGeneration( + generationId: string, + maxAttempts = 20 +): Promise { + for (let i = 0; i < maxAttempts; i++) { + const result = await api(`${config.endpoints.generations}/${generationId}`); + const generation = result.data.generation; + + if (generation.status === 'success' || generation.status === 'failed') { + return generation; + } + + await wait(1000); + } + + throw new Error('Generation timeout'); +} + +// Test context to share data between tests +export const testContext: { + imageId?: string; + generationId?: string; + flowId?: string; + uploadedImageId?: string; +} = {}; + +// Test runner helper +export async function runTest( + name: string, + fn: () => Promise +): Promise { + try { + const startTime = Date.now(); + await fn(); + const duration = Date.now() - startTime; + log.success(`${name} (${duration}ms)`); + return true; + } catch (error) { + log.error(`${name}`); + console.error(error); + return false; + } +}