Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions app/api/enhance-prompt/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/**
* Tests for the Enhance Prompt API Route
*
* Tests authentication, rate limiting, and prompt enhancement functionality.
*/
import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest"
import { NextRequest } from "next/server"
import { POST } from "./route"

// Mock Clerk auth
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn(),
}))

// Mock Convex fetchMutation
vi.mock("convex/nextjs", () => ({
fetchMutation: vi.fn(),
}))

// Mock the Convex API import
vi.mock("@/convex/_generated/api", () => ({
api: {
rateLimits: {
checkRateLimit: "rateLimits:checkRateLimit",
},
},
}))

// Mock prompt enhancement
vi.mock("@/lib/prompt-enhancement", () => ({
enhancePrompt: vi.fn(),
enhanceNegativePrompt: vi.fn(),
PromptEnhancementError: class PromptEnhancementError extends Error {
code: string
status?: number
constructor(message: string, code: string, status?: number) {
super(message)
this.code = code
this.status = status
}
},
}))

import { auth } from "@clerk/nextjs/server"
import { fetchMutation } from "convex/nextjs"
import { enhancePrompt, enhanceNegativePrompt, PromptEnhancementError } from "@/lib/prompt-enhancement"

function createMockRequest(body: Record<string, unknown>): NextRequest {
const request = new NextRequest("http://localhost:3000/api/enhance-prompt", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
})
return request
}

describe("/api/enhance-prompt", () => {
beforeEach(() => {
vi.clearAllMocks()
})

afterEach(() => {
vi.resetAllMocks()
})

describe("Authentication", () => {
it("should return 401 when user is not authenticated", async () => {
; (auth as Mock).mockResolvedValue({ userId: null })

const request = createMockRequest({ prompt: "test", type: "prompt" })
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(401)
expect(data).toEqual({
success: false,
error: {
code: "UNAUTHORIZED",
message: "Authentication required",
},
})
})

it("should proceed when user is authenticated", async () => {
; (auth as Mock).mockResolvedValue({ userId: "user_123" })
; (fetchMutation as Mock).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60000,
})
; (enhancePrompt as Mock).mockResolvedValue({ enhancedText: "enhanced prompt" })

const request = createMockRequest({ prompt: "test", type: "prompt" })
const response = await POST(request)

expect(response.status).toBe(200)
})
})

describe("Rate Limiting", () => {
it("should return 429 when rate limit is exceeded", async () => {
const resetAt = Date.now() + 30000
; (auth as Mock).mockResolvedValue({ userId: "user_123" })
; (fetchMutation as Mock).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt,
})

const request = createMockRequest({ prompt: "test", type: "prompt" })
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(429)
expect(data).toEqual({
success: false,
error: {
code: "RATE_LIMIT_EXCEEDED",
message: "Too many requests. Please try again later.",
},
})
expect(response.headers.get("Retry-After")).toBeDefined()
expect(response.headers.get("X-RateLimit-Remaining")).toBe("0")
})

it("should include rate limit headers on successful response", async () => {
const resetAt = Date.now() + 60000
; (auth as Mock).mockResolvedValue({ userId: "user_123" })
; (fetchMutation as Mock).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt,
})
; (enhancePrompt as Mock).mockResolvedValue({ enhancedText: "enhanced" })

const request = createMockRequest({ prompt: "test", type: "prompt" })
const response = await POST(request)

expect(response.headers.get("X-RateLimit-Remaining")).toBe("9")
expect(response.headers.get("X-RateLimit-Reset")).toBe(String(resetAt))
})
})

describe("Validation", () => {
beforeEach(() => {
; (auth as Mock).mockResolvedValue({ userId: "user_123" })
; (fetchMutation as Mock).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60000,
})
})

it("should return 400 when prompt is missing", async () => {
const request = createMockRequest({ type: "prompt" })
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(400)
expect(data.error.code).toBe("VALIDATION_ERROR")
expect(data.error.message).toBe("Prompt is required")
})

it("should return 400 when prompt is empty", async () => {
const request = createMockRequest({ prompt: " ", type: "prompt" })
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(400)
expect(data.error.code).toBe("VALIDATION_ERROR")
})

it("should return 400 when type is invalid", async () => {
const request = createMockRequest({ prompt: "test", type: "invalid" })
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(400)
expect(data.error.code).toBe("VALIDATION_ERROR")
expect(data.error.message).toBe("Type must be 'prompt' or 'negative'")
})
})

describe("Prompt Enhancement", () => {
beforeEach(() => {
; (auth as Mock).mockResolvedValue({ userId: "user_123" })
; (fetchMutation as Mock).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60000,
})
})

it("should enhance positive prompt successfully", async () => {
; (enhancePrompt as Mock).mockResolvedValue({ enhancedText: "enhanced positive prompt" })

const request = createMockRequest({ prompt: "a cat", type: "prompt" })
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(200)
expect(data).toEqual({
success: true,
data: { enhancedText: "enhanced positive prompt" },
})
expect(enhancePrompt).toHaveBeenCalledWith("a cat", expect.any(Object))
})

it("should enhance negative prompt successfully", async () => {
; (enhanceNegativePrompt as Mock).mockResolvedValue({ enhancedText: "enhanced negative prompt" })

const request = createMockRequest({ prompt: "a cat", negativePrompt: "blurry", type: "negative" })
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(200)
expect(data.data.enhancedText).toBe("enhanced negative prompt")
expect(enhanceNegativePrompt).toHaveBeenCalledWith("a cat", "blurry", expect.any(Object))
})
})

describe("Error Handling", () => {
beforeEach(() => {
; (auth as Mock).mockResolvedValue({ userId: "user_123" })
; (fetchMutation as Mock).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60000,
})
})

it("should handle PromptEnhancementError with custom status", async () => {
const error = new (PromptEnhancementError as unknown as new (message: string, code: string, status?: number) => Error & { code: string; status?: number })("API Error", "API_ERROR", 503)
; (enhancePrompt as Mock).mockRejectedValue(error)

const request = createMockRequest({ prompt: "test", type: "prompt" })
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(503)
expect(data.error.code).toBe("API_ERROR")
})

it("should handle unknown errors with 500 status", async () => {
; (enhancePrompt as Mock).mockRejectedValue(new Error("Unknown error"))

const request = createMockRequest({ prompt: "test", type: "prompt" })
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(500)
expect(data.error.code).toBe("INTERNAL_ERROR")
})
})
})
74 changes: 66 additions & 8 deletions app/api/enhance-prompt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@
*
* Server-side endpoint for prompt enhancement using OpenRouter.
* Handles both prompt and negative prompt enhancement requests.
*
* Security:
* - Requires authentication (returns 401 if not authenticated)
* - Rate limited to 10 requests per minute per user (returns 429 if exceeded)
*/

import { auth } from "@clerk/nextjs/server"
import { fetchMutation } from "convex/nextjs"
import {
enhanceNegativePrompt,
enhancePrompt,
PromptEnhancementError,
enhanceNegativePrompt,
enhancePrompt,
PromptEnhancementError,
} from "@/lib/prompt-enhancement"
import { NextRequest, NextResponse } from "next/server"
import { api } from "@/convex/_generated/api"

/**
* Request body schema
Expand Down Expand Up @@ -57,6 +64,48 @@ export async function POST(
request: NextRequest
): Promise<NextResponse<EnhancePromptResponse>> {
try {
// Authentication check
const { userId } = await auth()
if (!userId) {
return NextResponse.json(
{
success: false,
error: {
code: "UNAUTHORIZED",
message: "Authentication required",
},
},
{ status: 401 }
)
}

// Rate limit check
const rateLimitResult = await fetchMutation(api.rateLimits.checkRateLimit, {
userId,
endpoint: "enhance-prompt",
})

if (!rateLimitResult.allowed) {
const retryAfter = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000)
return NextResponse.json(
{
success: false,
error: {
code: "RATE_LIMIT_EXCEEDED",
message: "Too many requests. Please try again later.",
},
},
{
status: 429,
headers: {
"Retry-After": String(retryAfter),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": String(rateLimitResult.resetAt),
},
}
)
}

const body = (await request.json()) as EnhancePromptRequest

// Validate request
Expand Down Expand Up @@ -92,12 +141,20 @@ export async function POST(
? await enhancePrompt(body.prompt, { abortSignal: request.signal })
: await enhanceNegativePrompt(body.prompt, body.negativePrompt, { abortSignal: request.signal })

return NextResponse.json({
success: true,
data: {
enhancedText: result.enhancedText,
return NextResponse.json(
{
success: true,
data: {
enhancedText: result.enhancedText,
},
},
})
{
headers: {
"X-RateLimit-Remaining": String(rateLimitResult.remaining),
"X-RateLimit-Reset": String(rateLimitResult.resetAt),
},
}
)
} catch (error) {
// Handle cancellation
if (error instanceof Error && error.name === "AbortError") {
Expand Down Expand Up @@ -141,3 +198,4 @@ export async function POST(
)
}
}

Loading