From 071736c076deb76d86d02c29eb5e9217911164ac Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Sun, 9 Nov 2025 22:27:23 +0700 Subject: [PATCH] feat: add test scripts --- pnpm-lock.yaml | 3 + tests/api/INSTALLATION.md | 137 ++++ tests/api/api-requirements-v2.md | 840 +++++++++++++++++++++++ tests/api/test-01-basic.ts | 167 +++++ tests/api/test-02-flows.ts | 220 ++++++ tests/api/test-03-aliases.ts | 256 +++++++ tests/api/test-04-live.ts | 227 ++++++ tests/api/test-05-edge-cases.ts | 380 ++++++++++ tests/api/test-README.md | 170 +++++ tests/api/test-config.ts | 28 + tests/api/test-image.png | Bin 0 -> 39766 bytes tests/api/test-package-json-snippet.json | 19 + tests/api/test-run-all.ts | 89 +++ tests/api/test-utils.ts | 206 ++++++ 14 files changed, 2742 insertions(+) create mode 100644 tests/api/INSTALLATION.md create mode 100644 tests/api/api-requirements-v2.md create mode 100644 tests/api/test-01-basic.ts create mode 100644 tests/api/test-02-flows.ts create mode 100644 tests/api/test-03-aliases.ts create mode 100644 tests/api/test-04-live.ts create mode 100644 tests/api/test-05-edge-cases.ts create mode 100644 tests/api/test-README.md create mode 100644 tests/api/test-config.ts create mode 100644 tests/api/test-image.png create mode 100644 tests/api/test-package-json-snippet.json create mode 100644 tests/api/test-run-all.ts create mode 100644 tests/api/test-utils.ts 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 0000000000000000000000000000000000000000..dcf7a3ad928a1bdaa526055f37905b708b1b8c81 GIT binary patch literal 39766 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 literal 0 HcmV?d00001 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; + } +}