Compare commits
No commits in common. "1e080bd87c3d38c2a2977d382eb8cfaaf8f07067" and "7da1973072d61a061767dd26c9b891df6296ab4c" have entirely different histories.
1e080bd87c
...
7da1973072
|
|
@ -84,34 +84,27 @@ Project is in active development with no existing clients. All changes can be ma
|
|||
1. **Rename field:** `enhancedPrompt` → `originalPrompt`
|
||||
2. **Change field semantics:**
|
||||
- `prompt` - ALWAYS contains the prompt that was used for generation (enhanced or original)
|
||||
- `originalPrompt` - ALWAYS contains user's original input (for transparency and audit trail)
|
||||
- `originalPrompt` - contains user's original input ONLY if autoEnhance was used (nullable)
|
||||
|
||||
**Field population logic:**
|
||||
|
||||
```
|
||||
Case 1: autoEnhance = false
|
||||
prompt = user input
|
||||
originalPrompt = user input (same value, preserved for consistency)
|
||||
originalPrompt = NULL
|
||||
|
||||
Case 2: autoEnhance = true
|
||||
prompt = enhanced prompt (used for generation)
|
||||
originalPrompt = user input (preserved)
|
||||
```
|
||||
|
||||
**Rationale:** Always storing `originalPrompt` provides:
|
||||
- Audit trail of user's actual input
|
||||
- Ability to compare original vs enhanced prompts
|
||||
- Consistent API response structure
|
||||
- Simplified client logic (no null checks needed)
|
||||
|
||||
### 2.2 API Response Format
|
||||
|
||||
**Response structure:**
|
||||
```json
|
||||
{
|
||||
"prompt": "detailed enhanced prompt...", // Always the prompt used for generation
|
||||
"originalPrompt": "sunset", // Always the user's original input
|
||||
"autoEnhance": true // True if prompt differs from originalPrompt
|
||||
"originalPrompt": "sunset" // Only present if enhancement was used
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -472,28 +472,28 @@ flowsRouter.delete(
|
|||
);
|
||||
|
||||
/**
|
||||
* Regenerate the most recent generation in a flow (Section 3.6)
|
||||
* Regenerate the most recent generation in a flow
|
||||
*
|
||||
* Logic:
|
||||
* 1. Find the flow by ID
|
||||
* 2. Query for the most recent generation (ordered by createdAt desc)
|
||||
* 3. Trigger regeneration with exact same parameters
|
||||
* 4. Replace existing output image (preserves ID and URLs)
|
||||
* Identifies the latest generation in the flow and regenerates it:
|
||||
* - Uses exact same parameters (prompt, aspect ratio, references)
|
||||
* - Replaces existing output image (preserves ID and URLs)
|
||||
* - Returns error if flow has no generations
|
||||
* - Ordered by creation date (newest first)
|
||||
*
|
||||
* @route POST /api/v1/flows/:id/regenerate
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {string} req.params.id - Flow ID (affects: determines which flow's latest generation to regenerate)
|
||||
* @param {string} req.params.id - Flow ID (UUID)
|
||||
*
|
||||
* @returns {object} 200 - Regenerated generation with updated output image
|
||||
* @returns {object} 200 - Regenerated generation response
|
||||
* @returns {object} 404 - Flow not found or access denied
|
||||
* @returns {object} 400 - Flow has no generations
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
*
|
||||
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist
|
||||
* @throws {Error} FLOW_HAS_NO_GENERATIONS - Flow contains no generations to regenerate
|
||||
* @throws {Error} FLOW_HAS_NO_GENERATIONS - Flow contains no generations
|
||||
*
|
||||
* @example
|
||||
* POST /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/regenerate
|
||||
|
|
@ -558,16 +558,15 @@ flowsRouter.post(
|
|||
);
|
||||
|
||||
/**
|
||||
* Delete a flow with cascade deletion (Section 7.3)
|
||||
* Delete a flow (hard delete)
|
||||
*
|
||||
* Permanently removes the flow with cascade behavior:
|
||||
* - Flow record is hard deleted
|
||||
* - All generations in flow are hard deleted
|
||||
* - Images WITHOUT project alias: hard deleted with MinIO cleanup
|
||||
* - Images WITH project alias: kept, but flowId set to NULL (unlinked)
|
||||
* Permanently removes the flow record:
|
||||
* - Flow record is hard deleted (no soft delete)
|
||||
* - Generations remain intact (not cascaded)
|
||||
* - Images remain intact (not cascaded)
|
||||
* - Flow-scoped aliases are removed with flow
|
||||
*
|
||||
* Rationale: Images with project aliases are used globally and should be preserved.
|
||||
* Flow deletion removes the organizational structure but protects important assets.
|
||||
* Note: Generations and images lose their flow association but remain accessible.
|
||||
*
|
||||
* @route DELETE /api/v1/flows/:id
|
||||
* @authentication Project Key required
|
||||
|
|
|
|||
|
|
@ -478,15 +478,13 @@ generationsRouter.post(
|
|||
);
|
||||
|
||||
/**
|
||||
* Delete a generation and conditionally its output image (Section 7.2)
|
||||
* Delete a generation and its output image
|
||||
*
|
||||
* Performs deletion with alias protection:
|
||||
* - Hard delete generation record (permanently removed from database)
|
||||
* - If output image has NO project alias: hard delete image with MinIO cleanup
|
||||
* - If output image HAS project alias: keep image, set generationId=NULL
|
||||
*
|
||||
* Rationale: Images with aliases are used as standalone assets and should be preserved.
|
||||
* Images without aliases were created only for this generation and can be deleted together.
|
||||
* - Soft delete generation record (sets deletedAt timestamp)
|
||||
* - Hard delete output image if no project/flow aliases exist
|
||||
* - Soft delete output image if aliases exist (preserves for CDN access)
|
||||
* - Cascades to remove generation-image relationships
|
||||
*
|
||||
* @route DELETE /api/v1/generations/:id
|
||||
* @authentication Project Key required
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import sizeOf from 'image-size';
|
||||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { ImageService, AliasService } from '@/services/core';
|
||||
|
|
@ -13,7 +12,7 @@ import { validateAndNormalizePagination } from '@/utils/validators';
|
|||
import { buildPaginatedResponse } from '@/utils/helpers';
|
||||
import { toImageResponse } from '@/types/responses';
|
||||
import { db } from '@/db';
|
||||
import { flows, type Image } from '@banatie/database';
|
||||
import { flows } from '@banatie/database';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type {
|
||||
UploadImageResponse,
|
||||
|
|
@ -43,56 +42,20 @@ const getAliasService = (): AliasService => {
|
|||
return aliasService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve id_or_alias parameter to imageId
|
||||
* Supports both UUID and alias (@-prefixed) identifiers
|
||||
* Per Section 6.2 of api-refactoring-final.md
|
||||
*
|
||||
* @param identifier - UUID or alias string
|
||||
* @param projectId - Project ID for alias resolution
|
||||
* @param flowId - Optional flow ID for flow-scoped alias resolution
|
||||
* @returns imageId (UUID)
|
||||
* @throws Error if alias not found
|
||||
*/
|
||||
async function resolveImageIdentifier(
|
||||
identifier: string,
|
||||
projectId: string,
|
||||
flowId?: string
|
||||
): Promise<string> {
|
||||
// Check if parameter is alias (starts with @)
|
||||
if (identifier.startsWith('@')) {
|
||||
const aliasServiceInstance = getAliasService();
|
||||
const resolution = await aliasServiceInstance.resolve(
|
||||
identifier,
|
||||
projectId,
|
||||
flowId
|
||||
);
|
||||
|
||||
if (!resolution) {
|
||||
throw new Error(`Alias '${identifier}' not found`);
|
||||
}
|
||||
|
||||
return resolution.imageId;
|
||||
}
|
||||
|
||||
// Otherwise treat as UUID
|
||||
return identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single image file to project storage
|
||||
*
|
||||
* Uploads an image file to MinIO storage and creates a database record with support for:
|
||||
* - Lazy flow creation using pendingFlowId when flowId is undefined
|
||||
* - Automatic flow creation when flowId is undefined (lazy creation)
|
||||
* - Eager flow creation when flowAlias is provided
|
||||
* - Project-scoped alias assignment
|
||||
* - Custom metadata storage
|
||||
* - Multiple file formats (JPEG, PNG, WebP, etc.)
|
||||
*
|
||||
* FlowId behavior:
|
||||
* - undefined (not provided) → generates pendingFlowId, defers flow creation (lazy)
|
||||
* - undefined (not provided) → generates new UUID for automatic flow creation
|
||||
* - null (explicitly null) → no flow association
|
||||
* - string (specific value) → uses provided flow ID, creates if needed
|
||||
* - string (specific value) → uses provided flow ID
|
||||
*
|
||||
* @route POST /api/v1/images/upload
|
||||
* @authentication Project Key required
|
||||
|
|
@ -155,42 +118,17 @@ imagesRouter.post(
|
|||
const projectSlug = req.apiKey.projectSlug;
|
||||
const file = req.file;
|
||||
|
||||
// FlowId logic (matching GenerationService lazy pattern):
|
||||
// - If undefined → generate UUID for pendingFlowId, flowId = null (lazy)
|
||||
// - If null → flowId = null, pendingFlowId = null (explicitly no flow)
|
||||
// - If string → flowId = string, pendingFlowId = null (use provided, create if needed)
|
||||
// FlowId logic (Section 10.1 & 5.1):
|
||||
// - If undefined (not provided) → generate new UUID
|
||||
// - If null (explicitly null) → keep null
|
||||
// - If string (specific value) → use that value
|
||||
let finalFlowId: string | null;
|
||||
let pendingFlowId: string | null = null;
|
||||
|
||||
if (flowId === undefined) {
|
||||
// Lazy pattern: defer flow creation until needed
|
||||
pendingFlowId = randomUUID();
|
||||
finalFlowId = null;
|
||||
finalFlowId = randomUUID();
|
||||
} else if (flowId === null) {
|
||||
// Explicitly no flow
|
||||
finalFlowId = null;
|
||||
pendingFlowId = null;
|
||||
} else {
|
||||
// Specific flowId provided - ensure flow exists (eager creation)
|
||||
finalFlowId = flowId;
|
||||
pendingFlowId = null;
|
||||
|
||||
// Check if flow exists, create if not
|
||||
const existingFlow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, finalFlowId),
|
||||
});
|
||||
|
||||
if (!existingFlow) {
|
||||
await db.insert(flows).values({
|
||||
id: finalFlowId,
|
||||
projectId,
|
||||
aliases: {},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
// Link any pending images to this new flow
|
||||
await service.linkPendingImagesToFlow(finalFlowId, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -217,23 +155,9 @@ imagesRouter.post(
|
|||
return;
|
||||
}
|
||||
|
||||
// Extract image dimensions from uploaded file buffer
|
||||
let width: number | null = null;
|
||||
let height: number | null = null;
|
||||
try {
|
||||
const dimensions = sizeOf(file.buffer);
|
||||
if (dimensions.width && dimensions.height) {
|
||||
width = dimensions.width;
|
||||
height = dimensions.height;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to extract image dimensions:', error);
|
||||
}
|
||||
|
||||
const imageRecord = await service.create({
|
||||
projectId,
|
||||
flowId: finalFlowId,
|
||||
pendingFlowId: pendingFlowId,
|
||||
generationId: null,
|
||||
apiKeyId,
|
||||
storageKey: uploadResult.path!,
|
||||
|
|
@ -242,48 +166,29 @@ imagesRouter.post(
|
|||
fileSize: file.size,
|
||||
fileHash: null,
|
||||
source: 'uploaded',
|
||||
alias: null,
|
||||
alias: alias || null,
|
||||
meta: meta ? JSON.parse(meta) : {},
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
// Reassign project alias if provided (override behavior per Section 5.2)
|
||||
if (alias) {
|
||||
await service.reassignProjectAlias(alias, imageRecord.id, projectId);
|
||||
}
|
||||
|
||||
// Eager flow creation if flowAlias is provided
|
||||
if (flowAlias) {
|
||||
// Use pendingFlowId if available, otherwise finalFlowId
|
||||
const flowIdToUse = pendingFlowId || finalFlowId;
|
||||
|
||||
if (!flowIdToUse) {
|
||||
throw new Error('Cannot create flow: no flowId available');
|
||||
}
|
||||
|
||||
// Eager flow creation if flowAlias is provided (Section 5.1)
|
||||
if (flowAlias && finalFlowId) {
|
||||
// Check if flow exists, create if not
|
||||
const existingFlow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, flowIdToUse),
|
||||
where: eq(flows.id, finalFlowId),
|
||||
});
|
||||
|
||||
if (!existingFlow) {
|
||||
await db.insert(flows).values({
|
||||
id: flowIdToUse,
|
||||
id: finalFlowId,
|
||||
projectId,
|
||||
aliases: {},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
// Link pending images if this was a lazy flow
|
||||
if (pendingFlowId) {
|
||||
await service.linkPendingImagesToFlow(flowIdToUse, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign flow alias to uploaded image
|
||||
const flow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, flowIdToUse),
|
||||
where: eq(flows.id, finalFlowId),
|
||||
});
|
||||
|
||||
if (flow) {
|
||||
|
|
@ -294,16 +199,13 @@ imagesRouter.post(
|
|||
await db
|
||||
.update(flows)
|
||||
.set({ aliases: updatedAliases, updatedAt: new Date() })
|
||||
.where(eq(flows.id, flowIdToUse));
|
||||
.where(eq(flows.id, finalFlowId));
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch image to include any updates (alias assignment, flow alias)
|
||||
const finalImage = await service.getById(imageRecord.id);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: toImageResponse(finalImage!),
|
||||
data: toImageResponse(imageRecord),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
|
|
@ -388,21 +290,8 @@ imagesRouter.get(
|
|||
);
|
||||
|
||||
/**
|
||||
* @deprecated Use GET /api/v1/images/:alias directly instead (Section 6.2)
|
||||
*
|
||||
* Resolve an alias to an image using 3-tier precedence system
|
||||
*
|
||||
* **DEPRECATED**: This endpoint is deprecated as of Section 6.2. Use the main
|
||||
* GET /api/v1/images/:id_or_alias endpoint instead, which supports both UUIDs
|
||||
* and aliases (@-prefixed) directly in the path parameter.
|
||||
*
|
||||
* **Migration Guide**:
|
||||
* - Old: GET /api/v1/images/resolve/@hero
|
||||
* - New: GET /api/v1/images/@hero
|
||||
*
|
||||
* This endpoint remains functional for backwards compatibility but will be
|
||||
* removed in a future version.
|
||||
*
|
||||
* Resolves aliases through a priority-based lookup system:
|
||||
* 1. Technical aliases (@last, @first, @upload) - computed on-the-fly
|
||||
* 2. Flow-scoped aliases - looked up in flow's JSONB aliases field (requires flowId)
|
||||
|
|
@ -416,7 +305,7 @@ imagesRouter.get(
|
|||
* @param {string} req.params.alias - Alias to resolve (e.g., "@last", "@hero", "@step-1")
|
||||
* @param {string} [req.query.flowId] - Flow context for flow-scoped resolution
|
||||
*
|
||||
* @returns {ResolveAliasResponse} 200 - Resolved image with scope and details (includes X-Deprecated header)
|
||||
* @returns {ResolveAliasResponse} 200 - Resolved image with scope and details
|
||||
* @returns {object} 404 - Alias not found in any scope
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
|
|
@ -446,12 +335,6 @@ imagesRouter.get(
|
|||
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
// Add deprecation header
|
||||
res.setHeader(
|
||||
'X-Deprecated',
|
||||
'This endpoint is deprecated. Use GET /api/v1/images/:alias instead (Section 6.2)'
|
||||
);
|
||||
|
||||
try {
|
||||
const resolution = await aliasServiceInstance.resolve(
|
||||
alias,
|
||||
|
|
@ -516,11 +399,10 @@ imagesRouter.get(
|
|||
* - File metadata (size, MIME type, hash)
|
||||
* - Focal point and custom metadata
|
||||
*
|
||||
* @route GET /api/v1/images/:id_or_alias
|
||||
* @route GET /api/v1/images/:id
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@alias)
|
||||
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||
* @param {string} req.params.id - Image ID (UUID)
|
||||
*
|
||||
* @returns {GetImageResponse} 200 - Complete image details
|
||||
* @returns {object} 404 - Image not found or access denied
|
||||
|
|
@ -530,38 +412,16 @@ imagesRouter.get(
|
|||
*
|
||||
* @example
|
||||
* GET /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
||||
* GET /api/v1/images/@hero
|
||||
* GET /api/v1/images/@hero?flowId=abc-123
|
||||
*/
|
||||
imagesRouter.get(
|
||||
'/:id_or_alias',
|
||||
'/:id',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<GetImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { id_or_alias } = req.params;
|
||||
const { flowId } = req.query;
|
||||
const { id } = req.params;
|
||||
|
||||
// Resolve alias to imageId if needed (Section 6.2)
|
||||
let imageId: string;
|
||||
try {
|
||||
imageId = await resolveImageIdentifier(
|
||||
id_or_alias,
|
||||
req.apiKey.projectId,
|
||||
flowId as string | undefined
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = await service.getById(imageId);
|
||||
const image = await service.getById(id);
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
|
|
@ -599,13 +459,11 @@ imagesRouter.get(
|
|||
* - Custom metadata (arbitrary JSON object)
|
||||
*
|
||||
* Note: Alias assignment moved to separate endpoint PUT /images/:id/alias (Section 6.1)
|
||||
* Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
|
||||
*
|
||||
* @route PUT /api/v1/images/:id_or_alias
|
||||
* @route PUT /api/v1/images/:id
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
|
||||
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||
* @param {string} req.params.id - Image ID (UUID)
|
||||
* @param {UpdateImageRequest} req.body - Update parameters
|
||||
* @param {object} [req.body.focalPoint] - Focal point for cropping
|
||||
* @param {number} req.body.focalPoint.x - X coordinate (0.0-1.0)
|
||||
|
|
@ -618,55 +476,23 @@ imagesRouter.get(
|
|||
*
|
||||
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||
*
|
||||
* @example UUID identifier
|
||||
* @example
|
||||
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
||||
* {
|
||||
* "focalPoint": { "x": 0.5, "y": 0.3 },
|
||||
* "meta": { "category": "hero", "priority": 1 }
|
||||
* }
|
||||
*
|
||||
* @example Project-scoped alias
|
||||
* PUT /api/v1/images/@hero-banner
|
||||
* {
|
||||
* "focalPoint": { "x": 0.5, "y": 0.3 }
|
||||
* }
|
||||
*
|
||||
* @example Flow-scoped alias
|
||||
* PUT /api/v1/images/@product-shot?flowId=123e4567-e89b-12d3-a456-426614174000
|
||||
* {
|
||||
* "meta": { "category": "product" }
|
||||
* }
|
||||
*/
|
||||
imagesRouter.put(
|
||||
'/:id_or_alias',
|
||||
'/:id',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { id_or_alias } = req.params;
|
||||
const { flowId } = req.query;
|
||||
const { id } = req.params;
|
||||
const { focalPoint, meta } = req.body; // Removed alias (Section 6.1)
|
||||
|
||||
// Resolve alias to imageId if needed (Section 6.2)
|
||||
let imageId: string;
|
||||
try {
|
||||
imageId = await resolveImageIdentifier(
|
||||
id_or_alias,
|
||||
req.apiKey.projectId,
|
||||
flowId as string | undefined
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = await service.getById(imageId);
|
||||
const image = await service.getById(id);
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
|
|
@ -697,7 +523,7 @@ imagesRouter.put(
|
|||
if (focalPoint !== undefined) updates.focalPoint = focalPoint;
|
||||
if (meta !== undefined) updates.meta = meta;
|
||||
|
||||
const updated = await service.update(imageId, updates);
|
||||
const updated = await service.update(id, updates);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -707,103 +533,61 @@ imagesRouter.put(
|
|||
);
|
||||
|
||||
/**
|
||||
* Assign or remove a project-scoped alias from an image
|
||||
* Assign a project-scoped alias to an image
|
||||
*
|
||||
* Sets, updates, or removes the project-scoped alias for an image:
|
||||
* - Alias must start with @ symbol (when assigning)
|
||||
* Sets or updates the project-scoped alias for an image:
|
||||
* - Alias must start with @ symbol
|
||||
* - Must be unique within the project
|
||||
* - Replaces existing alias if image already has one
|
||||
* - Used for alias resolution in generations and CDN access
|
||||
* - Set alias to null to remove existing alias
|
||||
*
|
||||
* This is a dedicated endpoint introduced in Section 6.1 to separate
|
||||
* alias assignment from general metadata updates.
|
||||
* Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
|
||||
*
|
||||
* @route PUT /api/v1/images/:id_or_alias/alias
|
||||
* @route PUT /api/v1/images/:id/alias
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
|
||||
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||
* @param {string} req.params.id - Image ID (UUID)
|
||||
* @param {object} req.body - Request body
|
||||
* @param {string|null} req.body.alias - Project-scoped alias (e.g., "@hero-bg") or null to remove
|
||||
* @param {string} req.body.alias - Project-scoped alias (e.g., "@hero-bg")
|
||||
*
|
||||
* @returns {UpdateImageResponse} 200 - Updated image with new/removed alias
|
||||
* @returns {UpdateImageResponse} 200 - Updated image with new alias
|
||||
* @returns {object} 404 - Image not found or access denied
|
||||
* @returns {object} 400 - Invalid alias format
|
||||
* @returns {object} 400 - Missing or invalid alias
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 409 - Alias already exists
|
||||
*
|
||||
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||
* @throws {Error} VALIDATION_ERROR - Invalid alias format
|
||||
* @throws {Error} VALIDATION_ERROR - Alias is required
|
||||
* @throws {Error} ALIAS_CONFLICT - Alias already assigned to another image
|
||||
*
|
||||
* @example Assign alias
|
||||
* @example
|
||||
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias
|
||||
* {
|
||||
* "alias": "@hero-background"
|
||||
* }
|
||||
*
|
||||
* @example Remove alias
|
||||
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias
|
||||
* {
|
||||
* "alias": null
|
||||
* }
|
||||
*
|
||||
* @example Project-scoped alias identifier
|
||||
* PUT /api/v1/images/@old-hero/alias
|
||||
* {
|
||||
* "alias": "@new-hero"
|
||||
* }
|
||||
*
|
||||
* @example Flow-scoped alias identifier
|
||||
* PUT /api/v1/images/@temp-product/alias?flowId=123e4567-e89b-12d3-a456-426614174000
|
||||
* {
|
||||
* "alias": "@final-product"
|
||||
* }
|
||||
*/
|
||||
imagesRouter.put(
|
||||
'/:id_or_alias/alias',
|
||||
'/:id/alias',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { id_or_alias } = req.params;
|
||||
const { flowId } = req.query;
|
||||
const { id } = req.params;
|
||||
const { alias } = req.body;
|
||||
|
||||
// Validate: alias must be null (to remove) or a non-empty string
|
||||
if (alias !== null && (typeof alias !== 'string' || alias.trim() === '')) {
|
||||
if (!alias || typeof alias !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Alias must be null (to remove) or a non-empty string',
|
||||
message: 'Alias is required and must be a string',
|
||||
code: 'VALIDATION_ERROR',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve alias to imageId if needed (Section 6.2)
|
||||
let imageId: string;
|
||||
try {
|
||||
imageId = await resolveImageIdentifier(
|
||||
id_or_alias,
|
||||
req.apiKey.projectId,
|
||||
flowId as string | undefined
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = await service.getById(imageId);
|
||||
const image = await service.getById(id);
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
|
|
@ -826,16 +610,7 @@ imagesRouter.put(
|
|||
return;
|
||||
}
|
||||
|
||||
// Either remove alias (null) or assign new one (override behavior per Section 5.2)
|
||||
let updated: Image;
|
||||
if (alias === null) {
|
||||
// Remove alias
|
||||
updated = await service.update(imageId, { alias: null });
|
||||
} else {
|
||||
// Reassign alias (clears from any existing image, then assigns to this one)
|
||||
await service.reassignProjectAlias(alias, imageId, image.projectId);
|
||||
updated = (await service.getById(imageId))!;
|
||||
}
|
||||
const updated = await service.assignProjectAlias(id, alias);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -856,13 +631,11 @@ imagesRouter.put(
|
|||
*
|
||||
* Use with caution: This is a destructive operation that permanently removes
|
||||
* the image file and all database references.
|
||||
* Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
|
||||
*
|
||||
* @route DELETE /api/v1/images/:id_or_alias
|
||||
* @route DELETE /api/v1/images/:id
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
|
||||
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||
* @param {string} req.params.id - Image ID (UUID)
|
||||
*
|
||||
* @returns {DeleteImageResponse} 200 - Deletion confirmation with image ID
|
||||
* @returns {object} 404 - Image not found or access denied
|
||||
|
|
@ -870,7 +643,7 @@ imagesRouter.put(
|
|||
*
|
||||
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||
*
|
||||
* @example UUID identifier
|
||||
* @example
|
||||
* DELETE /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* Response:
|
||||
|
|
@ -878,42 +651,16 @@ imagesRouter.put(
|
|||
* "success": true,
|
||||
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
||||
* }
|
||||
*
|
||||
* @example Project-scoped alias
|
||||
* DELETE /api/v1/images/@old-banner
|
||||
*
|
||||
* @example Flow-scoped alias
|
||||
* DELETE /api/v1/images/@temp-image?flowId=123e4567-e89b-12d3-a456-426614174000
|
||||
*/
|
||||
imagesRouter.delete(
|
||||
'/:id_or_alias',
|
||||
'/:id',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<DeleteImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { id_or_alias } = req.params;
|
||||
const { flowId } = req.query;
|
||||
const { id } = req.params;
|
||||
|
||||
// Resolve alias to imageId if needed (Section 6.2)
|
||||
let imageId: string;
|
||||
try {
|
||||
imageId = await resolveImageIdentifier(
|
||||
id_or_alias,
|
||||
req.apiKey.projectId,
|
||||
flowId as string | undefined
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = await service.getById(imageId);
|
||||
const image = await service.getById(id);
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
|
|
@ -936,11 +683,11 @@ imagesRouter.delete(
|
|||
return;
|
||||
}
|
||||
|
||||
await service.hardDelete(imageId);
|
||||
await service.hardDelete(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { id: imageId },
|
||||
data: { id },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, and, isNull, desc, or } from 'drizzle-orm';
|
||||
import { eq, and, isNull, desc } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { images, flows } from '@banatie/database';
|
||||
import type { AliasResolution, Image } from '@/types/models';
|
||||
|
|
@ -117,13 +117,12 @@ export class AliasService {
|
|||
alias: string,
|
||||
projectId: string
|
||||
): Promise<AliasResolution | null> {
|
||||
// Project aliases can exist on images with or without flowId
|
||||
// Per spec: images with project alias should be resolvable at project level
|
||||
const image = await db.query.images.findFirst({
|
||||
where: and(
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.alias, alias),
|
||||
isNull(images.deletedAt)
|
||||
isNull(images.deletedAt),
|
||||
isNull(images.flowId)
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -142,11 +141,9 @@ export class AliasService {
|
|||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<Image | undefined> {
|
||||
// Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
|
||||
// Images may have pendingFlowId before the flow record is created
|
||||
return await db.query.images.findFirst({
|
||||
where: and(
|
||||
or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
|
||||
eq(images.flowId, flowId),
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.source, 'generated'),
|
||||
isNull(images.deletedAt)
|
||||
|
|
@ -159,10 +156,9 @@ export class AliasService {
|
|||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<Image | undefined> {
|
||||
// Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
|
||||
const allImages = await db.query.images.findMany({
|
||||
where: and(
|
||||
or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
|
||||
eq(images.flowId, flowId),
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.source, 'generated'),
|
||||
isNull(images.deletedAt)
|
||||
|
|
@ -178,10 +174,9 @@ export class AliasService {
|
|||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<Image | undefined> {
|
||||
// Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
|
||||
return await db.query.images.findFirst({
|
||||
where: and(
|
||||
or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
|
||||
eq(images.flowId, flowId),
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.source, 'uploaded'),
|
||||
isNull(images.deletedAt)
|
||||
|
|
@ -201,43 +196,42 @@ export class AliasService {
|
|||
throw new Error(reservedResult.error!.message);
|
||||
}
|
||||
|
||||
// NOTE: Conflict checks removed per Section 5.2 of api-refactoring-final.md
|
||||
// Aliases now use override behavior - new requests take priority over existing aliases
|
||||
// Flow alias conflicts are handled by JSONB field overwrite (no check needed)
|
||||
if (flowId) {
|
||||
await this.checkFlowAliasConflict(alias, flowId, projectId);
|
||||
} else {
|
||||
await this.checkProjectAliasConflict(alias, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// DEPRECATED: Removed per Section 5.2 - aliases now use override behavior
|
||||
// private async checkProjectAliasConflict(alias: string, projectId: string): Promise<void> {
|
||||
// const existing = await db.query.images.findFirst({
|
||||
// where: and(
|
||||
// eq(images.projectId, projectId),
|
||||
// eq(images.alias, alias),
|
||||
// isNull(images.deletedAt),
|
||||
// isNull(images.flowId)
|
||||
// ),
|
||||
// });
|
||||
//
|
||||
// if (existing) {
|
||||
// throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
|
||||
// }
|
||||
// }
|
||||
private async checkProjectAliasConflict(alias: string, projectId: string): Promise<void> {
|
||||
const existing = await db.query.images.findFirst({
|
||||
where: and(
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.alias, alias),
|
||||
isNull(images.deletedAt),
|
||||
isNull(images.flowId)
|
||||
),
|
||||
});
|
||||
|
||||
// DEPRECATED: Removed per Section 5.2 - flow aliases now use override behavior
|
||||
// Flow alias conflicts are naturally handled by JSONB field overwrite in assignFlowAlias()
|
||||
// private async checkFlowAliasConflict(alias: string, flowId: string, projectId: string): Promise<void> {
|
||||
// const flow = await db.query.flows.findFirst({
|
||||
// where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)),
|
||||
// });
|
||||
//
|
||||
// if (!flow) {
|
||||
// throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||
// }
|
||||
//
|
||||
// const flowAliases = flow.aliases as Record<string, string>;
|
||||
// if (flowAliases[alias]) {
|
||||
// throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
|
||||
// }
|
||||
// }
|
||||
if (existing) {
|
||||
throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkFlowAliasConflict(alias: string, flowId: string, projectId: string): Promise<void> {
|
||||
const flow = await db.query.flows.findFirst({
|
||||
where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)),
|
||||
});
|
||||
|
||||
if (!flow) {
|
||||
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||
}
|
||||
|
||||
const flowAliases = flow.aliases as Record<string, string>;
|
||||
if (flowAliases[alias]) {
|
||||
throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveMultiple(
|
||||
aliases: string[],
|
||||
|
|
|
|||
|
|
@ -180,21 +180,12 @@ export class GenerationService {
|
|||
fileSize: genResult.size || 0,
|
||||
fileHash,
|
||||
source: 'generated',
|
||||
alias: null,
|
||||
alias: params.alias || null,
|
||||
meta: params.meta || {},
|
||||
width: genResult.generatedImageData?.width ?? null,
|
||||
height: genResult.generatedImageData?.height ?? null,
|
||||
});
|
||||
|
||||
// Reassign project alias if provided (override behavior per Section 5.2)
|
||||
if (params.alias) {
|
||||
await this.imageService.reassignProjectAlias(
|
||||
params.alias,
|
||||
imageRecord.id,
|
||||
params.projectId
|
||||
);
|
||||
}
|
||||
|
||||
// Eager flow creation if flowAlias is provided (Section 4.2)
|
||||
if (params.flowAlias) {
|
||||
// If we have pendingFlowId, create flow and link pending generations
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export class ImageService {
|
|||
async update(
|
||||
id: string,
|
||||
updates: {
|
||||
alias?: string | null;
|
||||
alias?: string;
|
||||
focalPoint?: { x: number; y: number };
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -215,10 +215,17 @@ export class ImageService {
|
|||
await db.delete(images).where(eq(images.id, id));
|
||||
|
||||
} catch (error) {
|
||||
// Per Section 7.4: If MinIO delete fails, do NOT proceed with DB cleanup
|
||||
// This prevents orphaned files in MinIO
|
||||
console.error('MinIO delete failed, aborting image deletion:', error);
|
||||
throw new Error(ERROR_MESSAGES.STORAGE_DELETE_FAILED || 'Failed to delete file from storage');
|
||||
// If MinIO delete fails, still proceed with DB cleanup (MVP mindset)
|
||||
// Log error but don't throw
|
||||
console.error('MinIO delete failed, proceeding with DB cleanup:', error);
|
||||
|
||||
// Still perform DB cleanup
|
||||
await db
|
||||
.update(generations)
|
||||
.set({ outputImageId: null })
|
||||
.where(eq(generations.outputImageId, id));
|
||||
|
||||
await db.delete(images).where(eq(images.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -250,46 +257,6 @@ export class ImageService {
|
|||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign a project-scoped alias to a new image
|
||||
* Clears the alias from any existing image and assigns it to the new image
|
||||
* Implements override behavior per Section 5.2 of api-refactoring-final.md
|
||||
*
|
||||
* @param alias - The alias to reassign (e.g., "@hero")
|
||||
* @param newImageId - ID of the image to receive the alias
|
||||
* @param projectId - Project ID for scope validation
|
||||
*/
|
||||
async reassignProjectAlias(
|
||||
alias: string,
|
||||
newImageId: string,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
// Step 1: Clear alias from any existing image with this alias
|
||||
// Project aliases can exist on images with or without flowId
|
||||
await db
|
||||
.update(images)
|
||||
.set({
|
||||
alias: null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.alias, alias),
|
||||
isNull(images.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
// Step 2: Assign alias to new image
|
||||
await db
|
||||
.update(images)
|
||||
.set({
|
||||
alias: alias,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(images.id, newImageId));
|
||||
}
|
||||
|
||||
async getByStorageKey(storageKey: string): Promise<Image | null> {
|
||||
const image = await db.query.images.findFirst({
|
||||
where: and(
|
||||
|
|
@ -325,40 +292,4 @@ export class ImageService {
|
|||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Link all pending images to a flow
|
||||
* Called when flow is created to attach all images with matching pendingFlowId
|
||||
*/
|
||||
async linkPendingImagesToFlow(
|
||||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
// Find all images with pendingFlowId matching this flowId
|
||||
const pendingImages = await db.query.images.findMany({
|
||||
where: and(
|
||||
eq(images.pendingFlowId, flowId),
|
||||
eq(images.projectId, projectId)
|
||||
),
|
||||
});
|
||||
|
||||
if (pendingImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update images: set flowId and clear pendingFlowId
|
||||
await db
|
||||
.update(images)
|
||||
.set({
|
||||
flowId: flowId,
|
||||
pendingFlowId: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(images.pendingFlowId, flowId),
|
||||
eq(images.projectId, projectId)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ export const toGenerationResponse = (gen: GenerationWithRelations): GenerationRe
|
|||
export const toImageResponse = (img: Image): ImageResponse => ({
|
||||
id: img.id,
|
||||
projectId: img.projectId,
|
||||
flowId: img.flowId ?? img.pendingFlowId ?? null, // Return actual flowId or pendingFlowId for client
|
||||
flowId: img.flowId,
|
||||
storageKey: img.storageKey,
|
||||
storageUrl: img.storageUrl,
|
||||
mimeType: img.mimeType,
|
||||
|
|
|
|||
|
|
@ -1,449 +0,0 @@
|
|||
# Advanced Image Generation
|
||||
|
||||
Advanced generation features: reference images, aliases, flows, and regeneration. For basic generation, see [image-generation.md](image-generation.md).
|
||||
|
||||
All endpoints require Project Key authentication via `X-API-Key` header.
|
||||
|
||||
---
|
||||
|
||||
## Reference Images
|
||||
|
||||
Use existing images as style or content references for generation.
|
||||
|
||||
### Using References
|
||||
|
||||
Add `referenceImages` array to your generation request:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "A product photo with the logo in the corner",
|
||||
"referenceImages": ["@brand-logo", "@product-style"]
|
||||
}
|
||||
```
|
||||
|
||||
References can be:
|
||||
- **Project aliases**: `@logo`, `@brand-style`
|
||||
- **Flow aliases**: `@hero` (with flowId context)
|
||||
- **Technical aliases**: `@last`, `@first`, `@upload`
|
||||
- **Image UUIDs**: `550e8400-e29b-41d4-a716-446655440000`
|
||||
|
||||
### Auto-Detection from Prompt
|
||||
|
||||
Aliases in the prompt are automatically detected and used as references:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "Create a banner using @brand-logo with blue background"
|
||||
}
|
||||
// @brand-logo is auto-detected and added to referenceImages
|
||||
```
|
||||
|
||||
### Reference Limits
|
||||
|
||||
| Constraint | Limit |
|
||||
|------------|-------|
|
||||
| Max references | 3 images |
|
||||
| Max file size | 5MB per image |
|
||||
| Supported formats | PNG, JPEG, WebP |
|
||||
|
||||
### Response with References
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "550e8400-...",
|
||||
"prompt": "Create a banner using @brand-logo",
|
||||
"referencedImages": [
|
||||
{ "imageId": "7c4ccf47-...", "alias": "@brand-logo" }
|
||||
],
|
||||
"referenceImages": [
|
||||
{
|
||||
"id": "7c4ccf47-...",
|
||||
"storageUrl": "http://...",
|
||||
"alias": "@brand-logo"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alias Assignment
|
||||
|
||||
Assign aliases to generated images for easy referencing.
|
||||
|
||||
### Project-Scoped Alias
|
||||
|
||||
Use `alias` parameter to assign a project-wide alias:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "A hero banner image",
|
||||
"alias": "@hero-banner"
|
||||
}
|
||||
```
|
||||
|
||||
The output image will be accessible via `@hero-banner` anywhere in the project.
|
||||
|
||||
### Flow-Scoped Alias
|
||||
|
||||
Use `flowAlias` parameter to assign a flow-specific alias:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "A hero image variation",
|
||||
"flowId": "550e8400-...",
|
||||
"flowAlias": "@best"
|
||||
}
|
||||
```
|
||||
|
||||
The alias `@best` is only accessible within this flow's context.
|
||||
|
||||
### Alias Format
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| Prefix | Must start with `@` |
|
||||
| Characters | Alphanumeric, underscore, hyphen |
|
||||
| Pattern | `@[a-zA-Z0-9_-]+` |
|
||||
| Max length | 50 characters |
|
||||
| Examples | `@logo`, `@hero-bg`, `@image_1` |
|
||||
|
||||
### Reserved Aliases
|
||||
|
||||
These aliases are computed automatically and cannot be assigned:
|
||||
|
||||
| Alias | Description |
|
||||
|-------|-------------|
|
||||
| `@last` | Most recently generated image in flow |
|
||||
| `@first` | First generated image in flow |
|
||||
| `@upload` | Most recently uploaded image in flow |
|
||||
|
||||
### Override Behavior
|
||||
|
||||
When assigning an alias that already exists:
|
||||
- The **new image gets the alias**
|
||||
- The **old image loses the alias** (alias set to null)
|
||||
- The old image is **not deleted**, just unlinked
|
||||
|
||||
---
|
||||
|
||||
## 3-Tier Alias Resolution
|
||||
|
||||
Aliases are resolved in this order of precedence:
|
||||
|
||||
### 1. Technical Aliases (Highest Priority)
|
||||
|
||||
Computed on-the-fly, require flow context:
|
||||
|
||||
```
|
||||
GET /api/v1/images/@last?flowId=550e8400-...
|
||||
```
|
||||
|
||||
| Alias | Returns |
|
||||
|-------|---------|
|
||||
| `@last` | Last generated image in flow |
|
||||
| `@first` | First generated image in flow |
|
||||
| `@upload` | Last uploaded image in flow |
|
||||
|
||||
### 2. Flow Aliases
|
||||
|
||||
Stored in flow's `aliases` JSONB field:
|
||||
|
||||
```
|
||||
GET /api/v1/images/@hero?flowId=550e8400-...
|
||||
```
|
||||
|
||||
Different flows can have the same alias pointing to different images.
|
||||
|
||||
### 3. Project Aliases (Lowest Priority)
|
||||
|
||||
Stored in image's `alias` column:
|
||||
|
||||
```
|
||||
GET /api/v1/images/@logo
|
||||
```
|
||||
|
||||
Global across the project, unique per project.
|
||||
|
||||
### Resolution Example
|
||||
|
||||
```
|
||||
// Request with flowId
|
||||
GET /api/v1/images/@hero?flowId=abc-123
|
||||
|
||||
// Resolution order:
|
||||
// 1. Is "@hero" a technical alias? No
|
||||
// 2. Does flow abc-123 have "@hero" in aliases? Check flows.aliases JSONB
|
||||
// 3. Does any image have alias = "@hero"? Check images.alias column
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow Integration
|
||||
|
||||
Flows organize related generations into chains.
|
||||
|
||||
### Lazy Flow Creation
|
||||
|
||||
When `flowId` is not provided, a pending flow ID is generated:
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"prompt": "A red car"
|
||||
// No flowId
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
"data": {
|
||||
"id": "gen-123",
|
||||
"flowId": "flow-456" // Auto-generated, flow record not created yet
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The flow record is created when:
|
||||
- A second generation uses the same `flowId`
|
||||
- A `flowAlias` is assigned to any generation in the flow
|
||||
|
||||
### Eager Flow Creation
|
||||
|
||||
When `flowAlias` is provided, the flow is created immediately:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "A hero banner",
|
||||
"flowAlias": "@hero-flow"
|
||||
}
|
||||
```
|
||||
|
||||
### No Flow Association
|
||||
|
||||
To explicitly create without flow association:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "A standalone image",
|
||||
"flowId": null
|
||||
}
|
||||
```
|
||||
|
||||
### flowId Behavior Summary
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `undefined` (not provided) | Auto-generate pendingFlowId, lazy creation |
|
||||
| `null` (explicitly null) | No flow association |
|
||||
| `"uuid-string"` | Use provided ID, create flow if doesn't exist |
|
||||
|
||||
---
|
||||
|
||||
## Regeneration
|
||||
|
||||
### Regenerate Generation
|
||||
|
||||
Recreate an image using the exact same parameters:
|
||||
|
||||
```
|
||||
POST /api/v1/generations/:id/regenerate
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Uses exact same prompt, aspect ratio, references
|
||||
- **Preserves** output image ID and URL
|
||||
- Works regardless of current status
|
||||
- No request body needed
|
||||
|
||||
**Response:** Same as original generation with new image
|
||||
|
||||
### Update and Regenerate
|
||||
|
||||
Use PUT to modify parameters with smart regeneration:
|
||||
|
||||
```
|
||||
PUT /api/v1/generations/:id
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "A blue car instead",
|
||||
"aspectRatio": "1:1"
|
||||
}
|
||||
```
|
||||
|
||||
**Smart Behavior:**
|
||||
|
||||
| Changed Field | Triggers Regeneration |
|
||||
|---------------|----------------------|
|
||||
| `prompt` | Yes |
|
||||
| `aspectRatio` | Yes |
|
||||
| `flowId` | No (metadata only) |
|
||||
| `meta` | No (metadata only) |
|
||||
|
||||
### Flow Regenerate
|
||||
|
||||
Regenerate the most recent generation in a flow:
|
||||
|
||||
```
|
||||
POST /api/v1/flows/:id/regenerate
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Finds the most recent generation in flow
|
||||
- Regenerates with exact same parameters
|
||||
- Returns error if flow has no generations
|
||||
|
||||
---
|
||||
|
||||
## Flow Management
|
||||
|
||||
### List Flows
|
||||
|
||||
```
|
||||
GET /api/v1/flows
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `limit` | number | `20` | Results per page (max: 100) |
|
||||
| `offset` | number | `0` | Pagination offset |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "flow-456",
|
||||
"projectId": "project-123",
|
||||
"aliases": { "@hero": "img-789", "@best": "img-abc" },
|
||||
"generationCount": 5,
|
||||
"imageCount": 7,
|
||||
"createdAt": "2025-11-28T10:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "limit": 20, "offset": 0, "total": 3, "hasMore": false }
|
||||
}
|
||||
```
|
||||
|
||||
### Get Flow
|
||||
|
||||
```
|
||||
GET /api/v1/flows/:id
|
||||
```
|
||||
|
||||
Returns flow with computed counts and aliases.
|
||||
|
||||
### List Flow Generations
|
||||
|
||||
```
|
||||
GET /api/v1/flows/:id/generations
|
||||
```
|
||||
|
||||
Returns all generations in the flow, ordered by creation date (newest first).
|
||||
|
||||
### List Flow Images
|
||||
|
||||
```
|
||||
GET /api/v1/flows/:id/images
|
||||
```
|
||||
|
||||
Returns all images in the flow (generated and uploaded).
|
||||
|
||||
### Update Flow Aliases
|
||||
|
||||
```
|
||||
PUT /api/v1/flows/:id/aliases
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"aliases": {
|
||||
"@hero": "image-id-123",
|
||||
"@best": "image-id-456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:** Merges with existing aliases (does not replace all).
|
||||
|
||||
### Remove Flow Alias
|
||||
|
||||
```
|
||||
DELETE /api/v1/flows/:id/aliases/:alias
|
||||
```
|
||||
|
||||
Example: `DELETE /api/v1/flows/flow-456/aliases/@hero`
|
||||
|
||||
### Delete Flow
|
||||
|
||||
```
|
||||
DELETE /api/v1/flows/:id
|
||||
```
|
||||
|
||||
**Cascade Behavior:**
|
||||
- Flow record is **hard deleted**
|
||||
- All generations in flow are **hard deleted**
|
||||
- Images **without** project alias: **hard deleted** with MinIO cleanup
|
||||
- Images **with** project alias: **kept**, but `flowId` set to null
|
||||
|
||||
---
|
||||
|
||||
## Full Request Example
|
||||
|
||||
```json
|
||||
// POST /api/v1/generations
|
||||
{
|
||||
"prompt": "A professional product photo using @brand-style and @product-template",
|
||||
"aspectRatio": "1:1",
|
||||
"autoEnhance": true,
|
||||
"enhancementOptions": { "template": "product" },
|
||||
"flowId": "campaign-flow-123",
|
||||
"alias": "@latest-product",
|
||||
"flowAlias": "@hero",
|
||||
"meta": { "campaign": "summer-2025" }
|
||||
}
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. `@brand-style` and `@product-template` resolved and used as references
|
||||
2. Prompt enhanced using "product" template
|
||||
3. Generation created in flow `campaign-flow-123`
|
||||
4. Output image assigned project alias `@latest-product`
|
||||
5. Output image assigned flow alias `@hero` in the flow
|
||||
6. Custom metadata stored
|
||||
|
||||
---
|
||||
|
||||
## Response Fields (Additional)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `flowId` | string | Associated flow UUID |
|
||||
| `alias` | string | Project-scoped alias (on outputImage) |
|
||||
| `referencedImages` | array | Resolved references: `[{ imageId, alias }]` |
|
||||
| `referenceImages` | array | Full image details of references |
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
| HTTP Status | Code | Description |
|
||||
|-------------|------|-------------|
|
||||
| 400 | `ALIAS_FORMAT_CHECK` | Alias must start with @ |
|
||||
| 400 | `RESERVED_ALIAS` | Cannot use technical alias |
|
||||
| 404 | `ALIAS_NOT_FOUND` | Referenced alias doesn't exist |
|
||||
| 404 | `FLOW_NOT_FOUND` | Flow does not exist |
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Basic Generation](image-generation.md) - Simple generation
|
||||
- [Image Upload](images-upload.md) - Upload with aliases
|
||||
- [Live URLs](live-url.md) - CDN and live generation
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,374 +0,0 @@
|
|||
# Image Upload & Management API
|
||||
|
||||
Upload images and manage your image library. For generation, see [image-generation.md](image-generation.md).
|
||||
|
||||
All endpoints require Project Key authentication via `X-API-Key` header.
|
||||
|
||||
---
|
||||
|
||||
## Upload Image
|
||||
|
||||
```
|
||||
POST /api/v1/images/upload
|
||||
```
|
||||
|
||||
Upload an image file with optional alias and flow association.
|
||||
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
**Form Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `file` | file | Yes | Image file (PNG, JPEG, WebP) |
|
||||
| `alias` | string | No | Project-scoped alias (e.g., `@logo`) |
|
||||
| `flowId` | string | No | Flow UUID to associate with |
|
||||
| `flowAlias` | string | No | Flow-scoped alias (requires flowId) |
|
||||
| `meta` | string | No | JSON string with custom metadata |
|
||||
|
||||
**File Constraints:**
|
||||
|
||||
| Constraint | Limit |
|
||||
|------------|-------|
|
||||
| Max file size | 5MB |
|
||||
| Supported formats | PNG, JPEG, JPG, WebP |
|
||||
| MIME types | `image/png`, `image/jpeg`, `image/webp` |
|
||||
|
||||
**Example Request (curl):**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/images/upload \
|
||||
-H "X-API-Key: YOUR_PROJECT_KEY" \
|
||||
-F "file=@logo.png" \
|
||||
-F "alias=@brand-logo" \
|
||||
-F 'meta={"tags": ["logo", "brand"]}'
|
||||
```
|
||||
|
||||
**Response:** `201 Created`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "7c4ccf47-41ce-4718-afbc-8c553b2c631a",
|
||||
"projectId": "57c7f7f4-47de-4d70-9ebd-3807a0b63746",
|
||||
"flowId": null,
|
||||
"storageKey": "default/project-id/uploads/2025-11/logo.png",
|
||||
"storageUrl": "http://localhost:9000/banatie/default/project-id/uploads/logo.png",
|
||||
"mimeType": "image/png",
|
||||
"fileSize": 45678,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"source": "uploaded",
|
||||
"alias": "@brand-logo",
|
||||
"focalPoint": null,
|
||||
"meta": { "tags": ["logo", "brand"] },
|
||||
"createdAt": "2025-11-28T10:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### flowId Behavior
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| Not provided | Auto-generate `pendingFlowId`, lazy flow creation |
|
||||
| `null` | No flow association |
|
||||
| `"uuid"` | Associate with specified flow |
|
||||
|
||||
### Upload with Flow
|
||||
|
||||
```bash
|
||||
# Associate with existing flow
|
||||
curl -X POST .../images/upload \
|
||||
-F "file=@reference.png" \
|
||||
-F "flowId=flow-123" \
|
||||
-F "flowAlias=@reference"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## List Images
|
||||
|
||||
```
|
||||
GET /api/v1/images
|
||||
```
|
||||
|
||||
List all images with filtering and pagination.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `flowId` | string | - | Filter by flow UUID |
|
||||
| `source` | string | - | Filter by source: `generated`, `uploaded` |
|
||||
| `alias` | string | - | Filter by exact alias match |
|
||||
| `limit` | number | `20` | Results per page (max: 100) |
|
||||
| `offset` | number | `0` | Pagination offset |
|
||||
| `includeDeleted` | boolean | `false` | Include soft-deleted records |
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
GET /api/v1/images?source=uploaded&limit=10
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "7c4ccf47-...",
|
||||
"storageUrl": "http://...",
|
||||
"source": "uploaded",
|
||||
"alias": "@brand-logo",
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"createdAt": "2025-11-28T10:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
"total": 25,
|
||||
"hasMore": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Image
|
||||
|
||||
```
|
||||
GET /api/v1/images/:id_or_alias
|
||||
```
|
||||
|
||||
Get a single image by UUID or alias.
|
||||
|
||||
**Path Parameter:**
|
||||
- `id_or_alias` - Image UUID or `@alias`
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `flowId` | string | Flow context for alias resolution |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
# By UUID
|
||||
GET /api/v1/images/7c4ccf47-41ce-4718-afbc-8c553b2c631a
|
||||
|
||||
# By project alias
|
||||
GET /api/v1/images/@brand-logo
|
||||
|
||||
# By technical alias (requires flowId)
|
||||
GET /api/v1/images/@last?flowId=flow-123
|
||||
|
||||
# By flow alias
|
||||
GET /api/v1/images/@hero?flowId=flow-123
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "7c4ccf47-41ce-4718-afbc-8c553b2c631a",
|
||||
"projectId": "57c7f7f4-47de-4d70-9ebd-3807a0b63746",
|
||||
"flowId": null,
|
||||
"storageKey": "default/project-id/uploads/2025-11/logo.png",
|
||||
"storageUrl": "http://localhost:9000/banatie/.../logo.png",
|
||||
"mimeType": "image/png",
|
||||
"fileSize": 45678,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"source": "uploaded",
|
||||
"alias": "@brand-logo",
|
||||
"focalPoint": null,
|
||||
"fileHash": null,
|
||||
"generationId": null,
|
||||
"meta": { "tags": ["logo", "brand"] },
|
||||
"createdAt": "2025-11-28T10:00:00.000Z",
|
||||
"updatedAt": "2025-11-28T10:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Image Metadata
|
||||
|
||||
```
|
||||
PUT /api/v1/images/:id_or_alias
|
||||
```
|
||||
|
||||
Update image metadata (focal point, custom metadata).
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `focalPoint` | object | Focal point: `{ x: 0.0-1.0, y: 0.0-1.0 }` |
|
||||
| `meta` | object | Custom metadata |
|
||||
|
||||
**Example:**
|
||||
|
||||
```json
|
||||
// PUT /api/v1/images/@brand-logo
|
||||
{
|
||||
"focalPoint": { "x": 0.5, "y": 0.3 },
|
||||
"meta": {
|
||||
"description": "Updated brand logo",
|
||||
"tags": ["logo", "brand", "2025"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Updated image object.
|
||||
|
||||
> **Note:** Alias assignment has its own dedicated endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Assign Alias
|
||||
|
||||
```
|
||||
PUT /api/v1/images/:id_or_alias/alias
|
||||
```
|
||||
|
||||
Assign or remove a project-scoped alias.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
// Assign alias
|
||||
{ "alias": "@new-logo" }
|
||||
|
||||
// Remove alias
|
||||
{ "alias": null }
|
||||
```
|
||||
|
||||
**Override Behavior:**
|
||||
- If another image has this alias, it loses the alias
|
||||
- The new image gets the alias
|
||||
- Old image is preserved, just unlinked
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/v1/images/7c4ccf47-.../alias \
|
||||
-H "X-API-Key: YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"alias": "@primary-logo"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Delete Image
|
||||
|
||||
```
|
||||
DELETE /api/v1/images/:id_or_alias
|
||||
```
|
||||
|
||||
Permanently delete an image and its storage file.
|
||||
|
||||
**Behavior:**
|
||||
- **Hard delete** - image record permanently removed
|
||||
- Storage file deleted from MinIO
|
||||
- Cascading updates:
|
||||
- Related generations: `outputImageId` set to null
|
||||
- Flow aliases: image removed from flow's aliases
|
||||
- Referenced images: removed from generation's referencedImages
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Image deleted"
|
||||
}
|
||||
```
|
||||
|
||||
> **Warning:** This cannot be undone. The image file is permanently removed.
|
||||
|
||||
---
|
||||
|
||||
## Image Response Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | string | Image UUID |
|
||||
| `projectId` | string | Project UUID |
|
||||
| `flowId` | string | Associated flow UUID (null if none) |
|
||||
| `storageKey` | string | Internal storage path |
|
||||
| `storageUrl` | string | **Direct URL to access image** |
|
||||
| `mimeType` | string | Image MIME type |
|
||||
| `fileSize` | number | File size in bytes |
|
||||
| `width` | number | Image width in pixels |
|
||||
| `height` | number | Image height in pixels |
|
||||
| `source` | string | `"generated"` or `"uploaded"` |
|
||||
| `alias` | string | Project-scoped alias (null if none) |
|
||||
| `focalPoint` | object | `{ x, y }` coordinates (0.0-1.0) |
|
||||
| `fileHash` | string | SHA-256 hash for deduplication |
|
||||
| `generationId` | string | Source generation UUID (if generated) |
|
||||
| `meta` | object | Custom metadata |
|
||||
| `createdAt` | string | ISO timestamp |
|
||||
| `updatedAt` | string | ISO timestamp |
|
||||
| `deletedAt` | string | Soft delete timestamp (null if active) |
|
||||
|
||||
### Accessing Images
|
||||
|
||||
Use `storageUrl` for direct image access:
|
||||
|
||||
```html
|
||||
<img src="http://localhost:9000/banatie/.../image.png" />
|
||||
```
|
||||
|
||||
For public CDN access, see [Live URLs](live-url.md).
|
||||
|
||||
---
|
||||
|
||||
## Storage Organization
|
||||
|
||||
Images are organized in MinIO storage:
|
||||
|
||||
```
|
||||
bucket/
|
||||
{orgId}/
|
||||
{projectId}/
|
||||
uploads/ # Uploaded images
|
||||
2025-11/
|
||||
image.png
|
||||
generated/ # AI-generated images
|
||||
2025-11/
|
||||
gen_abc123.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
| HTTP Status | Code | Description |
|
||||
|-------------|------|-------------|
|
||||
| 400 | `VALIDATION_ERROR` | Invalid parameters |
|
||||
| 400 | `FILE_TOO_LARGE` | File exceeds 5MB limit |
|
||||
| 400 | `UNSUPPORTED_FILE_TYPE` | Not PNG, JPEG, or WebP |
|
||||
| 400 | `ALIAS_FORMAT_CHECK` | Alias must start with @ |
|
||||
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
|
||||
| 404 | `IMAGE_NOT_FOUND` | Image or alias doesn't exist |
|
||||
| 404 | `ALIAS_NOT_FOUND` | Alias doesn't resolve to any image |
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Basic Generation](image-generation.md) - Generate images
|
||||
- [Advanced Generation](image-generation-advanced.md) - References, aliases, flows
|
||||
- [Live URLs](live-url.md) - CDN and public access
|
||||
|
|
@ -1,380 +0,0 @@
|
|||
# Live URL & CDN API
|
||||
|
||||
Public CDN endpoints for image serving and live URL generation. For authenticated API, see [image-generation.md](image-generation.md).
|
||||
|
||||
---
|
||||
|
||||
## CDN Image Serving
|
||||
|
||||
```
|
||||
GET /cdn/:orgSlug/:projectSlug/img/:filenameOrAlias
|
||||
```
|
||||
|
||||
**Authentication:** None - Public endpoint
|
||||
|
||||
Serve images by filename or project-scoped alias.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `orgSlug` | Organization identifier |
|
||||
| `projectSlug` | Project identifier |
|
||||
| `filenameOrAlias` | Filename or `@alias` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
# By filename
|
||||
GET /cdn/acme/website/img/hero-background.jpg
|
||||
|
||||
# By alias
|
||||
GET /cdn/acme/website/img/@hero
|
||||
```
|
||||
|
||||
**Response:** Raw image bytes (not JSON)
|
||||
|
||||
**Response Headers:**
|
||||
|
||||
| Header | Value |
|
||||
|--------|-------|
|
||||
| `Content-Type` | `image/jpeg`, `image/png`, etc. |
|
||||
| `Content-Length` | File size in bytes |
|
||||
| `Cache-Control` | `public, max-age=31536000` (1 year) |
|
||||
| `X-Image-Id` | Image UUID |
|
||||
|
||||
---
|
||||
|
||||
## Live URL Generation
|
||||
|
||||
```
|
||||
GET /cdn/:orgSlug/:projectSlug/live/:scope
|
||||
```
|
||||
|
||||
**Authentication:** None - Public endpoint
|
||||
|
||||
Generate images on-demand via URL parameters with automatic caching.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `orgSlug` | Organization identifier |
|
||||
| `projectSlug` | Project identifier |
|
||||
| `scope` | Scope identifier (alphanumeric, hyphens, underscores) |
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `prompt` | string | Yes | - | Image description |
|
||||
| `aspectRatio` | string | No | `"1:1"` | Aspect ratio |
|
||||
| `autoEnhance` | boolean | No | `true` | Enable prompt enhancement |
|
||||
| `template` | string | No | `"general"` | Enhancement template |
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
GET /cdn/acme/website/live/hero-section?prompt=mountain+landscape&aspectRatio=16:9
|
||||
```
|
||||
|
||||
**Response:** Raw image bytes
|
||||
|
||||
### Cache Behavior
|
||||
|
||||
**Cache HIT** - Image exists in cache:
|
||||
- Returns instantly
|
||||
- No rate limit check
|
||||
- Headers include `X-Cache-Status: HIT`
|
||||
|
||||
**Cache MISS** - New generation:
|
||||
- Generates image using AI
|
||||
- Stores in cache
|
||||
- Counts toward rate limit
|
||||
- Headers include `X-Cache-Status: MISS`
|
||||
|
||||
**Cache Key:** Computed from `projectId + scope + prompt + aspectRatio + autoEnhance + template`
|
||||
|
||||
### Response Headers
|
||||
|
||||
**Cache HIT:**
|
||||
|
||||
| Header | Value |
|
||||
|--------|-------|
|
||||
| `Content-Type` | `image/jpeg` |
|
||||
| `Cache-Control` | `public, max-age=31536000` |
|
||||
| `X-Cache-Status` | `HIT` |
|
||||
| `X-Scope` | Scope identifier |
|
||||
| `X-Image-Id` | Image UUID |
|
||||
|
||||
**Cache MISS:**
|
||||
|
||||
| Header | Value |
|
||||
|--------|-------|
|
||||
| `Content-Type` | `image/jpeg` |
|
||||
| `Cache-Control` | `public, max-age=31536000` |
|
||||
| `X-Cache-Status` | `MISS` |
|
||||
| `X-Scope` | Scope identifier |
|
||||
| `X-Generation-Id` | Generation UUID |
|
||||
| `X-Image-Id` | Image UUID |
|
||||
| `X-RateLimit-Limit` | `10` |
|
||||
| `X-RateLimit-Remaining` | Remaining requests |
|
||||
| `X-RateLimit-Reset` | Seconds until reset |
|
||||
|
||||
---
|
||||
|
||||
## IP Rate Limiting
|
||||
|
||||
Live URLs are rate limited by IP address:
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| New generations | 10 per hour per IP |
|
||||
| Cache hits | Unlimited |
|
||||
|
||||
**Note:** Only cache MISS (new generations) count toward the limit. Cache HIT requests are not limited.
|
||||
|
||||
Rate limit headers are included on MISS responses:
|
||||
- `X-RateLimit-Limit`: Maximum requests (10)
|
||||
- `X-RateLimit-Remaining`: Remaining requests
|
||||
- `X-RateLimit-Reset`: Seconds until reset
|
||||
|
||||
---
|
||||
|
||||
## Scope Management
|
||||
|
||||
Scopes organize live URL generation budgets. All scope endpoints require Project Key authentication.
|
||||
|
||||
### Create Scope
|
||||
|
||||
```
|
||||
POST /api/v1/live/scopes
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `slug` | string | Yes | - | Unique identifier |
|
||||
| `allowNewGenerations` | boolean | No | `true` | Allow new generations |
|
||||
| `newGenerationsLimit` | number | No | `30` | Max generations in scope |
|
||||
| `meta` | object | No | `{}` | Custom metadata |
|
||||
|
||||
**Example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "hero-section",
|
||||
"allowNewGenerations": true,
|
||||
"newGenerationsLimit": 50,
|
||||
"meta": { "description": "Hero section images" }
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "scope-123",
|
||||
"projectId": "project-456",
|
||||
"slug": "hero-section",
|
||||
"allowNewGenerations": true,
|
||||
"newGenerationsLimit": 50,
|
||||
"currentGenerations": 0,
|
||||
"lastGeneratedAt": null,
|
||||
"meta": { "description": "Hero section images" },
|
||||
"createdAt": "2025-11-28T10:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Lazy Scope Creation
|
||||
|
||||
Scopes are auto-created on first live URL request if `project.allowNewLiveScopes = true`:
|
||||
|
||||
```
|
||||
GET /cdn/acme/website/live/new-scope?prompt=...
|
||||
// Creates "new-scope" with default settings
|
||||
```
|
||||
|
||||
### List Scopes
|
||||
|
||||
```
|
||||
GET /api/v1/live/scopes
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `slug` | string | - | Filter by exact slug |
|
||||
| `limit` | number | `20` | Results per page (max: 100) |
|
||||
| `offset` | number | `0` | Pagination offset |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "scope-123",
|
||||
"slug": "hero-section",
|
||||
"allowNewGenerations": true,
|
||||
"newGenerationsLimit": 50,
|
||||
"currentGenerations": 12,
|
||||
"lastGeneratedAt": "2025-11-28T09:30:00.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "limit": 20, "offset": 0, "total": 3, "hasMore": false }
|
||||
}
|
||||
```
|
||||
|
||||
### Get Scope
|
||||
|
||||
```
|
||||
GET /api/v1/live/scopes/:slug
|
||||
```
|
||||
|
||||
Returns scope with statistics (currentGenerations, lastGeneratedAt).
|
||||
|
||||
### Update Scope
|
||||
|
||||
```
|
||||
PUT /api/v1/live/scopes/:slug
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"allowNewGenerations": false,
|
||||
"newGenerationsLimit": 100,
|
||||
"meta": { "description": "Updated" }
|
||||
}
|
||||
```
|
||||
|
||||
Changes take effect immediately for new requests.
|
||||
|
||||
### Regenerate Scope Images
|
||||
|
||||
```
|
||||
POST /api/v1/live/scopes/:slug/regenerate
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `imageId` | string | Specific image UUID (optional) |
|
||||
|
||||
**Behavior:**
|
||||
- If `imageId` provided: Regenerate only that image
|
||||
- If `imageId` omitted: Regenerate all images in scope
|
||||
|
||||
Images are regenerated with exact same parameters. IDs and URLs are preserved.
|
||||
|
||||
### Delete Scope
|
||||
|
||||
```
|
||||
DELETE /api/v1/live/scopes/:slug
|
||||
```
|
||||
|
||||
**Cascade Behavior:**
|
||||
- Scope record is **hard deleted**
|
||||
- All images in scope are **hard deleted** (with MinIO cleanup)
|
||||
- Follows alias protection rules (aliased images may be kept)
|
||||
|
||||
> **Warning:** This permanently deletes all cached images in the scope.
|
||||
|
||||
---
|
||||
|
||||
## Scope Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| `slug` | string | - | Unique identifier within project |
|
||||
| `allowNewGenerations` | boolean | `true` | Whether new generations are allowed |
|
||||
| `newGenerationsLimit` | number | `30` | Maximum generations in scope |
|
||||
|
||||
When `allowNewGenerations: false`:
|
||||
- Cache HITs still work
|
||||
- New prompts return 403 error
|
||||
|
||||
When `newGenerationsLimit` reached:
|
||||
- Cache HITs still work
|
||||
- New prompts return 429 error
|
||||
|
||||
---
|
||||
|
||||
## Authenticated Live Endpoint
|
||||
|
||||
```
|
||||
GET /api/v1/live?prompt=...
|
||||
```
|
||||
|
||||
**Authentication:** Project Key required
|
||||
|
||||
Alternative to CDN endpoint with prompt caching by hash.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `prompt` | string | Yes | Image description |
|
||||
|
||||
**Cache Behavior:**
|
||||
- Cache key: SHA-256 hash of prompt
|
||||
- Cache stored in `prompt_url_cache` table
|
||||
- Tracks hit count and last access
|
||||
|
||||
**Response Headers:**
|
||||
- `X-Cache-Status`: `HIT` or `MISS`
|
||||
- `X-Cache-Hit-Count`: Number of cache hits (on HIT)
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
| HTTP Status | Code | Description |
|
||||
|-------------|------|-------------|
|
||||
| 400 | `SCOPE_INVALID_FORMAT` | Invalid scope slug format |
|
||||
| 403 | `SCOPE_CREATION_DISABLED` | New scope creation not allowed |
|
||||
| 404 | `ORG_NOT_FOUND` | Organization not found |
|
||||
| 404 | `PROJECT_NOT_FOUND` | Project not found |
|
||||
| 404 | `SCOPE_NOT_FOUND` | Scope does not exist |
|
||||
| 409 | `SCOPE_ALREADY_EXISTS` | Scope slug already in use |
|
||||
| 429 | `IP_RATE_LIMIT_EXCEEDED` | IP rate limit (10/hour) exceeded |
|
||||
| 429 | `SCOPE_GENERATION_LIMIT_EXCEEDED` | Scope limit reached |
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Dynamic Hero Images
|
||||
|
||||
```html
|
||||
<img src="/cdn/acme/website/live/hero?prompt=professional+office+workspace&aspectRatio=16:9" />
|
||||
```
|
||||
|
||||
First load generates, subsequent loads are cached.
|
||||
|
||||
### Product Placeholders
|
||||
|
||||
```html
|
||||
<img src="/cdn/acme/store/live/products?prompt=product+placeholder+gray+box&aspectRatio=1:1" />
|
||||
```
|
||||
|
||||
### Blog Post Images
|
||||
|
||||
```html
|
||||
<img src="/cdn/acme/blog/live/posts?prompt=abstract+technology+background&aspectRatio=16:9&template=illustration" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Basic Generation](image-generation.md) - API-based generation
|
||||
- [Advanced Generation](image-generation-advanced.md) - References, aliases, flows
|
||||
- [Image Upload](images-upload.md) - Upload and manage images
|
||||
|
|
@ -43,7 +43,6 @@ export const images = pgTable(
|
|||
{ onDelete: 'set null' },
|
||||
),
|
||||
flowId: uuid('flow_id').references(() => flows.id, { onDelete: 'cascade' }),
|
||||
pendingFlowId: text('pending_flow_id'), // Temporary UUID for lazy flow pattern
|
||||
apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
|
||||
|
||||
// Storage (MinIO path format: orgSlug/projectSlug/category/YYYY-MM/filename.ext)
|
||||
|
|
@ -120,11 +119,6 @@ export const images = pgTable(
|
|||
.on(table.flowId)
|
||||
.where(sql`${table.flowId} IS NOT NULL`),
|
||||
|
||||
// Index for pending flow lookups (lazy pattern)
|
||||
pendingFlowIdx: index('idx_images_pending_flow')
|
||||
.on(table.pendingFlowId, table.createdAt.desc())
|
||||
.where(sql`${table.pendingFlowId} IS NOT NULL`),
|
||||
|
||||
// Index for generation lookup
|
||||
generationIdx: index('idx_images_generation').on(table.generationId),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// tests/api/01-generation-basic.ts
|
||||
// Basic Image Generation Tests - Run FIRST to verify core functionality
|
||||
|
||||
import { api, log, runTest, saveImage, waitForGeneration, testContext, verifyImageAccessible, exitWithTestResults } from './utils';
|
||||
import { api, log, runTest, saveImage, waitForGeneration, testContext, verifyImageAccessible } from './utils';
|
||||
import { endpoints } from './config';
|
||||
|
||||
async function main() {
|
||||
|
|
@ -202,9 +202,4 @@ async function main() {
|
|||
log.section('GENERATION BASIC TESTS COMPLETED');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => exitWithTestResults())
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch(console.error);
|
||||
|
|
|
|||
|
|
@ -1,332 +0,0 @@
|
|||
@base = http://localhost:3000
|
||||
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||
|
||||
###############################################################################
|
||||
# IMAGE UPLOAD & CRUD TESTS
|
||||
# Tests: Upload, list, filter, pagination, metadata updates, alias management
|
||||
###############################################################################
|
||||
|
||||
### Test 1.1: Upload image with project-scoped alias
|
||||
# @name uploadWithAlias
|
||||
POST {{base}}/api/v1/images/upload
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="test-image2.png"
|
||||
Content-Type: image/png
|
||||
|
||||
< ./fixture/test-image.png
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="alias"
|
||||
|
||||
@test-logo
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="description"
|
||||
|
||||
Test logo image for CRUD tests
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||
|
||||
###
|
||||
|
||||
@uploadedImageId = {{uploadWithAlias.response.body.$.data.id}}
|
||||
@uploadedImageAlias = {{uploadWithAlias.response.body.$.data.alias}}
|
||||
@uploadedImageSource = {{uploadWithAlias.response.body.$.data.source}}
|
||||
|
||||
### Test 1.2: Verify uploaded image details
|
||||
# Expected: alias = @test-logo, source = uploaded
|
||||
GET {{base}}/api/v1/images/{{uploadedImageId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 2.1: Upload image without alias
|
||||
# @name uploadWithoutAlias
|
||||
POST {{base}}/api/v1/images/upload
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="test-image.png"
|
||||
Content-Type: image/png
|
||||
|
||||
< ./fixture/test-image.png
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="description"
|
||||
|
||||
Image without alias
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||
|
||||
###
|
||||
|
||||
@uploadedImageId2 = {{uploadWithoutAlias.response.body.$.data.id}}
|
||||
|
||||
### Test 2.2: Verify image has no alias
|
||||
# Expected: alias = null
|
||||
GET {{base}}/api/v1/images/{{uploadedImageId2}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 3: List all images
|
||||
# Expected: Returns array with pagination
|
||||
GET {{base}}/api/v1/images
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 4: List images - filter by source=uploaded
|
||||
# Expected: All results have source="uploaded"
|
||||
GET {{base}}/api/v1/images?source=uploaded
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 5: List images with pagination
|
||||
# Expected: limit=3, offset=0, hasMore=true/false
|
||||
GET {{base}}/api/v1/images?limit=3&offset=0
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 6: Get image by ID
|
||||
# Expected: Returns full image details
|
||||
GET {{base}}/api/v1/images/{{uploadedImageId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 7: Resolve project-scoped alias
|
||||
# Expected: Resolves to uploadedImageId (Section 6.2: direct alias support)
|
||||
GET {{base}}/api/v1/images/@test-logo
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 8.1: Update image metadata (focal point + meta)
|
||||
# @name updateMetadata
|
||||
PUT {{base}}/api/v1/images/{{uploadedImageId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"focalPoint": {
|
||||
"x": 0.5,
|
||||
"y": 0.3
|
||||
},
|
||||
"meta": {
|
||||
"description": "Updated description",
|
||||
"tags": ["test", "logo", "updated"]
|
||||
}
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Test 8.2: Verify metadata update
|
||||
# Expected: focalPoint x=0.5, y=0.3, meta has tags
|
||||
GET {{base}}/api/v1/images/{{uploadedImageId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 9.1: Update image alias (dedicated endpoint)
|
||||
# @name updateAlias
|
||||
PUT {{base}}/api/v1/images/{{uploadedImageId}}/alias
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"alias": "@new-test-logo"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Test 9.2: Verify new alias works
|
||||
# Expected: Resolves to same uploadedImageId (Section 6.2: direct alias support)
|
||||
GET {{base}}/api/v1/images/@new-test-logo
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 10: Verify old alias doesn't work after update
|
||||
# Expected: 404 - Alias not found
|
||||
GET {{base}}/api/v1/images/@test-logo
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 11.1: Remove image alias
|
||||
# @name removeAlias
|
||||
PUT {{base}}/api/v1/images/{{uploadedImageId}}/alias
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"alias": null
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Test 11.2: Verify image exists but has no alias
|
||||
# Expected: alias = null
|
||||
GET {{base}}/api/v1/images/{{uploadedImageId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 11.3: Verify alias resolution fails
|
||||
# Expected: 404 - Alias not found
|
||||
GET {{base}}/api/v1/images/@new-test-logo
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 12.1: Reassign alias for reference image test
|
||||
# @name reassignAlias
|
||||
PUT {{base}}/api/v1/images/{{uploadedImageId}}/alias
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"alias": "@reference-logo"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Test 12.2: Generate with manual reference image
|
||||
# @name genWithReference
|
||||
POST {{base}}/api/v1/generations
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"prompt": "A product photo with the logo in corner",
|
||||
"aspectRatio": "1:1",
|
||||
"referenceImages": ["@reference-logo"]
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@genWithReferenceId = {{genWithReference.response.body.$.data.id}}
|
||||
|
||||
### Test 12.3: Poll generation status
|
||||
# Run this multiple times until status = success
|
||||
GET {{base}}/api/v1/generations/{{genWithReferenceId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 12.4: Verify referenced images tracked
|
||||
# Expected: referencedImages array contains @reference-logo
|
||||
GET {{base}}/api/v1/generations/{{genWithReferenceId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 13.1: Generate with auto-detected reference in prompt
|
||||
# @name genAutoDetect
|
||||
POST {{base}}/api/v1/generations
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"prompt": "Create banner using @reference-logo with blue background",
|
||||
"aspectRatio": "16:9"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@genAutoDetectId = {{genAutoDetect.response.body.$.data.id}}
|
||||
|
||||
### Test 13.2: Poll until complete
|
||||
GET {{base}}/api/v1/generations/{{genAutoDetectId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 13.3: Verify auto-detection worked
|
||||
# Expected: referencedImages contains @reference-logo
|
||||
GET {{base}}/api/v1/generations/{{genAutoDetectId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 14.1: Generate with project alias assignment
|
||||
# @name genWithAlias
|
||||
POST {{base}}/api/v1/generations
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"prompt": "A hero banner image",
|
||||
"aspectRatio": "21:9",
|
||||
"alias": "@hero-banner"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@genWithAliasId = {{genWithAlias.response.body.$.data.id}}
|
||||
|
||||
### Test 14.2: Poll until complete
|
||||
GET {{base}}/api/v1/generations/{{genWithAliasId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@heroImageId = {{genWithAlias.response.body.$.data.outputImageId}}
|
||||
|
||||
### Test 14.3: Verify alias assigned to output image
|
||||
# Expected: alias = @hero-banner
|
||||
GET {{base}}/api/v1/images/{{heroImageId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 14.4: Verify alias resolution works
|
||||
# Expected: Resolves to heroImageId (Section 6.2: direct alias support)
|
||||
GET {{base}}/api/v1/images/@hero-banner
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 15.1: Alias conflict - create second generation with same alias
|
||||
# @name genConflict
|
||||
POST {{base}}/api/v1/generations
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"prompt": "A different hero image",
|
||||
"aspectRatio": "21:9",
|
||||
"alias": "@hero-banner"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@genConflictId = {{genConflict.response.body.$.data.id}}
|
||||
|
||||
### Test 15.2: Poll until complete
|
||||
GET {{base}}/api/v1/generations/{{genConflictId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@secondHeroImageId = {{genConflict.response.body.$.data.outputImageId}}
|
||||
|
||||
### Test 15.3: Verify second image has the alias
|
||||
# Expected: Resolves to secondHeroImageId (not heroImageId) (Section 6.2: direct alias support)
|
||||
GET {{base}}/api/v1/images/@hero-banner
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Test 15.4: Verify first image lost the alias but still exists
|
||||
# Expected: alias = null, image still exists
|
||||
GET {{base}}/api/v1/images/{{heroImageId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
###############################################################################
|
||||
# END OF IMAGE UPLOAD & CRUD TESTS
|
||||
###############################################################################
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// Image Upload and CRUD Operations
|
||||
|
||||
import { join } from 'path';
|
||||
import { api, log, runTest, saveImage, uploadFile, waitForGeneration, testContext, verifyImageAccessible, resolveAlias, exitWithTestResults } from './utils';
|
||||
import { api, log, runTest, saveImage, uploadFile, waitForGeneration, testContext, verifyImageAccessible, resolveAlias } from './utils';
|
||||
import { config, endpoints } from './config';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
|
|
@ -420,9 +420,4 @@ async function main() {
|
|||
log.section('IMAGE UPLOAD & CRUD TESTS COMPLETED');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => exitWithTestResults())
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch(console.error);
|
||||
|
|
|
|||
|
|
@ -1,296 +0,0 @@
|
|||
@base = http://localhost:3000
|
||||
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||
|
||||
###############################################################################
|
||||
# FLOW LIFECYCLE TESTS
|
||||
# Tests: Lazy flow creation, Eager flow creation, Flow operations
|
||||
#
|
||||
# Test Coverage:
|
||||
# 1. Lazy flow pattern - first generation without flowId
|
||||
# 2. Lazy flow - verify flow not created yet
|
||||
# 3. Lazy flow - second generation creates flow
|
||||
# 4. Eager flow creation with flowAlias
|
||||
# 5. List all flows
|
||||
# 6. Get flow with computed counts
|
||||
# 7. List flow generations
|
||||
# 8. List flow images
|
||||
# 9. Update flow aliases
|
||||
# 10. Remove specific flow alias
|
||||
# 11. Regenerate flow
|
||||
###############################################################################
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 1: Lazy Flow Pattern - First Generation
|
||||
# Generation without flowId should return auto-generated flowId
|
||||
# but NOT create flow in database yet (Section 4.1)
|
||||
###############################################################################
|
||||
|
||||
### Step 1.1: Create Generation without flowId
|
||||
# @name lazyFlowGen1
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "A red sports car on a mountain road",
|
||||
"aspectRatio": "16:9"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@lazyFlowId = {{lazyFlowGen1.response.body.$.data.flowId}}
|
||||
@lazyGenId1 = {{lazyFlowGen1.response.body.$.data.id}}
|
||||
|
||||
### Step 1.2: Poll Generation Status
|
||||
# @name checkLazyGen1
|
||||
GET {{base}}/api/v1/generations/{{lazyGenId1}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - flowId is returned (auto-generated UUID)
|
||||
# - status = "success"
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 2: Verify Lazy Flow Not Created Yet
|
||||
# Flow should NOT exist in database after first generation
|
||||
###############################################################################
|
||||
|
||||
### Step 2.1: Try to get flow (should return 404)
|
||||
# @name checkLazyFlowNotExists
|
||||
GET {{base}}/api/v1/flows/{{lazyFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 404 Not Found
|
||||
# Flow record not created yet (lazy creation pattern)
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 3: Lazy Flow - Second Generation Creates Flow
|
||||
# Using same flowId should create the flow record
|
||||
###############################################################################
|
||||
|
||||
### Step 3.1: Create second generation with same flowId
|
||||
# @name lazyFlowGen2
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Same car but blue color",
|
||||
"aspectRatio": "16:9",
|
||||
"flowId": "{{lazyFlowId}}"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@lazyGenId2 = {{lazyFlowGen2.response.body.$.data.id}}
|
||||
|
||||
### Step 3.2: Poll Generation Status
|
||||
# @name checkLazyGen2
|
||||
GET {{base}}/api/v1/generations/{{lazyGenId2}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Step 3.3: Verify flow now exists
|
||||
# @name verifyLazyFlowExists
|
||||
GET {{base}}/api/v1/flows/{{lazyFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 200 OK
|
||||
# Flow record now exists after second use
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 4: Eager Flow Creation with flowAlias
|
||||
# Using flowAlias should create flow immediately
|
||||
###############################################################################
|
||||
|
||||
### Step 4.1: Create generation with flowAlias
|
||||
# @name eagerFlowGen
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "A hero banner image",
|
||||
"aspectRatio": "21:9",
|
||||
"flowAlias": "@hero-flow"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@eagerFlowId = {{eagerFlowGen.response.body.$.data.flowId}}
|
||||
@eagerGenId = {{eagerFlowGen.response.body.$.data.id}}
|
||||
|
||||
### Step 4.2: Poll Generation Status
|
||||
# @name checkEagerGen
|
||||
GET {{base}}/api/v1/generations/{{eagerGenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Step 4.3: Verify flow exists immediately (eager creation)
|
||||
# @name verifyEagerFlowExists
|
||||
GET {{base}}/api/v1/flows/{{eagerFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Flow exists immediately
|
||||
# - aliases contains "@hero-flow"
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 5: List All Flows
|
||||
###############################################################################
|
||||
|
||||
### Step 5.1: List flows
|
||||
# @name listFlows
|
||||
GET {{base}}/api/v1/flows
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns array of flows
|
||||
# - Contains our lazyFlowId and eagerFlowId
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 6: Get Flow with Computed Counts
|
||||
###############################################################################
|
||||
|
||||
### Step 6.1: Get flow details
|
||||
# @name getFlowDetails
|
||||
GET {{base}}/api/v1/flows/{{lazyFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - generationCount is number (should be 2)
|
||||
# - imageCount is number (should be 2)
|
||||
# - aliases object present
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 7: List Flow Generations
|
||||
###############################################################################
|
||||
|
||||
### Step 7.1: Get flow's generations
|
||||
# @name getFlowGenerations
|
||||
GET {{base}}/api/v1/flows/{{lazyFlowId}}/generations
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns array of generations
|
||||
# - Contains 2 generations from lazy flow tests
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 8: List Flow Images
|
||||
###############################################################################
|
||||
|
||||
### Step 8.1: Get flow's images
|
||||
# @name getFlowImages
|
||||
GET {{base}}/api/v1/flows/{{lazyFlowId}}/images
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns array of images
|
||||
# - Contains output images from generations
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 9: Update Flow Aliases
|
||||
###############################################################################
|
||||
|
||||
### Step 9.1: Update flow aliases
|
||||
# @name updateFlowAliases
|
||||
PUT {{base}}/api/v1/flows/{{lazyFlowId}}/aliases
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"aliases": {
|
||||
"@latest": "{{checkLazyGen2.response.body.$.data.outputImageId}}",
|
||||
"@best": "{{checkLazyGen2.response.body.$.data.outputImageId}}"
|
||||
}
|
||||
}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns updated flow with new aliases
|
||||
# - aliases contains @latest and @best
|
||||
|
||||
|
||||
### Step 9.2: Verify aliases set
|
||||
# @name verifyAliasesSet
|
||||
GET {{base}}/api/v1/flows/{{lazyFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 10: Remove Specific Flow Alias
|
||||
###############################################################################
|
||||
|
||||
### Step 10.1: Delete @best alias
|
||||
# @name deleteFlowAlias
|
||||
DELETE {{base}}/api/v1/flows/{{lazyFlowId}}/aliases/@best
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Step 10.2: Verify alias removed
|
||||
# @name verifyAliasRemoved
|
||||
GET {{base}}/api/v1/flows/{{lazyFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - @best not in aliases
|
||||
# - @latest still in aliases
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 11: Regenerate Flow
|
||||
# Regenerates the most recent generation in a flow
|
||||
###############################################################################
|
||||
|
||||
### Step 11.1: Trigger regeneration
|
||||
# @name regenerateFlow
|
||||
POST {{base}}/api/v1/flows/{{lazyFlowId}}/regenerate
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns new generation object
|
||||
# - New generation is in the same flow
|
||||
|
||||
|
||||
###############################################################################
|
||||
# NOTES
|
||||
###############################################################################
|
||||
#
|
||||
# Lazy Flow Pattern (Section 4.1):
|
||||
# 1. First request without flowId -> return generated flowId, but DO NOT create in DB
|
||||
# 2. Any request with valid flowId -> create flow in DB if doesn't exist
|
||||
# 3. If flowAlias specified -> create flow immediately (eager creation)
|
||||
#
|
||||
# Flow Aliases:
|
||||
# - Stored in flow.aliases JSONB field
|
||||
# - Map alias names to image IDs
|
||||
# - Can be updated via PUT /flows/:id/aliases
|
||||
# - Individual aliases deleted via DELETE /flows/:id/aliases/:alias
|
||||
#
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// tests/api/03-flows.ts
|
||||
// Flow Lifecycle Tests - Lazy and Eager Creation Patterns
|
||||
|
||||
import { api, log, runTest, saveImage, waitForGeneration, testContext, resolveAlias, exitWithTestResults } from './utils';
|
||||
import { api, log, runTest, saveImage, waitForGeneration, testContext, resolveAlias } from './utils';
|
||||
import { endpoints } from './config';
|
||||
|
||||
async function main() {
|
||||
|
|
@ -34,15 +34,19 @@ async function main() {
|
|||
testContext.firstGenId = generation.id;
|
||||
});
|
||||
|
||||
// Test 2: Lazy flow - verify flow doesn't exist yet (Section 4.1)
|
||||
// Test 2: Lazy flow - verify flow doesn't exist yet
|
||||
await runTest('Lazy flow - verify flow not created yet', async () => {
|
||||
const result = await api(`${endpoints.flows}/${testContext.lazyFlowId}`, {
|
||||
expectError: true,
|
||||
});
|
||||
if (result.status !== 404) {
|
||||
try {
|
||||
await api(`${endpoints.flows}/${testContext.lazyFlowId}`, {
|
||||
expectError: true,
|
||||
});
|
||||
throw new Error('Flow should not exist yet (lazy creation)');
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('should not exist')) {
|
||||
throw error;
|
||||
}
|
||||
log.detail('Flow correctly does not exist', '✓');
|
||||
}
|
||||
log.detail('Flow correctly does not exist', '✓');
|
||||
});
|
||||
|
||||
// Test 3: Lazy flow - second use creates flow
|
||||
|
|
@ -241,9 +245,4 @@ async function main() {
|
|||
log.section('FLOW LIFECYCLE TESTS COMPLETED');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => exitWithTestResults())
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch(console.error);
|
||||
|
|
|
|||
|
|
@ -1,590 +0,0 @@
|
|||
@base = http://localhost:3000
|
||||
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||
|
||||
###############################################################################
|
||||
# ALIAS RESOLUTION TESTS
|
||||
# Tests: 3-Tier Alias Resolution (Technical -> Flow -> Project)
|
||||
#
|
||||
# Test Coverage:
|
||||
# 1. Technical alias @last
|
||||
# 2. Technical alias @first
|
||||
# 3. Technical alias @upload
|
||||
# 4. Technical alias requires flowId
|
||||
# 5. Flow-scoped alias resolution
|
||||
# 6. Project-scoped alias resolution
|
||||
# 7. Alias precedence (flow > project)
|
||||
# 8. Reserved aliases cannot be assigned
|
||||
# 9. Alias reassignment removes old
|
||||
# 10. Same alias in different flows
|
||||
# 11. Technical alias in generation prompt
|
||||
# 12. Upload with both project and flow alias
|
||||
###############################################################################
|
||||
|
||||
|
||||
###############################################################################
|
||||
# SETUP: Create Test Flow
|
||||
###############################################################################
|
||||
|
||||
### Setup: Create flow for alias tests
|
||||
# @name setupGen
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Setup image for alias tests",
|
||||
"aspectRatio": "1:1",
|
||||
"flowAlias": "@alias-test-flow"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@aliasFlowId = {{setupGen.response.body.$.data.flowId}}
|
||||
@setupGenId = {{setupGen.response.body.$.data.id}}
|
||||
|
||||
### Poll setup generation
|
||||
# @name checkSetupGen
|
||||
GET {{base}}/api/v1/generations/{{setupGenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@setupImageId = {{checkSetupGen.response.body.$.data.outputImageId}}
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 1: Technical Alias @last
|
||||
# Resolves to last generated image in flow
|
||||
###############################################################################
|
||||
|
||||
### Step 1.1: Resolve @last (requires flowId)
|
||||
# @name resolveLast
|
||||
GET {{base}}/api/v1/images/@last?flowId={{aliasFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns image (status 200)
|
||||
# - Returns the most recently generated image in the flow
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 2: Technical Alias @first
|
||||
# Resolves to first generated image in flow
|
||||
###############################################################################
|
||||
|
||||
### Step 2.1: Resolve @first (requires flowId)
|
||||
# @name resolveFirst
|
||||
GET {{base}}/api/v1/images/@first?flowId={{aliasFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns image (status 200)
|
||||
# - Returns the first generated image in the flow
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 3: Technical Alias @upload
|
||||
# Resolves to last uploaded image in flow
|
||||
###############################################################################
|
||||
|
||||
### Step 3.1: Upload image to flow
|
||||
# @name uploadForTest
|
||||
POST {{base}}/api/v1/images/upload
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="test-image.png"
|
||||
Content-Type: image/png
|
||||
|
||||
< ./fixture/test-image.png
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="flowId"
|
||||
|
||||
{{aliasFlowId}}
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="description"
|
||||
|
||||
Uploaded for @upload test
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||
|
||||
###
|
||||
|
||||
@uploadedImageId = {{uploadForTest.response.body.$.data.id}}
|
||||
|
||||
### Step 3.2: Resolve @upload (requires flowId)
|
||||
# @name resolveUpload
|
||||
GET {{base}}/api/v1/images/@upload?flowId={{aliasFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns image (status 200)
|
||||
# - Returns uploaded image (source = "uploaded")
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 4: Technical Alias Requires Flow Context
|
||||
# @last, @first, @upload require flowId parameter
|
||||
###############################################################################
|
||||
|
||||
### Step 4.1: Try @last without flowId (should fail)
|
||||
# @name resolveLastNoFlow
|
||||
GET {{base}}/api/v1/images/@last
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 404 with error "Technical aliases require flowId"
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 5: Flow-Scoped Alias Resolution
|
||||
###############################################################################
|
||||
|
||||
### Step 5.1: Create generation with flow alias
|
||||
# @name flowAliasGen
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Image for flow alias test",
|
||||
"aspectRatio": "1:1",
|
||||
"flowId": "{{aliasFlowId}}",
|
||||
"flowAlias": "@flow-hero"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@flowAliasGenId = {{flowAliasGen.response.body.$.data.id}}
|
||||
|
||||
### Step 5.2: Poll generation
|
||||
# @name checkFlowAliasGen
|
||||
GET {{base}}/api/v1/generations/{{flowAliasGenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@flowHeroImageId = {{checkFlowAliasGen.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 5.3: Resolve flow alias
|
||||
# @name resolveFlowAlias
|
||||
GET {{base}}/api/v1/images/@flow-hero?flowId={{aliasFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns the image from step 5.1
|
||||
# - Only works with flowId parameter
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 6: Project-Scoped Alias Resolution
|
||||
###############################################################################
|
||||
|
||||
### Step 6.1: Create generation with project alias
|
||||
# @name projectAliasGen
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Image for project alias test",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": "@project-logo",
|
||||
"flowId": null
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@projectAliasGenId = {{projectAliasGen.response.body.$.data.id}}
|
||||
|
||||
### Step 6.2: Poll generation
|
||||
# @name checkProjectAliasGen
|
||||
GET {{base}}/api/v1/generations/{{projectAliasGenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@projectLogoImageId = {{checkProjectAliasGen.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 6.3: Resolve project alias (no flowId needed)
|
||||
# @name resolveProjectAlias
|
||||
GET {{base}}/api/v1/images/@project-logo
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns the image from step 6.1
|
||||
# - Works without flowId parameter
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 7: Alias Precedence (Flow > Project)
|
||||
###############################################################################
|
||||
|
||||
### Step 7.1: Create project-scoped alias @priority-test
|
||||
# @name priorityProject
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Project scoped image for priority test",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": "@priority-test",
|
||||
"flowId": null
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@priorityProjectGenId = {{priorityProject.response.body.$.data.id}}
|
||||
|
||||
### Step 7.2: Poll generation
|
||||
# @name checkPriorityProject
|
||||
GET {{base}}/api/v1/generations/{{priorityProjectGenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@priorityProjectImageId = {{checkPriorityProject.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 7.3: Create flow-scoped alias @priority-test (same name)
|
||||
# @name priorityFlow
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Flow scoped image for priority test",
|
||||
"aspectRatio": "1:1",
|
||||
"flowId": "{{aliasFlowId}}",
|
||||
"flowAlias": "@priority-test"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@priorityFlowGenId = {{priorityFlow.response.body.$.data.id}}
|
||||
|
||||
### Step 7.4: Poll generation
|
||||
# @name checkPriorityFlow
|
||||
GET {{base}}/api/v1/generations/{{priorityFlowGenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@priorityFlowImageId = {{checkPriorityFlow.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 7.5: Resolve WITHOUT flowId (should get project)
|
||||
# @name resolvePriorityNoFlow
|
||||
GET {{base}}/api/v1/images/@priority-test
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify: Returns project image ({{priorityProjectImageId}})
|
||||
|
||||
### Step 7.6: Resolve WITH flowId (should get flow)
|
||||
# @name resolvePriorityWithFlow
|
||||
GET {{base}}/api/v1/images/@priority-test?flowId={{aliasFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify: Returns flow image ({{priorityFlowImageId}})
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 8: Reserved Aliases Cannot Be Assigned
|
||||
###############################################################################
|
||||
|
||||
### Step 8.1: Try to use @last as alias (should fail or warn)
|
||||
# @name reservedLast
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test reserved alias",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": "@last"
|
||||
}
|
||||
|
||||
###
|
||||
# Expected: 400 validation error OR generation succeeds but @last not assigned
|
||||
|
||||
### Step 8.2: Try to use @first as alias
|
||||
# @name reservedFirst
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test reserved alias",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": "@first"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Step 8.3: Try to use @upload as alias
|
||||
# @name reservedUpload
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test reserved alias",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": "@upload"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 9: Alias Reassignment (Override Behavior)
|
||||
###############################################################################
|
||||
|
||||
### Step 9.1: Create first image with alias
|
||||
# @name reassign1
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "First image for reassign test",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": "@reassign-test",
|
||||
"flowId": null
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@reassign1GenId = {{reassign1.response.body.$.data.id}}
|
||||
|
||||
### Step 9.2: Poll first generation
|
||||
# @name checkReassign1
|
||||
GET {{base}}/api/v1/generations/{{reassign1GenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@reassign1ImageId = {{checkReassign1.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 9.3: Create second image with SAME alias
|
||||
# @name reassign2
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Second image for reassign test",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": "@reassign-test",
|
||||
"flowId": null
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@reassign2GenId = {{reassign2.response.body.$.data.id}}
|
||||
|
||||
### Step 9.4: Poll second generation
|
||||
# @name checkReassign2
|
||||
GET {{base}}/api/v1/generations/{{reassign2GenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@reassign2ImageId = {{checkReassign2.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 9.5: Resolve alias (should be second image)
|
||||
# @name resolveReassign
|
||||
GET {{base}}/api/v1/images/@reassign-test
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify: Returns second image ({{reassign2ImageId}})
|
||||
|
||||
### Step 9.6: Check first image lost alias
|
||||
# @name checkFirstLostAlias
|
||||
GET {{base}}/api/v1/images/{{reassign1ImageId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify: alias = null
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 10: Same Alias in Different Flows
|
||||
###############################################################################
|
||||
|
||||
### Step 10.1: Create flow 1 with @shared-name alias
|
||||
# @name sharedFlow1
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Flow 1 image with shared name",
|
||||
"aspectRatio": "1:1",
|
||||
"flowAlias": "@shared-name"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@sharedFlow1Id = {{sharedFlow1.response.body.$.data.flowId}}
|
||||
@sharedGen1Id = {{sharedFlow1.response.body.$.data.id}}
|
||||
|
||||
### Step 10.2: Poll generation 1
|
||||
# @name checkSharedGen1
|
||||
GET {{base}}/api/v1/generations/{{sharedGen1Id}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@sharedImage1Id = {{checkSharedGen1.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 10.3: Create flow 2 with SAME @shared-name alias
|
||||
# @name sharedFlow2
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Flow 2 image with shared name",
|
||||
"aspectRatio": "1:1",
|
||||
"flowAlias": "@shared-name"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@sharedFlow2Id = {{sharedFlow2.response.body.$.data.flowId}}
|
||||
@sharedGen2Id = {{sharedFlow2.response.body.$.data.id}}
|
||||
|
||||
### Step 10.4: Poll generation 2
|
||||
# @name checkSharedGen2
|
||||
GET {{base}}/api/v1/generations/{{sharedGen2Id}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@sharedImage2Id = {{checkSharedGen2.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 10.5: Resolve @shared-name in flow 1
|
||||
# @name resolveSharedFlow1
|
||||
GET {{base}}/api/v1/images/@shared-name?flowId={{sharedFlow1Id}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify: Returns {{sharedImage1Id}}
|
||||
|
||||
### Step 10.6: Resolve @shared-name in flow 2
|
||||
# @name resolveSharedFlow2
|
||||
GET {{base}}/api/v1/images/@shared-name?flowId={{sharedFlow2Id}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify: Returns {{sharedImage2Id}} (different from flow 1)
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 11: Technical Alias in Generation Prompt
|
||||
###############################################################################
|
||||
|
||||
### Step 11.1: Generate using @last in prompt
|
||||
# @name techAliasPrompt
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "New variation based on @last",
|
||||
"aspectRatio": "1:1",
|
||||
"flowId": "{{aliasFlowId}}"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@techAliasGenId = {{techAliasPrompt.response.body.$.data.id}}
|
||||
|
||||
### Step 11.2: Poll generation
|
||||
# @name checkTechAliasGen
|
||||
GET {{base}}/api/v1/generations/{{techAliasGenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - status = "success"
|
||||
# - referencedImages contains @last alias
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 12: Upload with Both Project and Flow Alias
|
||||
###############################################################################
|
||||
|
||||
### Step 12.1: Upload with both aliases
|
||||
# @name dualAliasUpload
|
||||
POST {{base}}/api/v1/images/upload
|
||||
X-API-Key: {{apiKey}}
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="test-image.png"
|
||||
Content-Type: image/png
|
||||
|
||||
< ./fixture/test-image.png
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="alias"
|
||||
|
||||
@dual-project
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="flowId"
|
||||
|
||||
{{aliasFlowId}}
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="flowAlias"
|
||||
|
||||
@dual-flow
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||
|
||||
###
|
||||
|
||||
@dualAliasImageId = {{dualAliasUpload.response.body.$.data.id}}
|
||||
|
||||
### Step 12.2: Resolve project alias
|
||||
# @name resolveDualProject
|
||||
GET {{base}}/api/v1/images/@dual-project
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify: Returns {{dualAliasImageId}}
|
||||
|
||||
### Step 12.3: Resolve flow alias
|
||||
# @name resolveDualFlow
|
||||
GET {{base}}/api/v1/images/@dual-flow?flowId={{aliasFlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify: Returns {{dualAliasImageId}} (same image)
|
||||
|
||||
|
||||
###############################################################################
|
||||
# NOTES
|
||||
###############################################################################
|
||||
#
|
||||
# 3-Tier Alias Resolution Order:
|
||||
# 1. Technical (@last, @first, @upload) - require flowId
|
||||
# 2. Flow-scoped (stored in flow.aliases) - require flowId
|
||||
# 3. Project-scoped (stored in images.alias) - no flowId needed
|
||||
#
|
||||
# Alias Format:
|
||||
# - Must start with @
|
||||
# - Alphanumeric + hyphens only
|
||||
# - Reserved: @last, @first, @upload
|
||||
#
|
||||
# Override Behavior (Section 5.2):
|
||||
# - New alias assignment takes priority
|
||||
# - Previous image loses its alias
|
||||
# - Previous image is NOT deleted
|
||||
#
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// 3-Tier Alias Resolution System Tests
|
||||
|
||||
import { join } from 'path';
|
||||
import { api, log, runTest, uploadFile, waitForGeneration, testContext, resolveAlias, createTestImage, exitWithTestResults } from './utils';
|
||||
import { api, log, runTest, uploadFile, waitForGeneration, testContext, resolveAlias, createTestImage } from './utils';
|
||||
import { config, endpoints } from './config';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
|
|
@ -275,9 +275,4 @@ async function main() {
|
|||
log.section('ALIAS RESOLUTION TESTS COMPLETED');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => exitWithTestResults())
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch(console.error);
|
||||
|
|
|
|||
|
|
@ -1,217 +0,0 @@
|
|||
@base = http://localhost:3000
|
||||
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||
|
||||
###############################################################################
|
||||
# LIVE URL & SCOPE MANAGEMENT TESTS
|
||||
# Tests: Live generation with caching, Scope management
|
||||
#
|
||||
# Test Coverage:
|
||||
# 1. Create live scope
|
||||
# 2. List all scopes
|
||||
# 3. Get scope details
|
||||
# 4. Update scope settings
|
||||
# 5. Live URL - basic generation
|
||||
# 6. Regenerate scope images
|
||||
# 7. Delete scope
|
||||
###############################################################################
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 1: Create Live Scope
|
||||
###############################################################################
|
||||
|
||||
### Step 1.1: Create scope
|
||||
# @name createScope
|
||||
POST {{base}}/api/v1/live/scopes
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"slug": "test-scope",
|
||||
"allowNewGenerations": true,
|
||||
"newGenerationsLimit": 50
|
||||
}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns scope object
|
||||
# - slug = "test-scope"
|
||||
# - allowNewGenerations = true
|
||||
# - newGenerationsLimit = 50
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 2: List All Scopes
|
||||
###############################################################################
|
||||
|
||||
### Step 2.1: List scopes
|
||||
# @name listScopes
|
||||
GET {{base}}/api/v1/live/scopes
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns array of scopes
|
||||
# - Contains "test-scope"
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 3: Get Scope Details
|
||||
###############################################################################
|
||||
|
||||
### Step 3.1: Get scope by slug
|
||||
# @name getScope
|
||||
GET {{base}}/api/v1/live/scopes/test-scope
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns scope object
|
||||
# - slug = "test-scope"
|
||||
# - currentGenerations is number
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 4: Update Scope Settings
|
||||
###############################################################################
|
||||
|
||||
### Step 4.1: Disable new generations
|
||||
# @name updateScopeDisable
|
||||
PUT {{base}}/api/v1/live/scopes/test-scope
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"allowNewGenerations": false,
|
||||
"newGenerationsLimit": 100
|
||||
}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - allowNewGenerations = false
|
||||
# - newGenerationsLimit = 100
|
||||
|
||||
|
||||
### Step 4.2: Re-enable for testing
|
||||
# @name updateScopeEnable
|
||||
PUT {{base}}/api/v1/live/scopes/test-scope
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"allowNewGenerations": true
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 5: Live URL - Basic Generation
|
||||
# GET /api/v1/live?prompt=...
|
||||
# Returns image bytes directly with cache headers
|
||||
###############################################################################
|
||||
|
||||
### Step 5.1: Generate via live URL
|
||||
# @name liveGenerate
|
||||
GET {{base}}/api/v1/live?prompt=A%20simple%20blue%20square%20on%20white%20background
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns 200
|
||||
# - Response is image bytes (Content-Type: image/*)
|
||||
# - X-Cache-Status header (HIT or MISS)
|
||||
|
||||
|
||||
### Step 5.2: Same prompt again (should be cached)
|
||||
# @name liveGenerateCached
|
||||
GET {{base}}/api/v1/live?prompt=A%20simple%20blue%20square%20on%20white%20background
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - X-Cache-Status: HIT
|
||||
# - Faster response time
|
||||
|
||||
|
||||
### Step 5.3: Different prompt
|
||||
# @name liveGenerateNew
|
||||
GET {{base}}/api/v1/live?prompt=A%20red%20circle%20on%20black%20background
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - X-Cache-Status: MISS (new prompt)
|
||||
|
||||
|
||||
### Step 5.4: With aspect ratio
|
||||
# @name liveGenerateWithAspect
|
||||
GET {{base}}/api/v1/live?prompt=A%20landscape%20scene&aspectRatio=16:9
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 6: Regenerate Scope Images
|
||||
###############################################################################
|
||||
|
||||
### Step 6.1: Trigger regeneration
|
||||
# @name regenerateScope
|
||||
POST {{base}}/api/v1/live/scopes/test-scope/regenerate
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns 200
|
||||
# - Regeneration triggered
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 7: Delete Scope
|
||||
###############################################################################
|
||||
|
||||
### Step 7.1: Delete scope
|
||||
# @name deleteScope
|
||||
DELETE {{base}}/api/v1/live/scopes/test-scope
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns 200
|
||||
|
||||
|
||||
### Step 7.2: Verify deleted (should 404)
|
||||
# @name verifyScopeDeleted
|
||||
GET {{base}}/api/v1/live/scopes/test-scope
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 404 Not Found
|
||||
|
||||
|
||||
###############################################################################
|
||||
# NOTES
|
||||
###############################################################################
|
||||
#
|
||||
# Live URL Endpoint:
|
||||
# - GET /api/v1/live?prompt=...
|
||||
# - Returns image bytes directly (not JSON)
|
||||
# - Supports prompt caching via SHA-256 hash
|
||||
#
|
||||
# Response Headers:
|
||||
# - Content-Type: image/jpeg (or image/png, etc.)
|
||||
# - X-Cache-Status: HIT | MISS
|
||||
# - X-Cache-Hit-Count: number (on HIT)
|
||||
# - X-Generation-Id: UUID (on MISS)
|
||||
# - X-Image-Id: UUID
|
||||
# - Cache-Control: public, max-age=31536000
|
||||
#
|
||||
# Scope Management:
|
||||
# - Scopes group generations for management
|
||||
# - allowNewGenerations controls if new prompts generate
|
||||
# - newGenerationsLimit caps generations per scope
|
||||
#
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// tests/api/05-live.ts
|
||||
// Live URLs and Scope Management Tests
|
||||
|
||||
import { api, log, runTest, testContext, exitWithTestResults } from './utils';
|
||||
import { api, log, runTest, testContext } from './utils';
|
||||
import { endpoints } from './config';
|
||||
|
||||
async function main() {
|
||||
|
|
@ -91,9 +91,7 @@ async function main() {
|
|||
}),
|
||||
});
|
||||
|
||||
// Live endpoint requires prompt query parameter
|
||||
const testPrompt = encodeURIComponent('A simple blue square on white background');
|
||||
const result = await api(`${endpoints.live}?prompt=${testPrompt}`, {
|
||||
const result = await api(endpoints.live, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
|
|
@ -119,23 +117,21 @@ async function main() {
|
|||
method: 'DELETE',
|
||||
});
|
||||
|
||||
// Verify deleted - check for 404 status
|
||||
const result = await api(`${endpoints.live}/scopes/test-scope`, {
|
||||
expectError: true,
|
||||
});
|
||||
|
||||
if (result.status !== 404) {
|
||||
// Verify deleted
|
||||
try {
|
||||
await api(`${endpoints.live}/scopes/test-scope`, {
|
||||
expectError: true,
|
||||
});
|
||||
throw new Error('Scope should be deleted');
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('should be deleted')) {
|
||||
throw error;
|
||||
}
|
||||
log.detail('Scope deleted', '✓');
|
||||
}
|
||||
log.detail('Scope deleted', '✓');
|
||||
});
|
||||
|
||||
log.section('LIVE URL & SCOPE TESTS COMPLETED');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => exitWithTestResults())
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch(console.error);
|
||||
|
|
|
|||
|
|
@ -1,315 +0,0 @@
|
|||
@base = http://localhost:3000
|
||||
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||
|
||||
###############################################################################
|
||||
# EDGE CASES & VALIDATION TESTS
|
||||
# Tests: Input validation, Error handling, Edge cases
|
||||
#
|
||||
# Test Coverage:
|
||||
# 1. Invalid alias format
|
||||
# 2. Invalid aspect ratio
|
||||
# 3. Missing required fields
|
||||
# 4. 404 for non-existent resources
|
||||
# 5. Regenerate generation
|
||||
# 6. CDN endpoints
|
||||
###############################################################################
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 1: Invalid Alias Format
|
||||
# Aliases must start with @ and contain only alphanumeric + hyphens
|
||||
###############################################################################
|
||||
|
||||
### Step 1.1: Alias without @ symbol (should fail)
|
||||
# @name invalidNoAt
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test invalid alias",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": "no-at-symbol"
|
||||
}
|
||||
|
||||
###
|
||||
# Expected: 400 validation error OR 500 with alias error
|
||||
|
||||
### Step 1.2: Alias with spaces (should fail)
|
||||
# @name invalidWithSpaces
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test invalid alias",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": "@has spaces"
|
||||
}
|
||||
|
||||
###
|
||||
# Expected: 400 validation error
|
||||
|
||||
### Step 1.3: Alias with special characters (should fail)
|
||||
# @name invalidSpecialChars
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test invalid alias",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": "@special!chars"
|
||||
}
|
||||
|
||||
###
|
||||
# Expected: 400 validation error
|
||||
|
||||
### Step 1.4: Empty alias (should fail or be ignored)
|
||||
# @name invalidEmpty
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test invalid alias",
|
||||
"aspectRatio": "1:1",
|
||||
"alias": ""
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 2: Invalid Aspect Ratio
|
||||
###############################################################################
|
||||
|
||||
### Step 2.1: Invalid aspect ratio string
|
||||
# @name invalidAspectRatio
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test invalid aspect ratio",
|
||||
"aspectRatio": "invalid"
|
||||
}
|
||||
|
||||
###
|
||||
# Expected: 400 validation error
|
||||
|
||||
### Step 2.2: Unsupported aspect ratio
|
||||
# @name unsupportedAspectRatio
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test unsupported aspect ratio",
|
||||
"aspectRatio": "5:7"
|
||||
}
|
||||
|
||||
###
|
||||
# Expected: 400 validation error (only 1:1, 16:9, 9:16, 4:3, 3:4, 21:9 supported)
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 3: Missing Required Fields
|
||||
###############################################################################
|
||||
|
||||
### Step 3.1: Missing prompt
|
||||
# @name missingPrompt
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"aspectRatio": "1:1"
|
||||
}
|
||||
|
||||
###
|
||||
# Expected: 400 - "Prompt is required"
|
||||
|
||||
### Step 3.2: Empty body
|
||||
# @name emptyBody
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{}
|
||||
|
||||
###
|
||||
# Expected: 400 - "Prompt is required"
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 4: 404 for Non-Existent Resources
|
||||
###############################################################################
|
||||
|
||||
### Step 4.1: Non-existent image
|
||||
# @name notFoundImage
|
||||
GET {{base}}/api/v1/images/00000000-0000-0000-0000-000000000000
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 404 Not Found
|
||||
|
||||
### Step 4.2: Non-existent generation
|
||||
# @name notFoundGeneration
|
||||
GET {{base}}/api/v1/generations/00000000-0000-0000-0000-000000000000
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 404 Not Found
|
||||
|
||||
### Step 4.3: Non-existent flow
|
||||
# @name notFoundFlow
|
||||
GET {{base}}/api/v1/flows/00000000-0000-0000-0000-000000000000
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 404 Not Found
|
||||
|
||||
### Step 4.4: Non-existent alias
|
||||
# @name notFoundAlias
|
||||
GET {{base}}/api/v1/images/@non-existent-alias
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 404 - "Alias '@non-existent-alias' not found"
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 5: Regenerate Generation
|
||||
###############################################################################
|
||||
|
||||
### Step 5.1: Create generation for regenerate test
|
||||
# @name createForRegen
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test image for regenerate",
|
||||
"aspectRatio": "1:1"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@regenSourceId = {{createForRegen.response.body.$.data.id}}
|
||||
|
||||
### Step 5.2: Poll until success
|
||||
# @name checkForRegen
|
||||
GET {{base}}/api/v1/generations/{{regenSourceId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Step 5.3: Regenerate
|
||||
# @name regenerateGen
|
||||
POST {{base}}/api/v1/generations/{{regenSourceId}}/regenerate
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Returns new generation
|
||||
# - New generation has same prompt
|
||||
|
||||
### Step 5.4: Regenerate non-existent generation (should 404)
|
||||
# @name regenerateNotFound
|
||||
POST {{base}}/api/v1/generations/00000000-0000-0000-0000-000000000000/regenerate
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{}
|
||||
|
||||
###
|
||||
# Expected: 404 Not Found
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 6: CDN Endpoints
|
||||
###############################################################################
|
||||
|
||||
### Step 6.1: CDN image by path (if implemented)
|
||||
# @name cdnImage
|
||||
GET {{base}}/api/v1/cdn/default/test-project/generated/2024-01/test.jpg
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Note: Endpoint structure check only - actual paths depend on storage
|
||||
|
||||
### Step 6.2: Health check
|
||||
# @name healthCheck
|
||||
GET {{base}}/health
|
||||
|
||||
###
|
||||
# Expected: 200 with status info
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 7: Authentication Errors
|
||||
###############################################################################
|
||||
|
||||
### Step 7.1: Missing API key
|
||||
# @name noApiKey
|
||||
GET {{base}}/api/v1/generations
|
||||
|
||||
###
|
||||
# Expected: 401 Unauthorized
|
||||
|
||||
### Step 7.2: Invalid API key
|
||||
# @name invalidApiKey
|
||||
GET {{base}}/api/v1/generations
|
||||
X-API-Key: bnt_invalid_key_12345
|
||||
|
||||
###
|
||||
# Expected: 401 Unauthorized
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 8: Malformed Requests
|
||||
###############################################################################
|
||||
|
||||
### Step 8.1: Invalid JSON
|
||||
# @name invalidJson
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{invalid json}
|
||||
|
||||
###
|
||||
# Expected: 400 Bad Request
|
||||
|
||||
### Step 8.2: Wrong content type
|
||||
# @name wrongContentType
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: text/plain
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
prompt=test&aspectRatio=1:1
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# NOTES
|
||||
###############################################################################
|
||||
#
|
||||
# Validation Rules:
|
||||
# - Prompt: required, non-empty string
|
||||
# - Aspect ratio: must be supported (1:1, 16:9, 9:16, 4:3, 3:4, 21:9)
|
||||
# - Alias: must start with @, alphanumeric + hyphens only
|
||||
# - UUID: must be valid UUID format
|
||||
#
|
||||
# Error Responses:
|
||||
# - 400: Validation error (missing/invalid fields)
|
||||
# - 401: Authentication error (missing/invalid API key)
|
||||
# - 404: Resource not found
|
||||
# - 429: Rate limit exceeded
|
||||
# - 500: Internal server error
|
||||
#
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// Validation and Error Handling Tests
|
||||
|
||||
import { join } from 'path';
|
||||
import { api, log, runTest, testContext, uploadFile, exitWithTestResults } from './utils';
|
||||
import { api, log, runTest, testContext, uploadFile } from './utils';
|
||||
import { config, endpoints } from './config';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
|
|
@ -144,9 +144,4 @@ async function main() {
|
|||
log.section('EDGE CASES & VALIDATION TESTS COMPLETED');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => exitWithTestResults())
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch(console.error);
|
||||
|
|
|
|||
|
|
@ -1,259 +0,0 @@
|
|||
@base = http://localhost:3000
|
||||
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||
|
||||
###############################################################################
|
||||
# KNOWN ISSUES TESTS
|
||||
# These tests document known bugs and implementation gaps
|
||||
#
|
||||
# ⚠️ EXPECTED TO FAIL until issues are fixed
|
||||
#
|
||||
# Test Coverage:
|
||||
# 1. Project alias on flow image
|
||||
# 2. Flow delete cascades non-aliased images
|
||||
# 3. Flow delete preserves aliased images
|
||||
# 4. Flow delete cascades generations
|
||||
###############################################################################
|
||||
|
||||
|
||||
###############################################################################
|
||||
# ISSUE 1: Project Alias on Flow Image
|
||||
# An image in a flow should be able to have a project-scoped alias
|
||||
###############################################################################
|
||||
|
||||
### Step 1.1: Create image with both flow and project alias
|
||||
# @name issue1Gen
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Image in flow with project alias",
|
||||
"aspectRatio": "1:1",
|
||||
"flowAlias": "@flow-test",
|
||||
"alias": "@project-test"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@issue1FlowId = {{issue1Gen.response.body.$.data.flowId}}
|
||||
@issue1GenId = {{issue1Gen.response.body.$.data.id}}
|
||||
|
||||
### Step 1.2: Poll generation
|
||||
# @name checkIssue1Gen
|
||||
GET {{base}}/api/v1/generations/{{issue1GenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@issue1ImageId = {{checkIssue1Gen.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 1.3: Resolve project alias (via deprecated /resolve endpoint)
|
||||
# @name resolveProjectOnFlow
|
||||
GET {{base}}/api/v1/images/resolve/@project-test
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# BUG: Project alias on flow image should be resolvable
|
||||
# Expected: Returns image with id = {{issue1ImageId}}
|
||||
|
||||
### Step 1.4: Resolve project alias (via direct path - Section 6.2)
|
||||
# @name resolveProjectOnFlowDirect
|
||||
GET {{base}}/api/v1/images/@project-test
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# This should work after Section 6.2 implementation
|
||||
|
||||
|
||||
###############################################################################
|
||||
# ISSUE 2: Flow Delete Cascades Non-Aliased Images
|
||||
# When deleting a flow, images without project alias should be deleted
|
||||
###############################################################################
|
||||
|
||||
### Step 2.1: Create flow with non-aliased image
|
||||
# @name issue2Gen1
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "No alias image",
|
||||
"aspectRatio": "1:1",
|
||||
"flowAlias": "@issue-flow"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@issue2FlowId = {{issue2Gen1.response.body.$.data.flowId}}
|
||||
@issue2Gen1Id = {{issue2Gen1.response.body.$.data.id}}
|
||||
|
||||
### Step 2.2: Poll generation
|
||||
# @name checkIssue2Gen1
|
||||
GET {{base}}/api/v1/generations/{{issue2Gen1Id}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@issue2Image1Id = {{checkIssue2Gen1.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 2.3: Add aliased image to same flow
|
||||
# @name issue2Gen2
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "With alias image",
|
||||
"aspectRatio": "1:1",
|
||||
"flowId": "{{issue2FlowId}}",
|
||||
"alias": "@protected-image"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@issue2Gen2Id = {{issue2Gen2.response.body.$.data.id}}
|
||||
|
||||
### Step 2.4: Poll generation
|
||||
# @name checkIssue2Gen2
|
||||
GET {{base}}/api/v1/generations/{{issue2Gen2Id}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@issue2Image2Id = {{checkIssue2Gen2.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 2.5: Delete flow
|
||||
# @name deleteIssue2Flow
|
||||
DELETE {{base}}/api/v1/flows/{{issue2FlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Step 2.6: Check non-aliased image (should be 404)
|
||||
# @name checkIssue2Image1Deleted
|
||||
GET {{base}}/api/v1/images/{{issue2Image1Id}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 404 - Non-aliased image should be deleted with flow
|
||||
|
||||
### Step 2.7: Check aliased image (should still exist)
|
||||
# @name checkIssue2Image2Exists
|
||||
GET {{base}}/api/v1/images/{{issue2Image2Id}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 200 - Aliased image should be preserved
|
||||
|
||||
|
||||
###############################################################################
|
||||
# ISSUE 3: Flow Delete Preserves Aliased Images
|
||||
# Aliased images should have flowId set to null after flow deletion
|
||||
###############################################################################
|
||||
|
||||
### Step 3.1: Create flow with aliased image
|
||||
# @name issue3Gen
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Protected image",
|
||||
"aspectRatio": "1:1",
|
||||
"flowAlias": "@test-flow-2",
|
||||
"alias": "@keep-this"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@issue3FlowId = {{issue3Gen.response.body.$.data.flowId}}
|
||||
@issue3GenId = {{issue3Gen.response.body.$.data.id}}
|
||||
|
||||
### Step 3.2: Poll generation
|
||||
# @name checkIssue3Gen
|
||||
GET {{base}}/api/v1/generations/{{issue3GenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
@issue3ImageId = {{checkIssue3Gen.response.body.$.data.outputImageId}}
|
||||
|
||||
### Step 3.3: Delete flow
|
||||
# @name deleteIssue3Flow
|
||||
DELETE {{base}}/api/v1/flows/{{issue3FlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Step 3.4: Check aliased image (should exist with flowId=null)
|
||||
# @name checkIssue3ImagePreserved
|
||||
GET {{base}}/api/v1/images/{{issue3ImageId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 200 with flowId = null
|
||||
# BUG: flowId might not be set to null
|
||||
|
||||
|
||||
###############################################################################
|
||||
# ISSUE 4: Flow Delete Cascades Generations
|
||||
# Generations should be deleted when flow is deleted
|
||||
###############################################################################
|
||||
|
||||
### Step 4.1: Create flow with generation
|
||||
# @name issue4Gen
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Test generation",
|
||||
"aspectRatio": "1:1",
|
||||
"flowAlias": "@gen-flow"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@issue4FlowId = {{issue4Gen.response.body.$.data.flowId}}
|
||||
@issue4GenId = {{issue4Gen.response.body.$.data.id}}
|
||||
|
||||
### Step 4.2: Poll generation
|
||||
# @name checkIssue4Gen
|
||||
GET {{base}}/api/v1/generations/{{issue4GenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Step 4.3: Delete flow
|
||||
# @name deleteIssue4Flow
|
||||
DELETE {{base}}/api/v1/flows/{{issue4FlowId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
### Step 4.4: Check generation (should be 404)
|
||||
# @name checkIssue4GenDeleted
|
||||
GET {{base}}/api/v1/generations/{{issue4GenId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected: 404 - Generation should be deleted with flow
|
||||
|
||||
|
||||
###############################################################################
|
||||
# NOTES
|
||||
###############################################################################
|
||||
#
|
||||
# Flow Deletion Cascade (per api-refactoring-final.md):
|
||||
# - Flow record → DELETE
|
||||
# - All generations → DELETE
|
||||
# - Images without alias → DELETE (with MinIO cleanup)
|
||||
# - Images with project alias → KEEP (unlink: flowId = NULL)
|
||||
#
|
||||
# Known Issues:
|
||||
# 1. Project alias on flow images may not resolve properly
|
||||
# 2. Flow deletion may not properly cascade deletions
|
||||
# 3. Aliased images may not have flowId set to null
|
||||
#
|
||||
# These tests document expected behavior that may not be implemented yet.
|
||||
#
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// tests/api/07-known-issues.ts
|
||||
// Tests for Known Implementation Issues (EXPECTED TO FAIL)
|
||||
|
||||
import { api, log, runTest, createTestImage, testContext, exitWithTestResults } from './utils';
|
||||
import { api, log, runTest, createTestImage, testContext } from './utils';
|
||||
import { endpoints } from './config';
|
||||
|
||||
async function main() {
|
||||
|
|
@ -114,9 +114,4 @@ async function main() {
|
|||
log.warning('Failures above are EXPECTED and document bugs to fix');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => exitWithTestResults())
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch(console.error);
|
||||
|
|
|
|||
|
|
@ -1,248 +0,0 @@
|
|||
@base = http://localhost:3000
|
||||
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||
|
||||
###############################################################################
|
||||
# AUTO-ENHANCE TESTS
|
||||
# Tests: Prompt auto-enhancement feature
|
||||
#
|
||||
# Test Coverage:
|
||||
# 1. Generate without autoEnhance param (defaults to true)
|
||||
# 2. Generate with autoEnhance: false
|
||||
# 3. Generate with autoEnhance: true
|
||||
# 4. Verify enhancement quality
|
||||
# 5. List generations with autoEnhance field
|
||||
# 6. Verify response structure
|
||||
###############################################################################
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 1: Generate Without autoEnhance Parameter
|
||||
# Should default to true (enhancement enabled)
|
||||
###############################################################################
|
||||
|
||||
### Step 1.1: Create generation without autoEnhance param
|
||||
# @name genDefaultEnhance
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "a simple test image",
|
||||
"aspectRatio": "1:1"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@genDefaultId = {{genDefaultEnhance.response.body.$.data.id}}
|
||||
|
||||
### Step 1.2: Poll generation
|
||||
# @name checkGenDefault
|
||||
GET {{base}}/api/v1/generations/{{genDefaultId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - autoEnhance = true
|
||||
# - originalPrompt = "a simple test image"
|
||||
# - prompt != originalPrompt (was enhanced)
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 2: Generate with autoEnhance: false
|
||||
# Should NOT enhance the prompt
|
||||
###############################################################################
|
||||
|
||||
### Step 2.1: Create generation with autoEnhance: false
|
||||
# @name genNoEnhance
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "another test image",
|
||||
"aspectRatio": "1:1",
|
||||
"autoEnhance": false
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@genNoEnhanceId = {{genNoEnhance.response.body.$.data.id}}
|
||||
|
||||
### Step 2.2: Poll generation
|
||||
# @name checkGenNoEnhance
|
||||
GET {{base}}/api/v1/generations/{{genNoEnhanceId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - autoEnhance = false
|
||||
# - originalPrompt = "another test image"
|
||||
# - prompt = "another test image" (same, NOT enhanced)
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 3: Generate with autoEnhance: true
|
||||
# Should enhance the prompt
|
||||
###############################################################################
|
||||
|
||||
### Step 3.1: Create generation with explicit autoEnhance: true
|
||||
# @name genExplicitEnhance
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "third test image",
|
||||
"aspectRatio": "1:1",
|
||||
"autoEnhance": true
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@genExplicitId = {{genExplicitEnhance.response.body.$.data.id}}
|
||||
|
||||
### Step 3.2: Poll generation
|
||||
# @name checkGenExplicit
|
||||
GET {{base}}/api/v1/generations/{{genExplicitId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - autoEnhance = true
|
||||
# - originalPrompt = "third test image"
|
||||
# - prompt != originalPrompt (was enhanced)
|
||||
# - prompt is longer and more descriptive
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 4: Verify Enhancement Quality
|
||||
# Enhanced prompt should be longer and more descriptive
|
||||
###############################################################################
|
||||
|
||||
### Step 4.1: Get enhanced generation
|
||||
# @name getEnhancedGen
|
||||
GET {{base}}/api/v1/generations/{{genDefaultId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Enhanced prompt is longer than original
|
||||
# - Enhanced prompt may contain: "photorealistic", "detailed", "scene", etc.
|
||||
# - Compare: prompt.length > originalPrompt.length
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 5: List Generations with autoEnhance Field
|
||||
###############################################################################
|
||||
|
||||
### Step 5.1: List all generations
|
||||
# @name listGens
|
||||
GET {{base}}/api/v1/generations
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify:
|
||||
# - Each generation has autoEnhance field (boolean)
|
||||
# - Some generations have autoEnhance = true
|
||||
# - Some generations have autoEnhance = false
|
||||
|
||||
|
||||
### Step 5.2: Filter by status to see recent ones
|
||||
# @name listSuccessGens
|
||||
GET {{base}}/api/v1/generations?status=success&limit=10
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
|
||||
|
||||
###############################################################################
|
||||
# TEST 6: Verify Response Structure
|
||||
###############################################################################
|
||||
|
||||
### Step 6.1: Get generation and check fields
|
||||
# @name verifyStructure
|
||||
GET {{base}}/api/v1/generations/{{genDefaultId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Expected fields:
|
||||
# - prompt: string (final prompt, possibly enhanced)
|
||||
# - originalPrompt: string (original input prompt)
|
||||
# - autoEnhance: boolean (whether enhancement was applied)
|
||||
# - status: string
|
||||
# - outputImageId: string (on success)
|
||||
# - processingTimeMs: number (on completion)
|
||||
|
||||
|
||||
###############################################################################
|
||||
# ADDITIONAL TEST CASES
|
||||
###############################################################################
|
||||
|
||||
### Complex prompt that might be enhanced differently
|
||||
# @name complexPrompt
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "a cat sitting on a windowsill",
|
||||
"aspectRatio": "16:9"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@complexId = {{complexPrompt.response.body.$.data.id}}
|
||||
|
||||
### Check complex prompt enhancement
|
||||
# @name checkComplexPrompt
|
||||
GET {{base}}/api/v1/generations/{{complexId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify: Enhanced prompt should add details like lighting, perspective, style, etc.
|
||||
|
||||
|
||||
### Short prompt enhancement
|
||||
# @name shortPrompt
|
||||
POST {{base}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "sunset",
|
||||
"aspectRatio": "21:9"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@shortId = {{shortPrompt.response.body.$.data.id}}
|
||||
|
||||
### Check short prompt enhancement
|
||||
# @name checkShortPrompt
|
||||
GET {{base}}/api/v1/generations/{{shortId}}
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
###
|
||||
# Verify: Very short prompts should be significantly enhanced
|
||||
|
||||
|
||||
###############################################################################
|
||||
# NOTES
|
||||
###############################################################################
|
||||
#
|
||||
# Auto-Enhance Feature:
|
||||
# - Default: autoEnhance = true (prompts are enhanced by AI)
|
||||
# - Set autoEnhance: false to disable enhancement
|
||||
# - Enhanced prompts are more detailed and descriptive
|
||||
#
|
||||
# Response Fields:
|
||||
# - prompt: The final prompt (enhanced if autoEnhance was true)
|
||||
# - originalPrompt: The user's original input
|
||||
# - autoEnhance: Boolean flag indicating if enhancement was applied
|
||||
#
|
||||
# Enhancement adds:
|
||||
# - Descriptive adjectives
|
||||
# - Lighting and atmosphere details
|
||||
# - Perspective and composition hints
|
||||
# - Style and rendering suggestions
|
||||
#
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// tests/api/08-auto-enhance.ts
|
||||
// Auto-Enhance Feature Tests
|
||||
|
||||
import { api, log, runTest, waitForGeneration, testContext, exitWithTestResults } from './utils';
|
||||
import { api, log, runTest, waitForGeneration, testContext } from './utils';
|
||||
import { endpoints } from './config';
|
||||
|
||||
async function main() {
|
||||
|
|
@ -220,9 +220,4 @@ async function main() {
|
|||
log.section('AUTO-ENHANCE TESTS COMPLETED');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => exitWithTestResults())
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch(console.error);
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
|
|
@ -139,21 +139,11 @@ export async function uploadFile(
|
|||
): Promise<any> {
|
||||
const formData = new FormData();
|
||||
|
||||
// Read file and detect MIME type from extension
|
||||
// Read file
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
const fileBuffer = await fs.readFile(filepath);
|
||||
const ext = path.extname(filepath).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
const mimeType = mimeTypes[ext] || 'application/octet-stream';
|
||||
const filename = path.basename(filepath);
|
||||
const blob = new Blob([fileBuffer], { type: mimeType });
|
||||
formData.append('file', blob, filename);
|
||||
const blob = new Blob([fileBuffer]);
|
||||
formData.append('file', blob, 'test-image.png');
|
||||
|
||||
// Add other fields
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
|
|
@ -204,16 +194,11 @@ export const testContext: {
|
|||
[key: string]: any; // Allow dynamic properties
|
||||
} = {};
|
||||
|
||||
// Test tracking state
|
||||
let failedTests = 0;
|
||||
let totalTests = 0;
|
||||
|
||||
// Test runner helper
|
||||
export async function runTest(
|
||||
name: string,
|
||||
fn: () => Promise<void>
|
||||
): Promise<boolean> {
|
||||
totalTests++;
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
await fn();
|
||||
|
|
@ -221,29 +206,12 @@ export async function runTest(
|
|||
log.success(`${name} (${duration}ms)`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
failedTests++;
|
||||
log.error(`${name}`);
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get test statistics
|
||||
export function getTestStats() {
|
||||
return { total: totalTests, failed: failedTests, passed: totalTests - failedTests };
|
||||
}
|
||||
|
||||
// Exit with appropriate code based on test results
|
||||
export function exitWithTestResults() {
|
||||
const stats = getTestStats();
|
||||
if (stats.failed > 0) {
|
||||
log.error(`${stats.failed}/${stats.total} tests failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
log.success(`${stats.passed}/${stats.total} tests passed`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Verify image is accessible at URL
|
||||
export async function verifyImageAccessible(url: string): Promise<boolean> {
|
||||
try {
|
||||
|
|
@ -322,36 +290,14 @@ export async function createTestImage(
|
|||
}
|
||||
|
||||
// Helper to resolve alias
|
||||
// Returns format compatible with old /resolve/ endpoint: { imageId, scope, alias, image }
|
||||
export async function resolveAlias(
|
||||
alias: string,
|
||||
flowId?: string
|
||||
): Promise<any> {
|
||||
// Section 6.2: Use direct alias identifier instead of /resolve/ endpoint
|
||||
const endpoint = flowId
|
||||
? `${endpoints.images}/${alias}?flowId=${flowId}`
|
||||
: `${endpoints.images}/${alias}`;
|
||||
? `${endpoints.images}/resolve/${alias}?flowId=${flowId}`
|
||||
: `${endpoints.images}/resolve/${alias}`;
|
||||
|
||||
const result = await api(endpoint);
|
||||
const image = result.data.data;
|
||||
|
||||
// Determine scope based on alias type and context
|
||||
const technicalAliases = ['@last', '@first', '@upload'];
|
||||
let scope: string;
|
||||
if (technicalAliases.includes(alias)) {
|
||||
scope = 'technical';
|
||||
} else if (flowId) {
|
||||
scope = 'flow';
|
||||
} else {
|
||||
scope = 'project';
|
||||
}
|
||||
|
||||
// Adapt response to match old /resolve/ format for test compatibility
|
||||
return {
|
||||
imageId: image.id,
|
||||
alias: image.alias || alias,
|
||||
scope,
|
||||
flowId: image.flowId,
|
||||
image,
|
||||
};
|
||||
return result.data.data;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue