feature/api-development #1
|
|
@ -96,6 +96,9 @@ importers:
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.2.2
|
specifier: ^17.2.2
|
||||||
version: 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:
|
express:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0
|
version: 5.1.0
|
||||||
|
|
|
||||||
|
|
@ -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/` директории.
|
||||||
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<Flow & {
|
||||||
|
generationCount: number; // Computed
|
||||||
|
imageCount: number; // Computed
|
||||||
|
}>;
|
||||||
|
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<string, Image>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/v1/flows/:id/aliases
|
||||||
|
|
||||||
|
Update flow aliases.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
aliases: Record<string, string>; // { "@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*
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
|
|
@ -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+
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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<T = any>(
|
||||||
|
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<string> {
|
||||||
|
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<string, string> = {}
|
||||||
|
): Promise<any> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for generation completion
|
||||||
|
export async function waitForGeneration(
|
||||||
|
generationId: string,
|
||||||
|
maxAttempts = 20
|
||||||
|
): Promise<any> {
|
||||||
|
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<void>
|
||||||
|
): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue