feature/api-development #1

Merged
usulpro merged 47 commits from feature/api-development into main 2025-11-29 23:03:01 +07:00
14 changed files with 2742 additions and 0 deletions
Showing only changes of commit 071736c076 - Show all commits

View File

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

137
tests/api/INSTALLATION.md Normal file
View File

@ -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/` директории.

View File

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

167
tests/api/test-01-basic.ts Normal file
View File

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

220
tests/api/test-02-flows.ts Normal file
View File

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

View File

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

227
tests/api/test-04-live.ts Normal file
View File

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

View File

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

170
tests/api/test-README.md Normal file
View File

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

28
tests/api/test-config.ts Normal file
View File

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

BIN
tests/api/test-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

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

89
tests/api/test-run-all.ts Normal file
View File

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

206
tests/api/test-utils.ts Normal file
View File

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