Cf-api Refactor (#2131)

Fixes CF-833
This commit is contained in:
HeshamHM28 2026-01-19 09:33:57 -08:00 committed by GitHub
parent 21c2900e31
commit 45e22e0f94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2757 additions and 1029 deletions

View file

@ -1,9 +1,10 @@
import { PostHog } from "posthog-node" // new
import { PostHog } from "posthog-node"
import { POSTHOG_API_KEY, POSTHOG_HOST } from "./constants/index.js"
const isProduction = process.env.NODE_ENV === "production"
export const posthog = isProduction
? new PostHog("phc_aUO790jHd7z1SXwsYCz8dRApxueplZlZWeDSpKc5hol", {
host: "https://app.posthog.com",
? new PostHog(process.env.POSTHOG_API_KEY || POSTHOG_API_KEY, {
host: POSTHOG_HOST,
})
: undefined

View file

@ -0,0 +1,117 @@
/**
* Application Constants
* Centralized location for all constant values used throughout the application
*/
// ========================================
// API ROUTES
// ========================================
export const API_BASE_ROUTE = "/cfapi"
export const GITHUB_WEBHOOK_PATH = "/cfapi/github"
// Public routes
export const ROUTES = {
// Root
ROOT: "/",
HEALTHCHECK: "/healthcheck",
// Public API (no auth)
TEST_SENTRY: "/test-sentry",
SLACK_EVENTS: "/slack-events",
// Webhooks
GITHUB_WEBHOOKS: `${GITHUB_WEBHOOK_PATH}/webhooks`,
STRIPE_WEBHOOKS: "/cfapi/webhooks/stripe",
// Optimization
SUGGEST_PR_CHANGES: "/suggest-pr-changes",
CREATE_PR: "/create-pr",
VERIFY_EXISTING_OPTIMIZATIONS: "/verify-existing-optimizations",
IS_ALREADY_OPTIMIZED: "/is-already-optimized",
ADD_CODE_HASH: "/add-code-hash",
MARK_AS_SUCCESS: "/mark-as-success",
CREATE_STAGING: "/create-staging",
GET_STAGING_CODE: "/get-staging-code",
COMMIT_STAGING_CODE: "/commit-staging-code",
TEST_REPO: "/test-repo",
// GitHub
IS_GITHUB_APP_INSTALLED: "/is-github-app-installed",
SETUP_GITHUB_ACTIONS: "/setup-github-actions",
// Subscription
SUBSCRIPTION: "/subscription",
CREATE_CHECKOUT: "/create-checkout",
CANCEL_SUBSCRIPTION: "/cancel-subscription",
// User
CLI_GET_USER: "/cli-get-user",
SEND_COMPLETION_EMAIL: "/send-completion-email",
} as const
// Routes that require usage tracking
export const USAGE_TRACKED_ROUTES = [
ROUTES.SUGGEST_PR_CHANGES,
ROUTES.CREATE_PR,
ROUTES.CREATE_STAGING,
]
// ========================================
// SERVER CONFIG
// ========================================
export const DEFAULT_PORT = 3001
export const JSON_BODY_LIMIT = "10mb"
// ========================================
// CRON SCHEDULES
// ========================================
// Run daily at midnight UTC
export const CRON_DAILY_MIDNIGHT = "0 0 * * *"
// ========================================
// RATE LIMITING
// ========================================
export const RATE_LIMIT_WINDOW_MS = 60 * 1000 // 1 minute
export const RATE_LIMIT_MAX_REQUESTS = 100
// ========================================
// EXTERNAL SERVICES
// ========================================
export const POSTHOG_HOST = "https://app.posthog.com"
export const POSTHOG_API_KEY = "phc_aUO790jHd7z1SXwsYCz8dRApxueplZlZWeDSpKc5hol"
// ========================================
// SECURITY
// ========================================
export const SENSITIVE_KEYS = [
"password",
"token",
"secret",
"key",
"authorization",
"access_token",
]
// ========================================
// LOGGING
// ========================================
export const MAX_PENDING_LOGS = 500
export const SERVICE_NAME = "cf-api"
// ========================================
// EMAIL
// ========================================
export const STATUS_EMAIL_RECIPIENTS = {
to: "sarthak@codeflash.ai",
cc: ["hesham@codeflash.ai"],
}
export const CRON_EMAIL_SUBJECT_PREFIX = "Cron Job syncOrgsWithMembers"

37
js/cf-api/cron/index.ts Normal file
View file

@ -0,0 +1,37 @@
import cron from "node-cron"
import * as Sentry from "@sentry/node"
import { App } from "octokit"
import { syncOrgsWithMembers } from "../github/github-utils.js"
import { sendStatusEmail } from "../resend/email-service.js"
import { CRON_DAILY_MIDNIGHT } from "../constants/index.js"
let isRunning = false
/**
* Initialize all cron jobs
*/
export function initializeCronJobs(githubApp: App): void {
// Sync organizations with members - runs daily at midnight UTC
cron.schedule(CRON_DAILY_MIDNIGHT, async () => {
if (isRunning) return
isRunning = true
const startTime = new Date()
try {
await syncOrgsWithMembers(githubApp)
console.log("Finished syncOrgsWithMembers cron job.")
} catch (error: any) {
console.error("Error running syncOrgsWithMembers cron job:", error)
Sentry.captureException(error)
const endTime = new Date()
const duration = endTime.getTime() - startTime.getTime()
const durationHms = `${Math.floor(duration / 3600000)}h ${Math.floor((duration % 3600000) / 60000)}m ${Math.floor((duration % 60000) / 1000)}s`
const errorDetails = error.stack || error.message || String(error)
await sendStatusEmail("Failed", startTime, endTime, durationHms, errorDetails)
} finally {
isRunning = false
}
})
console.log("Cron jobs initialized")
}

View file

@ -5,6 +5,14 @@ import { getInstallationOctokitByOwner, isUserCollaborator } from "../github/git
import { userNickname } from "../auth0-mgmt.js"
import { posthog } from "../analytics.js"
import { logger } from "../utils/logger.js"
import {
missingRequiredFields,
unauthorized,
githubNotCollaborator,
validationFailure,
internalServerError,
conflict,
} from "../exceptions/index.js"
export async function is_code_being_optimized_again(req: Request, res: Response) {
try {
@ -12,16 +20,15 @@ export async function is_code_being_optimized_again(req: Request, res: Response)
const userId = (req as any).userId
if (!repo || !owner || !pr_number || !code_contexts) {
res.status(400).send("Missing or malformed fields")
return
throw missingRequiredFields("repo, owner, pr_number, code_contexts")
}
const nickname: string | null = await userNickname(userId)
if (nickname == null) {
return res.status(401).json({ error: "Unauthorized" })
throw unauthorized("")
}
const octokit = await getInstallationOctokitByOwner(githubApp, owner, repo)
if (octokit instanceof Error) {
return res.status(500).json({ error: octokit.message })
throw internalServerError(octokit.message)
}
// Check collaborator status with error handling
@ -32,29 +39,29 @@ export async function is_code_being_optimized_again(req: Request, res: Response)
nickname,
repo: `${owner}/${repo}`,
})
return res.status(401).json({ error: "Unauthorized - User is not a collaborator" })
throw githubNotCollaborator(`${owner}/${repo}`)
}
} catch (error) {
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
logger.error("Error checking collaborator status", req, {}, error as Error)
return res.status(500).json({ error: "Failed to verify collaborator status" })
throw internalServerError("Failed to verify collaborator status")
}
// Validate pr_number is an integer above 0
const pr_number_int = parseInt(pr_number, 10)
if (isNaN(pr_number_int) || pr_number_int <= 0) {
res.status(400).send("pr_number must be a positive integer")
return
throw validationFailure("pr_number must be a positive integer")
}
// Validate code_contexts is an array
if (!Array.isArray(code_contexts)) {
res.status(400).send("code_contexts must be an array of objects")
return
throw validationFailure("code_contexts must be an array of objects")
}
if (code_contexts.length === 0) {
res.status(400).send("code_contexts cannot be empty")
return
throw validationFailure("code_contexts cannot be empty")
}
// Validate each code context object has required fields
@ -62,25 +69,21 @@ export async function is_code_being_optimized_again(req: Request, res: Response)
const context = code_contexts[i]
if (typeof context !== "object" || context === null) {
res.status(400).send(`code_contexts[${i}] must be an object`)
return
throw validationFailure(`code_contexts[${i}] must be an object`)
}
const { file_path, function_name, code_hash } = context
if (typeof file_path !== "string" || file_path.trim().length === 0) {
res.status(400).send(`code_contexts[${i}].file_path must be a non-empty string`)
return
throw validationFailure(`code_contexts[${i}].file_path must be a non-empty string`)
}
if (typeof function_name !== "string" || function_name.trim().length === 0) {
res.status(400).send(`code_contexts[${i}].function_name must be a non-empty string`)
return
throw validationFailure(`code_contexts[${i}].function_name must be a non-empty string`)
}
if (typeof code_hash !== "string" || !/^[a-f0-9]{64}$/.test(code_hash)) {
res.status(400).send(`code_contexts[${i}].code_hash must be a valid SHA-256 hash`)
return
throw validationFailure(`code_contexts[${i}].code_hash must be a valid SHA-256 hash`)
}
}
@ -137,8 +140,11 @@ export async function is_code_being_optimized_again(req: Request, res: Response)
new_contexts: code_contexts.length - alreadyOptimizedTuples.length,
})
} catch (error) {
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
logger.errorWithSentry("Error in is_code_being_optimized_again", req, {}, error as Error)
res.status(500).send("Internal server error")
throw internalServerError("Error checking code optimization status")
}
}
@ -148,18 +154,17 @@ export async function add_optimized_code_context(req: Request, res: Response) {
const userId = (req as any).userId
if (!repo || !owner || !pr_number || !code_hash) {
res.status(400).send("Missing or malformed fields")
return
throw missingRequiredFields("repo, owner, pr_number, code_hash")
}
const nickname: string | null = await userNickname(userId)
if (nickname == null) {
return res.status(401).json({ error: "Unauthorized" })
throw unauthorized("")
}
const octokit = await getInstallationOctokitByOwner(githubApp, owner, repo)
if (octokit instanceof Error) {
return res.status(500).json({ error: octokit.message })
throw internalServerError(octokit.message)
}
// Check collaborator status with error handling
@ -170,24 +175,25 @@ export async function add_optimized_code_context(req: Request, res: Response) {
nickname,
repo: `${owner}/${repo}`,
})
return res.status(401).json({ error: "Unauthorized - User is not a collaborator" })
throw githubNotCollaborator(`${owner}/${repo}`)
}
} catch (error) {
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
logger.error("Error checking collaborator status", req, {}, error as Error)
return res.status(500).json({ error: "Failed to verify collaborator status" })
throw internalServerError("Failed to verify collaborator status")
}
// Validate pr_number is an integer above 0
const pr_number_int = parseInt(pr_number, 10)
if (isNaN(pr_number_int) || pr_number_int <= 0) {
res.status(400).send("pr_number must be a positive integer")
return
throw validationFailure("pr_number must be a positive integer")
}
// Validate code_hash is a valid SHA-256 hash
if (typeof code_hash !== "string" || !/^[a-f0-9]{64}$/.test(code_hash)) {
res.status(400).send("code_hash must be a valid SHA-256 hash")
return
throw validationFailure("code_hash must be a valid SHA-256 hash")
}
// Create the new entry
@ -209,18 +215,18 @@ export async function add_optimized_code_context(req: Request, res: Response) {
res.status(201).json({
message: "Code context successfully added",
})
} catch (error) {
} catch (error: any) {
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
logger.errorWithSentry("Error in add_optimized_code_context", req, {}, error as Error)
// Handle unique constraint violations gracefully
if (error.code === "P2002") {
logger.warn("Code context already exists for this PR", req, {})
return res.status(409).json({
error: "Code context already exists for this PR",
message: "This code hash has already been optimized for this PR",
})
throw conflict("Code context already exists for this PR")
}
res.status(500).send("Internal server error")
throw internalServerError("Error adding code context")
}
}

View file

@ -1,5 +1,6 @@
import { githubApp } from "../github/github-app.js"
import { logger } from "../utils/logger.js"
import { internalServerError } from "../exceptions/index.js"
export async function installedRepositories(req, res, next) {
try {
@ -27,7 +28,6 @@ export async function installedRepositories(req, res, next) {
res.json(allRepos)
} catch (error) {
logger.errorWithSentry("Error fetching repositories", req, {}, error as Error)
res.status(500).send("Error fetching repositories")
next(error)
next(internalServerError("Error fetching repositories"))
}
}

View file

@ -4,6 +4,15 @@ import { githubApp } from "../github/github-app.js"
import { Request, Response } from "express"
import { AuthorizedUserReq } from "types.js"
import { logger } from "../utils/logger.js"
import {
missingRequiredFields,
validationFailure,
unauthorized,
githubInstallationError,
githubNotCollaborator,
githubInstallationNotFound,
internalServerError,
} from "../exceptions/index.js"
// Dependencies interface for easier testing
export interface IsGitHubAppInstalledDependencies {
@ -41,23 +50,20 @@ export async function isGitHubAppInstalled(req: Request, res: Response): Promise
const { owner, repo } = req.query
if (!owner || !repo) {
res.status(400).send("Missing owner or repo query parameters")
return
throw missingRequiredFields("owner, repo")
}
const ownerStr = String(owner).trim()
const repoStr = String(repo).trim()
if (ownerStr === "" || repoStr === "") {
res.status(400).send("Invalid owner or repo query parameters")
return
throw validationFailure("owner and repo cannot be empty")
}
try {
const nickname = await dependencies.userNickname((req as AuthorizedUserReq).userId)
if (nickname == null) {
res.status(401).send("Unauthorized") // Error getting user nickname
return
throw unauthorized("")
}
const installationOctokit = await dependencies.getInstallationOctokitByOwner(
@ -67,8 +73,7 @@ export async function isGitHubAppInstalled(req: Request, res: Response): Promise
)
if (installationOctokit instanceof Error) {
res.status(401).send(installationOctokit.message)
return
throw githubInstallationError(installationOctokit.message)
}
const isCollaborator = await dependencies.isUserCollaborator(
@ -79,12 +84,7 @@ export async function isGitHubAppInstalled(req: Request, res: Response): Promise
)
if (!isCollaborator) {
res
.status(403)
.send(
`The authenticated user is not a collaborator on the repository ${ownerStr}/${repoStr}`,
)
return
throw githubNotCollaborator(`${ownerStr}/${repoStr}`)
}
logger.info("GitHub App installation and collaborator status verified", req, {
@ -94,12 +94,14 @@ export async function isGitHubAppInstalled(req: Request, res: Response): Promise
res.json(true)
} catch (error: any) {
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
if (error.status === 404) {
logger.warn("GitHub App not installed on repository", req, {
repo: `${ownerStr}/${repoStr}`,
})
res.status(404).send(`GitHub App is not installed on the repository ${ownerStr}/${repoStr}`)
return
throw githubInstallationNotFound(`${ownerStr}/${repoStr}`)
}
logger.errorWithSentry(
@ -108,6 +110,6 @@ export async function isGitHubAppInstalled(req: Request, res: Response): Promise
{ repo: `${ownerStr}/${repoStr}` },
error,
)
res.status(500).send("Error checking GitHub App installation or collaborator status")
throw internalServerError("Error checking GitHub App installation or collaborator status")
}
}

View file

@ -1,6 +1,11 @@
import { prisma } from "@codeflash-ai/common"
import { Request, Response } from "express"
import { logger } from "../utils/logger.js"
import {
validationFailure,
optimizationNotFound,
internalServerError,
} from "../exceptions/index.js"
// Dependencies interface for easier testing
export interface OptimizationSuccessDependencies {
@ -32,10 +37,7 @@ export async function optimizationSuccess(req: Request, res: Response): Promise<
// Fix validation to handle null values properly
if (trace_id == null || typeof is_optimization_found !== "boolean") {
res
.status(400)
.json({ error: "Invalid input: trace_id and is_optimization_found(boolean) are required." })
return
throw validationFailure("trace_id and is_optimization_found(boolean) are required")
}
try {
@ -45,15 +47,16 @@ export async function optimizationSuccess(req: Request, res: Response): Promise<
})
if (result.count === 0) {
res.status(404).json({ error: "Optimization event not found." })
return
throw optimizationNotFound(trace_id)
}
res.status(200).json({ message: "Optimization status updated." })
return
} catch (error) {
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
logger.errorWithSentry("Error in markOptimizationSuccess:", req, {}, error as Error)
res.status(500).json({ error: "Internal server error." })
return
throw internalServerError("Error updating optimization status")
}
}

View file

@ -3,6 +3,7 @@ import { prisma } from "@codeflash-ai/common"
import { loadAndRenderHtml, sendEmail } from "../resend/email-service.js"
import { Response, Request } from "express"
import { logger } from "../utils/logger.js"
import { internalServerError } from "../exceptions/index.js"
export async function sendOptimizationCompletedEmail(req: Request, res: Response): Promise<void> {
const user = await prisma.users.findUnique({
@ -67,9 +68,7 @@ export async function sendOptimizationCompletedEmail(req: Request, res: Response
error as Error,
)
Sentry.captureException(error)
res.status(500).json({ status: "error", message: "Failed to send email." })
return
throw internalServerError("Failed to send optimization completed email")
}
}
}

View file

@ -8,6 +8,15 @@ import { posthog } from "../analytics.js"
import { AuthorizedUserReq } from "types.js"
import { registerRepositoryAndMember } from "./utils/github-repo-setup.js"
import { createNewPullRequest } from "../github/create-pr-from-diffcontents.js"
import {
missingRequiredFields,
validationFailure,
unauthorized,
githubInstallationNotFound,
githubInstallationError,
githubNotCollaborator,
internalServerError,
} from "../exceptions/index.js"
/**
* Required GitHub App Permissions:
@ -288,16 +297,12 @@ export async function setupGithubActions(req: Request, res: Response): Promise<v
// Validate required fields
if (!owner || !repo || !baseBranch || !workflowContent) {
res.status(400).json({
error: "Missing required fields: owner, repo, baseBranch, and workflowContent are required",
})
return
throw missingRequiredFields("owner, repo, baseBranch, workflowContent")
}
// Validate workflowContent is a string
if (typeof workflowContent !== "string") {
res.status(400).json({ error: "workflowContent must be a string" })
return
throw validationFailure("workflowContent must be a string")
}
console.log(
@ -309,8 +314,7 @@ export async function setupGithubActions(req: Request, res: Response): Promise<v
// Get user nickname for authentication
const nickname: string | null = await dependencies.userNickname(userId)
if (nickname == null) {
res.status(401).json({ error: "Unauthorized" })
return
throw unauthorized("")
}
console.log(
@ -331,22 +335,11 @@ export async function setupGithubActions(req: Request, res: Response): Promise<v
// Check if it's a "not installed" error (404)
if (installationOctokit.message.includes("not installed")) {
res.status(404).json({
error: "GitHub App not installed",
message: `The CodeFlash GitHub App is not installed on ${owner}/${repo}.`,
installation_url: "https://github.com/apps/codeflash-ai/installations/select_target",
help: "Please install the GitHub App on your repository to continue.",
})
return
throw githubInstallationNotFound(`${owner}/${repo}`)
}
// Other installation errors
res.status(401).json({
error: "GitHub App installation error",
message: installationOctokit.message,
installation_url: "https://github.com/apps/codeflash-ai/installations/select_target",
})
return
throw githubInstallationError(installationOctokit.message)
}
// Verify user is collaborator
@ -358,16 +351,10 @@ export async function setupGithubActions(req: Request, res: Response): Promise<v
)
if (!isCollaborator) {
const errorMsg = `You are not a collaborator on ${owner}/${repo}. Please ensure you have write access to the repository.`
console.log(
`[setup-github-actions.ts:setupGithubActions] ${nickname} is not a collaborator on ${owner}/${repo}`,
)
res.status(403).json({
error: "Access denied",
message: errorMsg,
help: "Please contact a repository administrator to grant you write access, or ensure you're using the correct GitHub account.",
})
return
throw githubNotCollaborator(`${owner}/${repo}`)
}
console.log(
@ -429,6 +416,11 @@ export async function setupGithubActions(req: Request, res: Response): Promise<v
message: "GitHub Actions workflow PR created successfully",
})
} catch (error: any) {
// Re-throw AppExceptions to be handled by GlobalExceptionHandler
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
const { owner, repo, baseBranch } = req.body || {}
console.error(
`[setup-github-actions.ts:setupGithubActions] Error processing request for ${owner}/${repo} on branch ${baseBranch}:`,
@ -447,54 +439,18 @@ export async function setupGithubActions(req: Request, res: Response): Promise<v
// Check for specific error types and provide helpful messages
if (error.status === 404 && error.message?.includes("not installed")) {
res.status(404).json({
error: "GitHub App not installed",
message: `The CodeFlash GitHub App is not installed on ${owner}/${repo}.`,
installation_url: "https://github.com/apps/codeflash-ai/installations/select_target",
help: "Please install the GitHub App on your repository to continue.",
})
return
throw githubInstallationNotFound(`${owner}/${repo}`)
}
if (error.status === 403 || error.message?.includes("not a collaborator")) {
res.status(403).json({
error: "Access denied",
message:
error.message || `You don't have permission to perform this action on ${owner}/${repo}.`,
help: "Please ensure you have write access to the repository and that the GitHub App has the required permissions.",
installation_url: "https://github.com/apps/codeflash-ai/installations/select_target",
})
return
throw githubNotCollaborator(`${owner}/${repo}`)
}
if (error.status === 401 || error.message?.includes("Unauthorized")) {
res.status(401).json({
error: "Authorization failed",
message: error.message || "Authentication failed. Please check your API key and try again.",
help: "Please verify your API key is correct and has the necessary permissions.",
})
return
throw unauthorized(error.message || "Authentication failed")
}
// For PR creation errors, provide more context
if (
error.message?.includes("create") ||
error.message?.includes("PR") ||
error.message?.includes("pull request")
) {
res.status(500).json({
error: "Failed to create PR",
message: error.message || "An error occurred while creating the pull request.",
help: "Please try again, or manually create the workflow file at .github/workflows/codeflash.yaml",
})
return
}
// For other errors, return 500 with generic message
res.status(500).json({
error: "Failed to setup GitHub Actions",
message: error.message || "An unexpected error occurred",
help: "Please try again later, or contact support if the issue persists.",
})
// For other errors, throw internal server error
throw internalServerError(error.message || "Failed to setup GitHub Actions")
}
}

View file

@ -4,6 +4,7 @@ import { posthog } from "../analytics.js"
import { processReaction } from "../github/optimization_approval.js"
import * as Sentry from "@sentry/node"
import { logger } from "../utils/logger.js"
import { forbidden, internalServerError } from "../exceptions/index.js"
// Dependencies interface for easier testing
export interface HandleSlackEventsDependencies {
@ -86,7 +87,7 @@ export async function handleSlackEvents(req: Request, res: Response) {
// Verify the request is from Slack
if (!verifySlackRequest(req)) {
logger.error("Failed to verify Slack request", req)
return res.status(403).send("Invalid request")
throw forbidden("Invalid Slack request signature")
}
// Handle URL verification (required when setting up events)
@ -120,11 +121,22 @@ export async function handleSlackEvents(req: Request, res: Response) {
}
}
} catch (error) {
// Re-throw AppExceptions to be handled by GlobalExceptionHandler if headers not sent
if (error && typeof error === "object" && "getHttpStatus" in error) {
if (!res.headersSent) {
throw error
}
// If headers already sent, just log it
logger.errorWithSentry(`Error handling Slack event: ${error}`, req, {}, error as Error)
dependencies.Sentry.captureException(error)
return
}
logger.errorWithSentry(`Error handling Slack event: ${error}`, req, {}, error as Error)
// If we haven't sent a response yet, send an error
// If we haven't sent a response yet, throw an error
if (!res.headersSent) {
res.status(500).send("Error processing event")
throw internalServerError("Error processing Slack event")
}
dependencies.Sentry.captureException(error)
// Log to monitoring

View file

@ -3,6 +3,7 @@ import { addMonthsSafe, stripe, SUBSCRIPTION_PLANS } from "@codeflash-ai/common"
import { prisma } from "@codeflash-ai/common"
import * as Sentry from "@sentry/node"
import { logger } from "../utils/logger.js"
import { badRequest } from "../exceptions/index.js"
// Define types for better type safety
type SubscriptionStatus = "active" | "past_due" | "canceled" | "incomplete"
@ -99,7 +100,7 @@ export async function stripeWebhookHandler(req: Request, res: Response) {
} catch (err: any) {
logger.errorWithSentry("Webhook Error", req, { errorMessage: err.message }, err)
dependencies.Sentry.captureException(err)
return res.status(400).send(`Webhook Error: ${err.message}`)
throw badRequest(`Webhook Error: ${err.message}`)
}
}

View file

@ -7,6 +7,10 @@ import {
} from "@codeflash-ai/common"
import * as Sentry from "@sentry/node"
import { logger } from "../utils/logger.js"
import {
missingRequiredFields,
subscriptionNotFound,
} from "../exceptions/index.js"
// Dependencies interface for easier testing
export interface SubscriptionDependencies {
@ -52,7 +56,7 @@ export async function getSubscription(req: Request, res: Response, next: NextFun
const userId = req.query.userId as string
if (!userId) {
return res.status(400).json({ error: "User ID is required" })
return next(missingRequiredFields("userId"))
}
try {
@ -60,7 +64,7 @@ export async function getSubscription(req: Request, res: Response, next: NextFun
const subscription = await dependencies.fetchSubscription(userId)
if (!subscription) {
return res.status(404).json({ error: "Subscription not found" })
return next(subscriptionNotFound(userId))
}
return res.json({
@ -83,7 +87,7 @@ export async function createCheckout(req: Request, res: Response, next: NextFunc
const { userId, priceId, successUrl, cancelUrl, period } = req.body
if (!userId || !priceId) {
return res.status(400).json({ error: "User ID and price ID are required" })
return next(missingRequiredFields("userId, priceId"))
}
try {
@ -112,7 +116,7 @@ export async function cancelSubscription(req: Request, res: Response, next: Next
const { userId } = req.body
if (!userId) {
return res.status(400).json({ error: "User ID is required" })
return next(missingRequiredFields("userId"))
}
try {

View file

@ -29,6 +29,16 @@ import { registerRepositoryAndMember } from "./utils/github-repo-setup.js"
import { saveStagingReview } from "./create-staging.js"
import { AnyOctokit, AuthorizedUserReq, PullRequestDB } from "../types.js"
import { OptimizationReview } from "../OptimizationReview.js"
import {
missingRequiredFields,
validationFailure,
unauthorized,
githubInstallationError,
githubNotCollaborator,
optimizationRejected,
unprocessableEntity,
internalServerError,
} from "../exceptions/index.js"
// Dependencies interface for easier testing
export interface SuggestPrChangesDependencies {
@ -201,12 +211,12 @@ export async function suggestPrChanges(
logger.info(`traceId: ${traceId}`, req)
if (!repo || !owner || !pullNumber || !dependencies.isDiffContentsWellFormed(diffContents)) {
return res.status(400).send("Missing or malformed fields")
throw validationFailure("Missing or malformed fields: repo, owner, pullNumber, diffContents")
}
const nickname = await dependencies.userNickname(userId)
if (nickname == null) {
return res.status(401).json({ error: "Unauthorized" }) // Error getting user nickname
throw unauthorized("")
}
const installationOctokit = await dependencies.getInstallationOctokitByOwner(
@ -215,7 +225,7 @@ export async function suggestPrChanges(
repo,
)
if (installationOctokit instanceof Error) {
return res.status(401).send(installationOctokit.message)
throw githubInstallationError(installationOctokit.message)
}
const isCollaborator = await dependencies.isUserCollaborator(
@ -226,14 +236,21 @@ export async function suggestPrChanges(
)
if (!isCollaborator) {
logger.info(`${nickname} is not a collaborator on ${owner}/${repo}`, req)
return res.status(401).json({ error: "Unauthorized" }) // User is not a collaborator
throw githubNotCollaborator(`${owner}/${repo}`)
}
logger.info(`${nickname} is a collaborator on ${owner}/${repo}`, req)
// TODO: Remove this background upsert logic after ensuring all old repositories have been saved.
registerRepositoryAndMember(owner, repo, nickname, userId, installationOctokit)
.then(() => logger.info(`Background repo and member upsert completed for ${owner}/${repo}`, req))
.then(() =>
logger.info(`Background repo and member upsert completed for ${owner}/${repo}`, req),
)
.catch(err => {
logger.errorWithSentry(`Error in background upsertRepoAndCreateMember:`, req, {}, err as Error)
logger.errorWithSentry(
`Error in background upsertRepoAndCreateMember:`,
req,
{},
err as Error,
)
Sentry.captureException(err)
})
// Check if approval is required
@ -247,14 +264,14 @@ export async function suggestPrChanges(
})
if (optimization?.approval_status === "rejected") {
return res.status(403).json({
status: "rejected",
message: "This optimization request was rejected.",
})
throw optimizationRejected("This optimization request was rejected")
}
if (optimization?.approval_status === "approved") {
logger.info(`Request ${traceId} was previously approved, continuing with PR suggestion`, req)
logger.info(
`Request ${traceId} was previously approved, continuing with PR suggestion`,
req,
)
const result = await triggerSuggestPrChanges(
owner,
repo,
@ -313,7 +330,7 @@ export async function suggestPrChanges(
// Check if the owner is roboflow
if (owner === "roboflow") {
logger.info(`Rejecting request for roboflow repository`, req)
return res.status(401).send("Unauthorized for roboflow repositories")
throw unauthorized("Unauthorized for roboflow repositories")
}
// No approval required, proceed with PR suggestion
const result = await triggerSuggestPrChanges(
@ -377,10 +394,19 @@ export async function suggestPrChanges(
}
return res.json(result)
} catch (error: any) {
logger.errorWithSentry(`Error in /cfapi/suggest-pr-changes: ${error}`, req, {
errorMessage: error.message,
}, error as Error)
dependencies.posthog?.capture({
// Re-throw AppExceptions to be handled by GlobalExceptionHandler
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
logger.errorWithSentry(
`Error in /cfapi/suggest-pr-changes: ${error}`,
req,
{
errorMessage: error.message,
},
error as Error,
)
dependencies.posthog.capture({
distinctId: req.userId,
event: `cfapi-suggest-pr-changes-failed-error`,
properties: {
@ -388,7 +414,7 @@ export async function suggestPrChanges(
stack: error.stack,
},
})
return res.status(500).send(`Error creating pull request: ${error.message}`)
throw internalServerError(`Error creating pull request: ${error.message}`)
}
}
@ -434,10 +460,13 @@ export async function triggerSuggestPrChanges(
pull_number: pullNumber,
})
const baseBranch = originalPrData.data.head.ref
logger.info(
`Attempting to access ref for: ${owner}/${repo}, branch: ${baseBranch}`,
{ endpoint: "/cfapi/suggest-pr-changes", operation: "access_ref", owner, repo, userId },
)
logger.info(`Attempting to access ref for: ${owner}/${repo}, branch: ${baseBranch}`, {
endpoint: "/cfapi/suggest-pr-changes",
operation: "access_ref",
owner,
repo,
userId,
})
const commitMessage = `Optimize ${prCommentFields.function_name} \n\n${prCommentFields.optimization_explanation}`
@ -448,7 +477,13 @@ export async function triggerSuggestPrChanges(
if (hunks.length > 1) {
logger.info(
`File ${filePath} has ${hunks.length} hunks, using dependent PR instead of review comments`,
{ endpoint: "/cfapi/suggest-pr-changes", operation: "multiple_hunks", owner, repo, userId },
{
endpoint: "/cfapi/suggest-pr-changes",
operation: "multiple_hunks",
owner,
repo,
userId,
},
)
hasMultipleHunksInSameFile = true
break
@ -470,7 +505,13 @@ export async function triggerSuggestPrChanges(
) {
logger.info(
`Creating a dependent PR because there are ${invalidHunks.size > 0 ? "invalid hunks" : "multiple valid hunks"}.`,
{ userId, endpoint: "/cfapi/suggest-pr-changes", operation: "create_dependent_pr", owner, repo },
{
userId,
endpoint: "/cfapi/suggest-pr-changes",
operation: "create_dependent_pr",
owner,
repo,
},
)
logger.info(`Making a new dependent PR...`, {
userId,
@ -552,7 +593,13 @@ export async function triggerSuggestPrChanges(
logger.info(
`Created new dependent PR #${newPrData.data.number} from branch ${newPrData.data.head.ref}`,
{ userId, endpoint: "/cfapi/suggest-pr-changes", operation: "dependent_pr_created", owner, repo },
{
userId,
endpoint: "/cfapi/suggest-pr-changes",
operation: "dependent_pr_created",
owner,
repo,
},
)
if (slackNotificationConfig[owner as keyof typeof slackNotificationConfig]?.includes(repo)) {
@ -676,7 +723,13 @@ export async function triggerSuggestPrChanges(
} else {
logger.warn(
`Found invalid review range for ${filePath}: start_line (${hunk.oldStart}) must be less than line (${hunk.oldEnd}) with content ${hunk.newContent.join("\n")}`,
{ userId, endpoint: "/cfapi/suggest-pr-changes", operation: "invalid_review_range", owner, repo },
{
userId,
endpoint: "/cfapi/suggest-pr-changes",
operation: "invalid_review_range",
owner,
repo,
},
)
foundInvalidHunk = true
break
@ -778,39 +831,39 @@ export async function triggerSuggestPrChanges(
repo,
})
if (res) {
return res.status(422).json({
error: `Cannot create review comments due to ${reason}`,
message: "Please consider creating a dependent PR instead",
})
} else {
logger.error(`Cannot create review comments due to ${reason}`, {
userId,
endpoint: "/cfapi/suggest-pr-changes",
operation: "cannot_create_review",
owner,
repo,
})
return null
}
logger.error(`Cannot create review comments due to ${reason}`, {
userId,
endpoint: "/cfapi/suggest-pr-changes",
operation: "cannot_create_review",
owner,
repo,
})
throw unprocessableEntity(
`Cannot create review comments due to ${reason}. Please consider creating a dependent PR instead`,
)
}
}
} catch (error: any) {
logger.errorWithSentry(`Error in triggerSuggestPrChanges: ${error}`, {
userId,
endpoint: "/cfapi/suggest-pr-changes",
operation: "trigger_suggest_pr_changes",
owner,
repo,
}, {
errorMessage: error.message,
}, error as Error)
if (res) {
res.status(500).send(`Error creating pull request: ${error.message}`)
return res
// Re-throw AppExceptions to be handled by GlobalExceptionHandler
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
return null
logger.errorWithSentry(
`Error in triggerSuggestPrChanges: ${error}`,
{
userId,
endpoint: "/cfapi/suggest-pr-changes",
operation: "trigger_suggest_pr_changes",
owner,
repo,
},
{
errorMessage: error.message,
},
error as Error,
)
throw internalServerError(`Error creating pull request: ${error.message}`)
}
}

View file

@ -46,64 +46,69 @@ describe("isGitHubAppInstalled", () => {
})
describe("input validation", () => {
it("should return 400 when owner is missing", async () => {
it("should throw missingRequiredFields when owner is missing", async () => {
mockReq = {
query: { repo: "test-repo" },
userId: "test-user-id",
}
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Missing required fields"),
})
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.send).toHaveBeenCalledWith("Missing owner or repo query parameters")
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 when repo is missing", async () => {
it("should throw missingRequiredFields when repo is missing", async () => {
mockReq = {
query: { owner: "test-owner" },
userId: "test-user-id",
}
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Missing required fields"),
})
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.send).toHaveBeenCalledWith("Missing owner or repo query parameters")
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 when both owner and repo are missing", async () => {
it("should throw missingRequiredFields when both owner and repo are missing", async () => {
mockReq = {
query: {},
userId: "test-user-id",
}
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Missing required fields"),
})
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.send).toHaveBeenCalledWith("Missing owner or repo query parameters")
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 when owner is empty string", async () => {
it("should throw validationFailure when owner is empty string", async () => {
mockReq = {
query: { owner: " ", repo: "test-repo" },
userId: "test-user-id",
}
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Validation Failure"),
})
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.send).toHaveBeenCalledWith("Invalid owner or repo query parameters")
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 when repo is empty string", async () => {
it("should throw validationFailure when repo is empty string", async () => {
mockReq = {
query: { owner: "test-owner", repo: " " },
userId: "test-user-id",
}
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Validation Failure"),
})
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.send).toHaveBeenCalledWith("Invalid owner or repo query parameters")
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should trim whitespace from owner and repo", async () => {
@ -134,25 +139,27 @@ describe("isGitHubAppInstalled", () => {
}
})
it("should return 401 when user nickname is null", async () => {
it("should throw unauthorized when user nickname is null", async () => {
mockDependencies.userNickname.mockResolvedValue(null)
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Unauthorized"),
})
expect(mockDependencies.userNickname).toHaveBeenCalledWith("test-user-id")
expect(mockRes.status).toHaveBeenCalledWith(401)
expect(mockRes.send).toHaveBeenCalledWith("Unauthorized")
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 401 when getInstallationOctokitByOwner returns Error", async () => {
it("should throw githubInstallationError when getInstallationOctokitByOwner returns Error", async () => {
const error = new Error("Installation not found")
mockDependencies.userNickname.mockResolvedValue("test-nickname")
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(error)
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Installation not found"),
})
expect(mockRes.status).toHaveBeenCalledWith(401)
expect(mockRes.send).toHaveBeenCalledWith("Installation not found")
expect(mockRes.status).not.toHaveBeenCalled()
})
})
@ -167,10 +174,12 @@ describe("isGitHubAppInstalled", () => {
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue({})
})
it("should return 403 when user is not a collaborator", async () => {
it("should throw githubNotCollaborator when user is not a collaborator", async () => {
mockDependencies.isUserCollaborator.mockResolvedValue(false)
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("not a collaborator"),
})
expect(mockDependencies.isUserCollaborator).toHaveBeenCalledWith(
{},
@ -178,10 +187,7 @@ describe("isGitHubAppInstalled", () => {
"test-repo",
"test-nickname",
)
expect(mockRes.status).toHaveBeenCalledWith(403)
expect(mockRes.send).toHaveBeenCalledWith(
"The authenticated user is not a collaborator on the repository test-owner/test-repo",
)
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return true when user is a collaborator", async () => {
@ -207,21 +213,20 @@ describe("isGitHubAppInstalled", () => {
}
})
it("should return 404 when error status is 404", async () => {
it("should throw githubInstallationNotFound when error status is 404", async () => {
const error = { status: 404, message: "Not found" }
mockDependencies.userNickname.mockResolvedValue("test-nickname")
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue({})
mockDependencies.isUserCollaborator.mockRejectedValue(error)
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("GitHub installation not found"),
})
expect(mockRes.status).toHaveBeenCalledWith(404)
expect(mockRes.send).toHaveBeenCalledWith(
"GitHub App is not installed on the repository test-owner/test-repo",
)
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 500 for other errors from isUserCollaborator", async () => {
it("should throw internalServerError for other errors from isUserCollaborator", async () => {
const error = new Error("Unexpected error")
mockDependencies.userNickname.mockResolvedValue("test-nickname")
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue({})
@ -229,57 +234,42 @@ describe("isGitHubAppInstalled", () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Error checking GitHub App installation"),
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error checking GitHub App installation or collaborator status:",
expect.anything(),
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.send).toHaveBeenCalledWith(
"Error checking GitHub App installation or collaborator status",
)
expect(mockRes.status).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it("should handle error from userNickname", async () => {
it("should throw internalServerError for error from userNickname", async () => {
const error = new Error("Auth0 error")
mockDependencies.userNickname.mockRejectedValue(error)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Error checking GitHub App installation"),
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error checking GitHub App installation or collaborator status:",
expect.anything(),
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.send).toHaveBeenCalledWith(
"Error checking GitHub App installation or collaborator status",
)
expect(mockRes.status).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it("should handle error from getInstallationOctokitByOwner", async () => {
it("should throw internalServerError for error from getInstallationOctokitByOwner", async () => {
const error = new Error("GitHub API error")
mockDependencies.userNickname.mockResolvedValue("test-nickname")
mockDependencies.getInstallationOctokitByOwner.mockRejectedValue(error)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
await expect(isGitHubAppInstalled(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Error checking GitHub App installation"),
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error checking GitHub App installation or collaborator status:",
expect.anything(),
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.send).toHaveBeenCalledWith(
"Error checking GitHub App installation or collaborator status",
)
expect(mockRes.status).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})

View file

@ -36,22 +36,20 @@ describe("optimizationSuccess", () => {
})
describe("input validation", () => {
it("should return 400 when trace_id is undefined", async () => {
it("should throw validation error when trace_id is undefined", async () => {
mockReq = {
body: {
is_optimization_found: true,
},
} as Request
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Invalid input: trace_id and is_optimization_found(boolean) are required.",
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("trace_id and is_optimization_found(boolean) are required"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 when trace_id is null", async () => {
it("should throw validation error when trace_id is null", async () => {
mockReq = {
body: {
trace_id: null,
@ -59,30 +57,26 @@ describe("optimizationSuccess", () => {
},
} as Request
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Invalid input: trace_id and is_optimization_found(boolean) are required.",
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("trace_id and is_optimization_found(boolean) are required"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 when is_optimization_found is undefined", async () => {
it("should throw validation error when is_optimization_found is undefined", async () => {
mockReq = {
body: {
trace_id: "test-trace-id",
},
} as Request
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Invalid input: trace_id and is_optimization_found(boolean) are required.",
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("trace_id and is_optimization_found(boolean) are required"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 when is_optimization_found is not a boolean", async () => {
it("should throw validation error when is_optimization_found is not a boolean", async () => {
mockReq = {
body: {
trace_id: "test-trace-id",
@ -90,15 +84,13 @@ describe("optimizationSuccess", () => {
},
} as Request
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Invalid input: trace_id and is_optimization_found(boolean) are required.",
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("trace_id and is_optimization_found(boolean) are required"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 when is_optimization_found is null", async () => {
it("should throw validation error when is_optimization_found is null", async () => {
mockReq = {
body: {
trace_id: "test-trace-id",
@ -106,25 +98,21 @@ describe("optimizationSuccess", () => {
},
} as Request
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Invalid input: trace_id and is_optimization_found(boolean) are required.",
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("trace_id and is_optimization_found(boolean) are required"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 when both fields are missing", async () => {
it("should throw validation error when both fields are missing", async () => {
mockReq = {
body: {},
} as Request
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Invalid input: trace_id and is_optimization_found(boolean) are required.",
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("trace_id and is_optimization_found(boolean) are required"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should accept valid input with is_optimization_found as true", async () => {
@ -199,17 +187,18 @@ describe("optimizationSuccess", () => {
expect(mockRes.json).toHaveBeenCalledWith({ message: "Optimization status updated." })
})
it("should return 404 when no optimization event is found", async () => {
it("should throw not found error when no optimization event is found", async () => {
mockDependencies.prisma.optimization_events.updateMany.mockResolvedValue({ count: 0 })
await optimizationSuccess(mockReq as Request, mockRes as Response)
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("not found"),
})
expect(mockDependencies.prisma.optimization_events.updateMany).toHaveBeenCalledWith({
where: { trace_id: "test-trace-id" },
data: { is_optimization_found: true },
})
expect(mockRes.status).toHaveBeenCalledWith(404)
expect(mockRes.json).toHaveBeenCalledWith({ error: "Optimization event not found." })
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should handle multiple records updated", async () => {
@ -225,22 +214,14 @@ describe("optimizationSuccess", () => {
expect(mockRes.json).toHaveBeenCalledWith({ message: "Optimization status updated." })
})
it("should handle database error", async () => {
it("should throw internal server error on database error", async () => {
const error = new Error("Database connection failed")
mockDependencies.prisma.optimization_events.updateMany.mockRejectedValue(error)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error in markOptimizationSuccess:",
expect.anything(),
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.json).toHaveBeenCalledWith({ error: "Internal server error." })
consoleErrorSpy.mockRestore()
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Error"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
})
@ -297,12 +278,10 @@ describe("optimizationSuccess", () => {
},
} as Request
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Invalid input: trace_id and is_optimization_found(boolean) are required.",
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("trace_id and is_optimization_found(boolean) are required"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should reject is_optimization_found as number 1", async () => {
@ -313,12 +292,10 @@ describe("optimizationSuccess", () => {
},
} as Request
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Invalid input: trace_id and is_optimization_found(boolean) are required.",
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("trace_id and is_optimization_found(boolean) are required"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should reject is_optimization_found as array", async () => {
@ -329,12 +306,10 @@ describe("optimizationSuccess", () => {
},
} as Request
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Invalid input: trace_id and is_optimization_found(boolean) are required.",
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("trace_id and is_optimization_found(boolean) are required"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should reject is_optimization_found as object", async () => {
@ -345,12 +320,10 @@ describe("optimizationSuccess", () => {
},
} as Request
await optimizationSuccess(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Invalid input: trace_id and is_optimization_found(boolean) are required.",
await expect(optimizationSuccess(mockReq as Request, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("trace_id and is_optimization_found(boolean) are required"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
})
})

View file

@ -327,7 +327,7 @@ describe("handleSlackEvents", () => {
;(mockDependencies.crypto.timingSafeEqual as jest.MockedFunction<any>).mockReturnValue(true)
})
it("should return 403 when Slack request verification fails", async () => {
it("should throw forbidden exception when Slack request verification fails", async () => {
;(mockDependencies.crypto.timingSafeEqual as jest.MockedFunction<any>).mockReturnValue(false)
mockReq = createMockRequest({
@ -338,13 +338,14 @@ describe("handleSlackEvents", () => {
body: {},
})
await handleSlackEvents(mockReq, mockRes as Response)
await expect(handleSlackEvents(mockReq, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Access forbidden"),
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to verify Slack request"),
)
expect(mockRes.status).toHaveBeenCalledWith(403)
expect(mockRes.send).toHaveBeenCalledWith("Invalid request")
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should handle URL verification challenge", async () => {
@ -483,7 +484,7 @@ describe("handleSlackEvents", () => {
expect(mockDependencies.processReaction).not.toHaveBeenCalled()
})
it("should handle errors and capture them", async () => {
it("should throw internalServerError when errors occur during processing", async () => {
const error = new Error("Processing failed")
mockReq = createMockRequest({
@ -501,21 +502,13 @@ describe("handleSlackEvents", () => {
})
;(mockDependencies.processReaction as jest.MockedFunction<any>).mockRejectedValue(error)
await handleSlackEvents(mockReq, mockRes as Response)
await expect(handleSlackEvents(mockReq, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Error processing Slack event"),
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Error handling Slack event"),
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.send).toHaveBeenCalledWith("Error processing event")
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
expect(mockDependencies.posthog.capture).toHaveBeenCalledWith({
distinctId: "system",
event: "slack-optimization-approval-error",
properties: {
error: "Error: Processing failed",
},
})
})
it("should not send error response if headers already sent", async () => {
@ -556,7 +549,7 @@ describe("handleSlackEvents", () => {
expect(mockRes.status).toHaveBeenCalledWith(200)
})
it("should handle errors during verification", async () => {
it("should throw internalServerError when errors occur during verification", async () => {
const error = new Error("Verification failed")
;(mockDependencies.crypto.createHmac as jest.MockedFunction<any>).mockImplementation(() => {
throw error
@ -570,16 +563,17 @@ describe("handleSlackEvents", () => {
body: {},
})
await handleSlackEvents(mockReq, mockRes as Response)
await expect(handleSlackEvents(mockReq, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Error processing Slack event"),
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Error handling Slack event"),
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.send).toHaveBeenCalledWith("Error processing event")
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should handle invalid timestamp format gracefully", async () => {
it("should throw forbidden exception when invalid timestamp format", async () => {
// Force verification to fail by making timingSafeEqual return false
;(mockDependencies.crypto.timingSafeEqual as jest.MockedFunction<any>).mockReturnValue(false)
@ -591,11 +585,11 @@ describe("handleSlackEvents", () => {
body: {},
})
await handleSlackEvents(mockReq, mockRes as Response)
// Should fail verification and return 403
expect(mockRes.status).toHaveBeenCalledWith(403)
expect(mockRes.send).toHaveBeenCalledWith("Invalid request")
// Should fail verification and throw forbidden exception
await expect(handleSlackEvents(mockReq, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Access forbidden"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should handle complex event data with nested objects", async () => {
@ -671,7 +665,7 @@ describe("handleSlackEvents", () => {
expect(mockRes.status).toHaveBeenCalledWith(200)
})
it("should handle malformed JSON in error conversion", async () => {
it("should throw internalServerError when error occurs after response sent", async () => {
const circularError = {}
;(circularError as any).self = circularError // Create circular reference
@ -692,18 +686,14 @@ describe("handleSlackEvents", () => {
circularError,
)
await handleSlackEvents(mockReq, mockRes as Response)
expect(mockDependencies.posthog.capture).toHaveBeenCalledWith({
distinctId: "system",
event: "slack-optimization-approval-error",
properties: {
error: "[object Object]", // String() conversion of circular object
},
// Since this is an async error that happens after response is sent (status 200),
// it throws internalServerError but also sends posthog capture because headers were sent
await expect(handleSlackEvents(mockReq, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Error processing Slack event"),
})
})
it("should handle processReaction throwing synchronously", async () => {
it("should throw internalServerError when processReaction throws synchronously", async () => {
const error = new Error("Sync error")
mockReq = createMockRequest({
@ -723,15 +713,16 @@ describe("handleSlackEvents", () => {
throw error
})
await handleSlackEvents(mockReq, mockRes as Response)
await expect(handleSlackEvents(mockReq, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Error processing Slack event"),
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Error handling Slack event"),
)
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
})
it("should handle errors that occur before sending response", async () => {
it("should throw internalServerError when errors occur before sending response", async () => {
const error = new Error("Early error")
// Mock verification to fail with an error before any response is sent
@ -749,14 +740,14 @@ describe("handleSlackEvents", () => {
body: {},
})
await handleSlackEvents(mockReq, mockRes as Response)
await expect(handleSlackEvents(mockReq, mockRes as Response)).rejects.toMatchObject({
message: expect.stringContaining("Error processing Slack event"),
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Error handling Slack event"),
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.send).toHaveBeenCalledWith("Error processing event")
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
expect(mockRes.status).not.toHaveBeenCalled()
})
})

View file

@ -74,21 +74,13 @@ describe("Stripe Webhook Handler", () => {
it("should return 400 when webhook secret is not configured", async () => {
;(mockDependencies.getWebhookSecret as jest.MockedFunction<any>).mockReturnValue(undefined)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await stripeWebhookHandler(mockReq as Request, mockRes as Response)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Webhook Error:",
"STRIPE_WEBHOOK_SECRET is not configured",
)
await expect(
stripeWebhookHandler(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("STRIPE_WEBHOOK_SECRET is not configured"),
})
expect(mockDependencies.Sentry.captureException).toHaveBeenCalled()
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.send).toHaveBeenCalledWith(
"Webhook Error: STRIPE_WEBHOOK_SECRET is not configured",
)
consoleErrorSpy.mockRestore()
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 when webhook signature verification fails", async () => {
@ -99,21 +91,19 @@ describe("Stripe Webhook Handler", () => {
throw error
})
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await stripeWebhookHandler(mockReq as Request, mockRes as Response)
await expect(
stripeWebhookHandler(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Invalid signature"),
})
expect(mockDependencies.stripe.webhooks.constructEvent).toHaveBeenCalledWith(
"test-body",
"test-signature",
"test-webhook-secret",
)
expect(consoleErrorSpy).toHaveBeenCalledWith("Webhook Error:", "Invalid signature")
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.send).toHaveBeenCalledWith("Webhook Error: Invalid signature")
consoleErrorSpy.mockRestore()
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should process webhook event successfully", async () => {
@ -146,17 +136,9 @@ describe("Stripe Webhook Handler", () => {
metadata: { tier: "pro", optimizations: "1000" },
})
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await stripeWebhookHandler(mockReq as Request, mockRes as Response)
expect(consoleSpy).toHaveBeenCalledWith(
"Processing Stripe webhook: customer.subscription.created",
)
expect(consoleSpy).toHaveBeenCalledWith("Event ID: evt_test123")
expect(mockRes.json).toHaveBeenCalledWith({ received: true })
consoleSpy.mockRestore()
})
it("should handle unimplemented event types", async () => {
@ -172,16 +154,9 @@ describe("Stripe Webhook Handler", () => {
mockDependencies.stripe.webhooks.constructEvent as jest.MockedFunction<any>
).mockReturnValue(mockEvent)
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await stripeWebhookHandler(mockReq as Request, mockRes as Response)
expect(consoleSpy).toHaveBeenCalledWith(
"No handler implemented for event type: unknown.event.type",
)
expect(mockRes.json).toHaveBeenCalledWith({ received: true })
consoleSpy.mockRestore()
})
})
})
@ -194,17 +169,9 @@ describe("Stripe Webhook Handler", () => {
mode: "subscription",
}
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await handleCheckoutCompleted(session)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"No userId in checkout session metadata",
"cs_test123",
)
expect(mockDependencies.stripe.subscriptions.update).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it("should handle non-subscription checkout", async () => {
@ -214,16 +181,9 @@ describe("Stripe Webhook Handler", () => {
mode: "payment",
}
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await handleCheckoutCompleted(session)
expect(consoleSpy).toHaveBeenCalledWith(
"Processing checkout completion for user user_test123",
)
expect(mockDependencies.stripe.subscriptions.update).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it("should update subscription and customer metadata", async () => {
@ -256,8 +216,6 @@ describe("Stripe Webhook Handler", () => {
metadata: { tier: "pro", optimizations: "1000" },
})
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await handleCheckoutCompleted(session)
expect(mockDependencies.stripe.subscriptions.update).toHaveBeenCalledWith("sub_test123", {
@ -268,8 +226,6 @@ describe("Stripe Webhook Handler", () => {
})
expect(mockDependencies.stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_test123")
expect(mockDependencies.prisma.subscriptions.upsert).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it("should handle errors during checkout processing", async () => {
@ -286,20 +242,9 @@ describe("Stripe Webhook Handler", () => {
error,
)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await handleCheckoutCompleted(session)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error processing checkout session:",
expect.objectContaining({
message: "Stripe API error",
name: "Error",
}),
)
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
consoleErrorSpy.mockRestore()
})
})
@ -315,21 +260,10 @@ describe("Stripe Webhook Handler", () => {
metadata: {},
})
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await handleSubscriptionUpdate(subscription)
expect(mockDependencies.stripe.customers.retrieve).toHaveBeenCalledWith("cus_test123")
expect(consoleErrorSpy).toHaveBeenCalledWith(
"No userId found in subscription or customer metadata",
JSON.stringify({
subscription_id: "sub_test123",
customer_id: "cus_test123",
}),
)
expect(mockDependencies.prisma.subscriptions.upsert).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it("should get userId from customer metadata when not in subscription", async () => {
@ -356,16 +290,11 @@ describe("Stripe Webhook Handler", () => {
metadata: { tier: "enterprise", optimizations: "2000" },
})
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await handleSubscriptionUpdate(subscription)
expect(mockDependencies.stripe.customers.retrieve).toHaveBeenCalledWith("cus_test123")
expect(consoleSpy).toHaveBeenCalledWith("Updating subscription for user user_test123")
expect(mockDependencies.stripe.prices.retrieve).toHaveBeenCalledWith("price_test123")
expect(mockDependencies.prisma.subscriptions.upsert).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it("should handle customer retrieval error gracefully", async () => {
@ -380,20 +309,9 @@ describe("Stripe Webhook Handler", () => {
error,
)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await handleSubscriptionUpdate(subscription)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error retrieving customer:",
expect.objectContaining({
message: "Customer not found",
name: "Error",
}),
)
expect(mockDependencies.prisma.subscriptions.upsert).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it("should create new subscription record", async () => {
@ -418,8 +336,6 @@ describe("Stripe Webhook Handler", () => {
metadata: { tier: "pro", optimizations: "1000" },
})
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await handleSubscriptionUpdate(subscription)
expect(mockDependencies.stripe.prices.retrieve).toHaveBeenCalledWith("price_test123")
@ -440,11 +356,6 @@ describe("Stripe Webhook Handler", () => {
update: expect.any(Object),
}),
)
expect(consoleSpy).toHaveBeenCalledWith(
"Successfully updated subscription for user user_test123",
)
consoleSpy.mockRestore()
})
it("should handle cancel_at_period_end = true", async () => {
@ -561,20 +472,9 @@ describe("Stripe Webhook Handler", () => {
error,
)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await handleSubscriptionUpdate(subscription)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error updating subscription:",
expect.objectContaining({
message: "Database error",
name: "Error",
}),
)
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
consoleErrorSpy.mockRestore()
})
})
@ -596,8 +496,6 @@ describe("Stripe Webhook Handler", () => {
metadata: { userId: "user_test123" },
}
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await handleSubscriptionCancellation(subscription)
expect(mockDependencies.prisma.subscriptions.update).toHaveBeenCalledWith({
@ -611,9 +509,6 @@ describe("Stripe Webhook Handler", () => {
cancellation_request_date: null,
},
})
expect(consoleSpy).toHaveBeenCalledWith("Cancelled subscription for user user_test123")
consoleSpy.mockRestore()
})
it("should handle database errors during cancellation", async () => {
@ -627,20 +522,9 @@ describe("Stripe Webhook Handler", () => {
error,
)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await handleSubscriptionCancellation(subscription)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error cancelling subscription:",
expect.objectContaining({
message: "Database error",
name: "Error",
}),
)
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
consoleErrorSpy.mockRestore()
})
})
@ -687,8 +571,6 @@ describe("Stripe Webhook Handler", () => {
metadata: { userId: "user_test123" },
})
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await handleFailedPayment(invoice)
expect(mockDependencies.stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_test123")
@ -698,11 +580,6 @@ describe("Stripe Webhook Handler", () => {
subscription_status: "past_due",
},
})
expect(consoleSpy).toHaveBeenCalledWith(
"Updated subscription status to past_due for user user_test123",
)
consoleSpy.mockRestore()
})
it("should handle errors during failed payment processing", async () => {
@ -722,20 +599,9 @@ describe("Stripe Webhook Handler", () => {
error,
)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await handleFailedPayment(invoice)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error handling failed payment:",
expect.objectContaining({
message: "Database error",
name: "Error",
}),
)
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
consoleErrorSpy.mockRestore()
})
it("should handle Stripe API errors when retrieving subscription", async () => {
@ -749,21 +615,10 @@ describe("Stripe Webhook Handler", () => {
mockDependencies.stripe.subscriptions.retrieve as jest.MockedFunction<any>
).mockRejectedValue(error)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await handleFailedPayment(invoice)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error handling failed payment:",
expect.objectContaining({
message: "Subscription not found",
name: "Error",
}),
)
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
expect(mockDependencies.prisma.subscriptions.update).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})
})

View file

@ -93,26 +93,34 @@ describe("Subscription Handlers", () => {
})
describe("getSubscription", () => {
it("should return 400 when userId is missing", async () => {
it("should call next with missingRequiredFields when userId is missing", async () => {
req.query = {}
await getSubscription(req as Request, res as Response, next as unknown as NextFunction)
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "User ID is required" })
expect(next).not.toHaveBeenCalled()
expect(next).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Missing required fields"),
}),
)
expect(res.status).not.toHaveBeenCalled()
expect(res.json).not.toHaveBeenCalled()
})
it("should return 404 when subscription is not found", async () => {
it("should call next with subscriptionNotFound when subscription is not found", async () => {
req.query = { userId: "user123" }
;(mockDependencies.fetchSubscription as any).mockResolvedValueOnce(null)
await getSubscription(req as Request, res as Response, next as unknown as NextFunction)
expect(mockDependencies.fetchSubscription).toHaveBeenCalledWith("user123")
expect(res.status).toHaveBeenCalledWith(404)
expect(res.json).toHaveBeenCalledWith({ error: "Subscription not found" })
expect(next).not.toHaveBeenCalled()
expect(next).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Subscription not found"),
}),
)
expect(res.status).not.toHaveBeenCalled()
expect(res.json).not.toHaveBeenCalled()
})
it("should return subscription details successfully", async () => {
@ -162,24 +170,32 @@ describe("Subscription Handlers", () => {
})
describe("createCheckout", () => {
it("should return 400 when userId is missing", async () => {
it("should call next with missingRequiredFields when userId is missing", async () => {
req.body = { priceId: "price123" }
await createCheckout(req as Request, res as Response, next as unknown as NextFunction)
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "User ID and price ID are required" })
expect(next).not.toHaveBeenCalled()
expect(next).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Missing required fields"),
}),
)
expect(res.status).not.toHaveBeenCalled()
expect(res.json).not.toHaveBeenCalled()
})
it("should return 400 when priceId is missing", async () => {
it("should call next with missingRequiredFields when priceId is missing", async () => {
req.body = { userId: "user123" }
await createCheckout(req as Request, res as Response, next as unknown as NextFunction)
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "User ID and price ID are required" })
expect(next).not.toHaveBeenCalled()
expect(next).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Missing required fields"),
}),
)
expect(res.status).not.toHaveBeenCalled()
expect(res.json).not.toHaveBeenCalled()
})
it("should create checkout session with default URLs", async () => {
@ -264,14 +280,18 @@ describe("Subscription Handlers", () => {
})
describe("cancelSubscription", () => {
it("should return 400 when userId is missing", async () => {
it("should call next with missingRequiredFields when userId is missing", async () => {
req.body = {}
await cancelSubscription(req as Request, res as Response, next as unknown as NextFunction)
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith({ error: "User ID is required" })
expect(next).not.toHaveBeenCalled()
expect(next).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Missing required fields"),
}),
)
expect(res.status).not.toHaveBeenCalled()
expect(res.json).not.toHaveBeenCalled()
})
it("should cancel subscription successfully", async () => {

View file

@ -82,10 +82,12 @@ describe("Suggest PR Changes", () => {
userId: "test-user-id",
} as AuthorizedUserReq
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.send).toHaveBeenCalledWith("Missing or malformed fields")
await expect(
suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Missing or malformed fields"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 for malformed diff contents", async () => {
@ -100,10 +102,12 @@ describe("Suggest PR Changes", () => {
} as AuthorizedUserReq
mockDependencies.isDiffContentsWellFormed.mockReturnValue(false)
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.send).toHaveBeenCalledWith("Missing or malformed fields")
await expect(
suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Missing or malformed fields"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
})
@ -125,10 +129,12 @@ describe("Suggest PR Changes", () => {
it("should return 401 when user nickname is null", async () => {
mockDependencies.userNickname.mockResolvedValue(null)
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(401)
expect(mockRes.json).toHaveBeenCalledWith({ error: "Unauthorized" })
await expect(
suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Unauthorized"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 401 when installation octokit fails", async () => {
@ -137,26 +143,27 @@ describe("Suggest PR Changes", () => {
new Error("Installation error"),
)
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(401)
expect(mockRes.send).toHaveBeenCalledWith("Installation error")
await expect(
suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Installation error"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 401 when user is not a collaborator", async () => {
it("should return 403 when user is not a collaborator", async () => {
mockDependencies.userNickname.mockResolvedValue("test-user")
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue({})
mockDependencies.isUserCollaborator.mockResolvedValue(false)
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("test-user is not a collaborator on test-owner/test-repo"),
)
expect(mockRes.status).toHaveBeenCalledWith(401)
expect(mockRes.json).toHaveBeenCalledWith({ error: "Unauthorized" })
await expect(
suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("not a collaborator"),
})
expect(mockRes.status).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
@ -180,13 +187,12 @@ describe("Suggest PR Changes", () => {
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Rejecting request for roboflow repository"),
)
expect(mockRes.status).toHaveBeenCalledWith(401)
expect(mockRes.send).toHaveBeenCalledWith("Unauthorized for roboflow repositories")
await expect(
suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Unauthorized for roboflow repositories"),
})
expect(mockRes.status).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
@ -240,13 +246,12 @@ describe("Suggest PR Changes", () => {
approval_status: "rejected",
})
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(403)
expect(mockRes.json).toHaveBeenCalledWith({
status: "rejected",
message: "This optimization request was rejected.",
await expect(
suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("rejected"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should proceed with approved requests", async () => {
@ -255,6 +260,38 @@ describe("Suggest PR Changes", () => {
approval_status: "approved",
})
// Set up valid hunks with proper line ranges so triggerSuggestPrChanges can succeed
const validHunks = new Map([
[
"file1.js",
[{ oldStart: 1, oldEnd: 5, newContent: ["new code line 1", "new code line 2"] }],
],
])
mockDependencies.determineValidHunks.mockResolvedValue({
validHunks,
invalidHunks: new Map(),
})
// Mock the installation octokit to also include createReview
const mockInstallationOctokit = {
rest: {
pulls: {
get: jest.fn() as any,
createReview: jest.fn() as any,
},
},
}
mockInstallationOctokit.rest.pulls.get.mockResolvedValue({
data: { head: { ref: "feature-branch", sha: "abc123" } },
})
mockInstallationOctokit.rest.pulls.createReview.mockResolvedValue({
data: {
id: 789,
html_url: "https://github.com/test/test/pull/1#pullrequestreview-789",
},
})
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(mockInstallationOctokit)
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
@ -313,11 +350,11 @@ describe("Suggest PR Changes", () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Error in /cfapi/suggest-pr-changes"),
)
await expect(
suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Error creating pull request"),
})
expect(mockDependencies.posthog.capture).toHaveBeenCalledWith({
distinctId: "test-user-id",
event: "cfapi-suggest-pr-changes-failed-error",
@ -326,8 +363,7 @@ describe("Suggest PR Changes", () => {
stack: error.stack,
},
})
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.send).toHaveBeenCalledWith(`Error creating pull request: ${error.message}`)
expect(mockRes.status).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
@ -655,37 +691,31 @@ describe("Suggest PR Changes", () => {
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {})
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
const result = await triggerSuggestPrChanges(
"test-owner",
"test-repo",
1,
[],
{
function_name: "testFunc",
speedup_pct: 50,
speedup_x: 2,
optimization_explanation: "test",
},
"existing tests",
"generated tests",
"coverage message",
"user123",
"testuser",
mockInstallationOctokit,
"replay tests",
"concolic tests",
"trace123",
)
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Found invalid review range for file1.js: start_line (5) must be less than line (1) with content new code",
await expect(
triggerSuggestPrChanges(
"test-owner",
"test-repo",
1,
[],
{
function_name: "testFunc",
speedup_pct: 50,
speedup_x: 2,
optimization_explanation: "test",
},
"existing tests",
"generated tests",
"coverage message",
"user123",
"testuser",
mockInstallationOctokit,
"replay tests",
"concolic tests",
"trace123",
),
)
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Cannot create review due to invalid line ordering in hunks"),
)
expect(result).toBe(null)
).rejects.toMatchObject({
message: expect.stringContaining("Cannot create review comments"),
})
consoleLogSpy.mockRestore()
consoleWarnSpy.mockRestore()
@ -715,37 +745,34 @@ describe("Suggest PR Changes", () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
const result = await triggerSuggestPrChanges(
"test-owner",
"test-repo",
1,
[],
{
function_name: "testFunc",
speedup_pct: 50,
speedup_x: 2,
optimization_explanation: "test",
},
"existing tests",
"generated tests",
"coverage message",
"user123",
"testuser",
mockInstallationOctokit,
"replay tests",
"concolic tests",
"trace123",
"low",
mockRes as Response,
)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Error in triggerSuggestPrChanges:"),
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.send).toHaveBeenCalledWith(
expect.stringContaining("Error creating pull request:"),
)
await expect(
triggerSuggestPrChanges(
"test-owner",
"test-repo",
1,
[],
{
function_name: "testFunc",
speedup_pct: 50,
speedup_x: 2,
optimization_explanation: "test",
},
"existing tests",
"generated tests",
"coverage message",
"user123",
"testuser",
mockInstallationOctokit,
"replay tests",
"concolic tests",
"trace123",
"low",
mockRes as Response,
),
).rejects.toMatchObject({
message: expect.stringContaining("Error creating pull request"),
})
expect(mockRes.status).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})

View file

@ -76,47 +76,59 @@ describe("verifyExistingOptimizations", () => {
})
describe("input validation", () => {
it("should return 400 for missing required fields", async () => {
it("should throw missingRequiredFields for missing required fields", async () => {
mockReq.body = { repo_owner: "test-owner" } // Missing repo_name and pr_number
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Missing required fields"),
})
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({ error: "Missing or malformed fields" })
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 400 for zero PR number", async () => {
it("should throw missingRequiredFields for zero PR number", async () => {
mockReq.body = {
repo_owner: "test-owner",
repo_name: "test-repo",
pr_number: 0, // 0 is falsy and should fail validation
}
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Missing required fields"),
})
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({ error: "Missing or malformed fields" })
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 401 when user nickname is null", async () => {
it("should throw unauthorized when user nickname is null", async () => {
mockDependencies.userNickname.mockResolvedValue(null)
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Unauthorized"),
})
expect(mockRes.status).toHaveBeenCalledWith(401)
expect(mockRes.json).toHaveBeenCalledWith({ error: "Unauthorized" })
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should return 500 when installation octokit fails", async () => {
it("should throw githubInstallationError when installation octokit fails", async () => {
mockDependencies.userNickname.mockResolvedValue("test-user")
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(
new Error("Installation error"),
)
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Installation error"),
})
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.json).toHaveBeenCalledWith({ error: "Installation error" })
expect(mockRes.status).not.toHaveBeenCalled()
})
})
@ -126,17 +138,18 @@ describe("verifyExistingOptimizations", () => {
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(mockOctokit)
})
it("should return 401 when user is not a collaborator", async () => {
it("should throw githubNotCollaborator when user is not a collaborator", async () => {
mockDependencies.isUserCollaborator.mockResolvedValue(false)
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(401)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Unauthorized - User is not a collaborator",
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("not a collaborator"),
})
expect(mockRes.status).not.toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalledWith(
"test-user is not a collaborator on test-owner/test-repo",
)
@ -144,16 +157,19 @@ describe("verifyExistingOptimizations", () => {
consoleSpy.mockRestore()
})
it("should return 500 when collaborator check fails", async () => {
it("should throw internalServerError when collaborator check fails", async () => {
const error = new Error("API error")
mockDependencies.isUserCollaborator.mockRejectedValue(error)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Failed to verify collaborator status"),
})
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.json).toHaveBeenCalledWith({ error: "Failed to verify collaborator status" })
expect(mockRes.status).not.toHaveBeenCalled()
// Fix: console.error concatenates the error as a string
expect(consoleErrorSpy).toHaveBeenCalledWith(`Error checking collaborator status: ${error}`)
@ -168,33 +184,38 @@ describe("verifyExistingOptimizations", () => {
mockDependencies.isUserCollaborator.mockResolvedValue(true)
})
it("should return 404 when PR is not found", async () => {
it("should throw githubPrNotFound when PR is not found", async () => {
const error = new Error("Not found")
;(error as any).status = 404
mockOctokit.rest.pulls.get.mockRejectedValue(error)
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(404)
expect(mockRes.json).toHaveBeenCalledWith({
error: "PR #123 not found for test-owner/test-repo",
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Pull request not found"),
})
expect(mockRes.status).not.toHaveBeenCalled()
})
it("should handle non-404 PR retrieval errors", async () => {
it("should throw internalServerError for non-404 PR retrieval errors", async () => {
const error = new Error("API error")
;(error as any).status = 500
mockOctokit.rest.pulls.get.mockRejectedValue(error)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Error verifying existing optimizations"),
})
// Fix: console.error concatenates the error as a string
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Error in /cfapi/verify-existing-optimizations: ${error}`,
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.status).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
@ -228,50 +249,38 @@ describe("verifyExistingOptimizations", () => {
})
})
it("should handle PR comments retrieval failure", async () => {
it("should throw internalServerError when PR comments retrieval fails", async () => {
const error = new Error("Comments API error")
mockOctokit.rest.issues.listComments.mockRejectedValue(error)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error getting PR messages:",
expect.objectContaining({
message: "Comments API error",
name: "Error",
}),
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Failed to retrieve PR comments for test-owner/test-repo",
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Failed to retrieve PR comments"),
})
expect(mockRes.status).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it("should handle PR reviews retrieval failure", async () => {
it("should throw internalServerError when PR reviews retrieval fails", async () => {
mockOctokit.rest.issues.listComments.mockResolvedValue({ data: [] })
const error = new Error("Reviews API error")
mockOctokit.rest.pulls.listReviews.mockRejectedValue(error)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error getting PR reviews:",
expect.objectContaining({
message: "Reviews API error",
name: "Error",
}),
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Failed to retrieve PR reviews for test-owner/test-repo",
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Failed to retrieve PR reviews"),
})
expect(mockRes.status).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
@ -451,38 +460,40 @@ describe("verifyExistingOptimizations", () => {
mockDependencies.isUserCollaborator.mockResolvedValue(true)
})
it("should handle general errors with Error objects", async () => {
it("should throw internalServerError for general errors with Error objects", async () => {
const error = new Error("Unexpected error")
mockOctokit.rest.pulls.get.mockRejectedValue(error)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Error verifying existing optimizations"),
})
// Fix: console.error concatenates the error as a string
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Error in /cfapi/verify-existing-optimizations: ${error}`,
)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Error verifying existing optimizations: Unexpected error",
})
expect(mockRes.status).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it("should handle non-Error exceptions", async () => {
it("should throw internalServerError for non-Error exceptions", async () => {
mockOctokit.rest.pulls.get.mockRejectedValue("String error")
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Error verifying existing optimizations",
await expect(
verifyExistingOptimizations(mockReq as Request, mockRes as Response),
).rejects.toMatchObject({
message: expect.stringContaining("Error verifying existing optimizations"),
})
expect(mockRes.status).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})

View file

@ -5,6 +5,14 @@ import { parseAndCreateOptimizationsDict } from "../github/pr-changes-utils.js"
import { posthog } from "../analytics.js"
import { userNickname } from "../auth0-mgmt.js"
import { logger } from "../utils/logger.js"
import {
missingRequiredFields,
unauthorized,
githubInstallationError,
githubNotCollaborator,
githubPrNotFound,
internalServerError,
} from "../exceptions/index.js"
// Dependencies interface for easier testing
export interface VerifyExistingOptimizationsDependencies {
@ -60,12 +68,12 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
})
if (!repo_name || !repo_owner || !pr_number) {
return res.status(400).json({ error: "Missing or malformed fields" })
throw missingRequiredFields("repo_name, repo_owner, pr_number")
}
const nickname: string | null = await dependencies.userNickname(userId)
if (nickname == null) {
return res.status(401).json({ error: "Unauthorized" })
throw unauthorized("")
}
const octokit = await dependencies.getInstallationOctokitByOwner(
@ -74,7 +82,7 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
repo_name,
)
if (octokit instanceof Error) {
return res.status(500).json({ error: octokit.message })
throw githubInstallationError(octokit.message)
}
logger.info("Got installation Octokit for repository", {
@ -105,9 +113,12 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
repo_name,
nickname,
})
return res.status(401).json({ error: "Unauthorized - User is not a collaborator" })
throw githubNotCollaborator(`${repo_owner}/${repo_name}`)
}
} catch (error) {
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
// Concatenate error in message for tests
logger.error(`Error checking collaborator status: ${error}`, {
requestId: (req as any).requestId,
@ -118,7 +129,7 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
repo_name,
nickname,
})
return res.status(500).json({ error: "Failed to verify collaborator status" })
throw internalServerError("Failed to verify collaborator status")
}
// Get PR with specific 404 handling
@ -131,9 +142,7 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
})
} catch (error: any) {
if (error.status === 404) {
return res.status(404).json({
error: `PR #${pr_number} not found for ${repo_owner}/${repo_name}`,
})
throw githubPrNotFound(`#${pr_number} in ${repo_owner}/${repo_name}`)
}
throw error // Re-throw to be caught by global handler
}
@ -162,9 +171,7 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
undefined,
error as Error,
)
return res.status(500).json({
error: `Failed to retrieve PR comments for ${repo_owner}/${repo_name}`,
})
throw internalServerError(`Failed to retrieve PR comments for ${repo_owner}/${repo_name}`)
}
// Get PR reviews with error handling
@ -191,9 +198,7 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
undefined,
error as Error,
)
return res.status(500).json({
error: `Failed to retrieve PR reviews for ${repo_owner}/${repo_name}`,
})
throw internalServerError(`Failed to retrieve PR reviews for ${repo_owner}/${repo_name}`)
}
const reviewBodies: { body: string }[] = []
@ -297,6 +302,11 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
return res.status(200).json(response_dict)
} catch (error) {
// Re-throw AppExceptions to be handled by GlobalExceptionHandler
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
// Emit specific console message expected by tests (stringified for single-arg output)
// Note: Error object is still passed for Sentry, but test mode outputs stringified version
// Use String(error) to match test expectation of ${error} format
@ -318,13 +328,9 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
)
if (error instanceof Error) {
return res.status(500).json({
error: `Error verifying existing optimizations: ${error.message}`,
})
throw internalServerError(`Error verifying existing optimizations: ${error.message}`)
} else {
return res.status(500).json({
error: "Error verifying existing optimizations",
})
throw internalServerError("Error verifying existing optimizations")
}
}
}

View file

@ -0,0 +1,9 @@
/**
* Actions to take when an error occurs
*/
export enum AppErrorAction {
/** Default action - log locally only */
DEFAULT = "DEFAULT",
/** Log externally to error tracking service (e.g., Sentry) */
LOG_EXTERNALLY = "LOG_EXTERNALLY",
}

View file

@ -0,0 +1,205 @@
/**
* Application error codes with unique identifiers and descriptions
*/
export interface AppErrorCodeDefinition {
code: string
description: string
}
export const AppErrorCode = {
// Generic Errors
GENERIC_NOT_FOUND: {
code: "CF-GEN-404",
description: "The requested resource was not found",
},
GENERIC_BAD_REQUEST: {
code: "CF-GEN-400",
description: "The request was invalid or cannot be processed",
},
UNAUTHORIZED_ACCESS: {
code: "CF-GEN-403",
description: "Unauthorized access",
},
GENERIC_INTERNAL_SERVER_ERROR: {
code: "CF-GEN-500",
description: "An unexpected error occurred",
},
// Validation Errors
INVALID_PARAMETER: {
code: "CF-VAL-4001",
description: "Invalid parameter provided",
},
VALIDATION_FAILURE: {
code: "CF-VAL-4002",
description: "Validation failure",
},
PAYLOAD_TOO_LARGE: {
code: "CF-VAL-4003",
description: "Payload too large",
},
FILE_SIZE_EXCEEDED: {
code: "CF-VAL-4004",
description: "File size exceeded",
},
INVALID_FILE_TYPE: {
code: "CF-VAL-4005",
description: "Invalid file type",
},
MISSING_REQUIRED_FIELDS: {
code: "CF-VAL-4006",
description: "Missing required fields",
},
CONFLICT: {
code: "CF-VAL-4009",
description: "Resource conflict",
},
UNPROCESSABLE_ENTITY: {
code: "CF-VAL-4022",
description: "Unprocessable entity",
},
// Authentication Errors
JWT_AUTHENTICATION_FAILURE: {
code: "CF-AUTH-4001",
description: "JWT authentication failure",
},
INVALID_REFRESH_TOKEN: {
code: "CF-AUTH-4002",
description: "Refresh token is invalid or expired",
},
INVALID_API_KEY: {
code: "CF-AUTH-4003",
description: "Invalid API key",
},
API_KEY_EXPIRED: {
code: "CF-AUTH-4004",
description: "API key has expired",
},
USER_DISABLED: {
code: "CF-AUTH-4005",
description: "User account is disabled",
},
MISSING_AUTHORIZATION_HEADER: {
code: "CF-AUTH-4006",
description: "Authorization header is missing",
},
MISSING_USER_ID: {
code: "CF-AUTH-4007",
description: "User ID is missing",
},
UNAUTHORIZED: {
code: "CF-AUTH-4008",
description: "Unauthorized access",
},
FORBIDDEN: {
code: "CF-AUTH-4009",
description: "Access forbidden",
},
// Rate Limiting
TOO_MANY_REQUESTS: {
code: "CF-RATE-429",
description: "Too many requests",
},
// GitHub Integration Errors
GITHUB_API_ERROR: {
code: "CF-GH-5001",
description: "GitHub API error",
},
GITHUB_INSTALLATION_NOT_FOUND: {
code: "CF-GH-4001",
description: "GitHub installation not found",
},
GITHUB_REPOSITORY_NOT_FOUND: {
code: "CF-GH-4002",
description: "GitHub repository not found",
},
GITHUB_WEBHOOK_VERIFICATION_FAILED: {
code: "CF-GH-4003",
description: "GitHub webhook verification failed",
},
GITHUB_NOT_COLLABORATOR: {
code: "CF-GH-4004",
description: "User is not a collaborator on the repository",
},
GITHUB_BRANCH_NOT_FOUND: {
code: "CF-GH-4005",
description: "Branch not found",
},
GITHUB_PR_NOT_FOUND: {
code: "CF-GH-4006",
description: "Pull request not found",
},
GITHUB_INSTALLATION_ERROR: {
code: "CF-GH-4007",
description: "GitHub App installation error",
},
// Optimization Errors
OPTIMIZATION_NOT_FOUND: {
code: "CF-OPT-4001",
description: "Optimization not found",
},
OPTIMIZATION_ALREADY_EXISTS: {
code: "CF-OPT-4002",
description: "Optimization already exists",
},
OPTIMIZATION_LIMIT_EXCEEDED: {
code: "CF-OPT-4003",
description: "Optimization limit exceeded",
},
OPTIMIZATION_REJECTED: {
code: "CF-OPT-4004",
description: "Optimization request was rejected",
},
OPTIMIZATION_APPROVAL_ERROR: {
code: "CF-OPT-4005",
description: "Error checking approval status",
},
// Subscription Errors
SUBSCRIPTION_NOT_FOUND: {
code: "CF-SUB-4001",
description: "Subscription not found",
},
SUBSCRIPTION_EXPIRED: {
code: "CF-SUB-4002",
description: "Subscription has expired",
},
SUBSCRIPTION_LIMIT_REACHED: {
code: "CF-SUB-4003",
description: "Subscription limit reached",
},
SUBSCRIPTION_INACTIVE: {
code: "CF-SUB-4004",
description: "Subscription is not active",
},
// Database Errors
DATABASE_CONNECTION_ERROR: {
code: "CF-DB-5001",
description: "Database connection error",
},
DATABASE_QUERY_ERROR: {
code: "CF-DB-5002",
description: "Database query error",
},
DUPLICATE_KEY_ERROR: {
code: "CF-DB-4001",
description: "Duplicate key error",
},
// External Service Errors
EXTERNAL_SERVICE_ERROR: {
code: "CF-EXT-5001",
description: "External service error",
},
EXTERNAL_SERVICE_TIMEOUT: {
code: "CF-EXT-5002",
description: "External service timeout",
},
} as const
export type AppErrorCodeKey = keyof typeof AppErrorCode

View file

@ -0,0 +1,484 @@
import { AppErrorAction } from "./app-error-action.js"
import { AppErrorCode, AppErrorCodeKey } from "./app-error-code.js"
import { ErrorType } from "./error-type.js"
/**
* Application error definition with all metadata
*/
export interface AppErrorDefinition {
/** HTTP status code */
httpErrorCode: number
/** Unique application error code */
appErrorCode: string
/** Error message template (supports {0}, {1}, etc. placeholders) */
message: string
/** Action to take when error occurs */
errorAction: AppErrorAction
/** Short title for the error */
title: string
/** Error type category */
errorType: ErrorType
/** Reference documentation URL (optional) */
referenceDoc: string | null
}
/**
* Format a message template with arguments
* Supports placeholders like {0}, {1}, etc.
*/
function formatMessage(template: string, args: unknown[]): string {
return template.replace(/\{(\d+)\}/g, (match, index) => {
const argIndex = parseInt(index, 10)
return argIndex < args.length ? String(args[argIndex]) : match
})
}
/**
* Helper function to create an error definition
*/
function createError(
httpErrorCode: number,
errorCodeKey: AppErrorCodeKey,
message: string,
errorAction: AppErrorAction,
title: string,
errorType: ErrorType,
referenceDoc: string | null = null,
): AppErrorDefinition {
return {
httpErrorCode,
appErrorCode: AppErrorCode[errorCodeKey].code,
message,
errorAction,
title,
errorType,
referenceDoc,
}
}
/**
* Application error definitions - similar to Java AppError enum
* Each error has: HTTP code, app error code, message template, action, title, error type
*/
export const AppError = {
// Generic Errors
INVALID_PARAMETER: createError(
400,
"INVALID_PARAMETER",
"Please enter a valid parameter {0}.",
AppErrorAction.DEFAULT,
"Invalid Parameter",
ErrorType.ARGUMENT_ERROR,
),
UNAUTHORIZED_ACCESS: createError(
403,
"UNAUTHORIZED_ACCESS",
"Unauthorized access",
AppErrorAction.DEFAULT,
"Unauthorized Access",
ErrorType.AUTHENTICATION_ERROR,
),
GENERIC_BAD_REQUEST: createError(
400,
"GENERIC_BAD_REQUEST",
"The request was invalid or cannot be processed: {0}",
AppErrorAction.DEFAULT,
"Bad Request",
ErrorType.BAD_REQUEST,
),
GENERIC_NOT_FOUND: createError(
404,
"GENERIC_NOT_FOUND",
"The requested resource was not found: {0}",
AppErrorAction.DEFAULT,
"Not Found",
ErrorType.NOT_FOUND,
),
GENERIC_INTERNAL_SERVER_ERROR: createError(
500,
"GENERIC_INTERNAL_SERVER_ERROR",
"An unexpected error occurred. Please try again later: {0}",
AppErrorAction.LOG_EXTERNALLY,
"Internal Server Error",
ErrorType.INTERNAL_SERVER_ERROR,
),
// Validation Errors
VALIDATION_FAILURE: createError(
400,
"VALIDATION_FAILURE",
"Validation Failure(s): {0}",
AppErrorAction.DEFAULT,
"Validation Failed",
ErrorType.VALIDATION_ERROR,
),
MISSING_REQUIRED_FIELDS: createError(
400,
"MISSING_REQUIRED_FIELDS",
"Missing required fields: {0}",
AppErrorAction.DEFAULT,
"Missing Required Fields",
ErrorType.VALIDATION_ERROR,
),
PAYLOAD_TOO_LARGE: createError(
413,
"PAYLOAD_TOO_LARGE",
"The request payload is too large. Max allowed size is {0} KB",
AppErrorAction.DEFAULT,
"Payload Exceeds Max Allowed Size",
ErrorType.CONNECTIVITY_ERROR,
),
FILE_SIZE_EXCEEDED: createError(
400,
"FILE_SIZE_EXCEEDED",
"File size should be less than {0}",
AppErrorAction.DEFAULT,
"File Size Exceeded",
ErrorType.VALIDATION_ERROR,
),
INVALID_FILE_TYPE: createError(
400,
"INVALID_FILE_TYPE",
"Invalid file type. Only {0} formats are allowed.",
AppErrorAction.DEFAULT,
"Invalid File Type",
ErrorType.VALIDATION_ERROR,
),
CONFLICT: createError(
409,
"CONFLICT",
"Resource conflict: {0}",
AppErrorAction.DEFAULT,
"Conflict",
ErrorType.BAD_REQUEST,
),
UNPROCESSABLE_ENTITY: createError(
422,
"UNPROCESSABLE_ENTITY",
"Unable to process request: {0}",
AppErrorAction.DEFAULT,
"Unprocessable Entity",
ErrorType.VALIDATION_ERROR,
),
// Authentication Errors
JWT_AUTHENTICATION_FAILURE: createError(
401,
"JWT_AUTHENTICATION_FAILURE",
"JWT is either invalid or expired",
AppErrorAction.DEFAULT,
"JWT Authentication Error",
ErrorType.AUTHENTICATION_ERROR,
),
INVALID_REFRESH_TOKEN: createError(
400,
"INVALID_REFRESH_TOKEN",
"Refresh token is either invalid or expired",
AppErrorAction.DEFAULT,
"Refresh Token Authentication Error",
ErrorType.AUTHENTICATION_ERROR,
),
INVALID_API_KEY: createError(
401,
"INVALID_API_KEY",
"The provided API key is invalid",
AppErrorAction.DEFAULT,
"Invalid API Key",
ErrorType.AUTHENTICATION_ERROR,
),
API_KEY_EXPIRED: createError(
401,
"API_KEY_EXPIRED",
"The provided API key has expired",
AppErrorAction.DEFAULT,
"API Key Expired",
ErrorType.AUTHENTICATION_ERROR,
),
USER_DISABLED: createError(
403,
"USER_DISABLED",
"Your account has been disabled. Please contact support for assistance.",
AppErrorAction.DEFAULT,
"User Disabled",
ErrorType.AUTHENTICATION_ERROR,
),
MISSING_AUTHORIZATION_HEADER: createError(
401,
"MISSING_AUTHORIZATION_HEADER",
"Authorization header is missing",
AppErrorAction.DEFAULT,
"Missing Authorization",
ErrorType.AUTHENTICATION_ERROR,
),
MISSING_USER_ID: createError(
401,
"MISSING_USER_ID",
"User ID is missing",
AppErrorAction.DEFAULT,
"Missing User ID",
ErrorType.AUTHENTICATION_ERROR,
),
UNAUTHORIZED: createError(
401,
"UNAUTHORIZED",
"Unauthorized: {0}",
AppErrorAction.DEFAULT,
"Unauthorized",
ErrorType.AUTHENTICATION_ERROR,
),
FORBIDDEN: createError(
403,
"FORBIDDEN",
"Access forbidden: {0}",
AppErrorAction.DEFAULT,
"Forbidden",
ErrorType.AUTHENTICATION_ERROR,
),
// Rate Limiting
TOO_MANY_REQUESTS: createError(
429,
"TOO_MANY_REQUESTS",
"Too many requests. Please try again later.",
AppErrorAction.DEFAULT,
"Too Many Requests",
ErrorType.CONNECTIVITY_ERROR,
),
// GitHub Integration Errors
GITHUB_API_ERROR: createError(
502,
"GITHUB_API_ERROR",
"GitHub API error: {0}",
AppErrorAction.LOG_EXTERNALLY,
"GitHub API Error",
ErrorType.CONNECTIVITY_ERROR,
),
GITHUB_INSTALLATION_NOT_FOUND: createError(
404,
"GITHUB_INSTALLATION_NOT_FOUND",
"GitHub installation not found for {0}",
AppErrorAction.DEFAULT,
"GitHub Installation Not Found",
ErrorType.NOT_FOUND,
),
GITHUB_REPOSITORY_NOT_FOUND: createError(
404,
"GITHUB_REPOSITORY_NOT_FOUND",
"GitHub repository not found: {0}",
AppErrorAction.DEFAULT,
"GitHub Repository Not Found",
ErrorType.NOT_FOUND,
),
GITHUB_WEBHOOK_VERIFICATION_FAILED: createError(
401,
"GITHUB_WEBHOOK_VERIFICATION_FAILED",
"GitHub webhook signature verification failed",
AppErrorAction.LOG_EXTERNALLY,
"Webhook Verification Failed",
ErrorType.AUTHENTICATION_ERROR,
),
GITHUB_NOT_COLLABORATOR: createError(
403,
"GITHUB_NOT_COLLABORATOR",
"You are not a collaborator on this repository: {0}",
AppErrorAction.DEFAULT,
"Not a Collaborator",
ErrorType.AUTHENTICATION_ERROR,
),
GITHUB_BRANCH_NOT_FOUND: createError(
404,
"GITHUB_BRANCH_NOT_FOUND",
"Branch not found: {0}",
AppErrorAction.DEFAULT,
"Branch Not Found",
ErrorType.NOT_FOUND,
),
GITHUB_PR_NOT_FOUND: createError(
404,
"GITHUB_PR_NOT_FOUND",
"Pull request not found: {0}",
AppErrorAction.DEFAULT,
"PR Not Found",
ErrorType.NOT_FOUND,
),
GITHUB_INSTALLATION_ERROR: createError(
401,
"GITHUB_INSTALLATION_ERROR",
"GitHub App installation error: {0}",
AppErrorAction.DEFAULT,
"GitHub Installation Error",
ErrorType.AUTHENTICATION_ERROR,
),
// Optimization Errors
OPTIMIZATION_NOT_FOUND: createError(
404,
"OPTIMIZATION_NOT_FOUND",
"Optimization not found: {0}",
AppErrorAction.DEFAULT,
"Optimization Not Found",
ErrorType.NOT_FOUND,
),
OPTIMIZATION_ALREADY_EXISTS: createError(
400,
"OPTIMIZATION_ALREADY_EXISTS",
"Optimization already exists for: {0}",
AppErrorAction.DEFAULT,
"Optimization Already Exists",
ErrorType.BAD_REQUEST,
),
OPTIMIZATION_LIMIT_EXCEEDED: createError(
403,
"OPTIMIZATION_LIMIT_EXCEEDED",
"Optimization limit exceeded. You have used {0} of {1} optimizations.",
AppErrorAction.DEFAULT,
"Optimization Limit Exceeded",
ErrorType.BAD_REQUEST,
),
OPTIMIZATION_REJECTED: createError(
403,
"OPTIMIZATION_REJECTED",
"This optimization request was rejected: {0}",
AppErrorAction.DEFAULT,
"Optimization Rejected",
ErrorType.BAD_REQUEST,
),
OPTIMIZATION_APPROVAL_ERROR: createError(
500,
"OPTIMIZATION_APPROVAL_ERROR",
"Error checking approval status: {0}",
AppErrorAction.LOG_EXTERNALLY,
"Approval Error",
ErrorType.INTERNAL_SERVER_ERROR,
),
// Subscription Errors
SUBSCRIPTION_NOT_FOUND: createError(
404,
"SUBSCRIPTION_NOT_FOUND",
"Subscription not found for user: {0}",
AppErrorAction.DEFAULT,
"Subscription Not Found",
ErrorType.NOT_FOUND,
),
SUBSCRIPTION_EXPIRED: createError(
403,
"SUBSCRIPTION_EXPIRED",
"Your subscription has expired. Please renew to continue.",
AppErrorAction.DEFAULT,
"Subscription Expired",
ErrorType.BAD_REQUEST,
),
SUBSCRIPTION_LIMIT_REACHED: createError(
403,
"SUBSCRIPTION_LIMIT_REACHED",
"You have reached your subscription limit of {0} optimizations.",
AppErrorAction.DEFAULT,
"Subscription Limit Reached",
ErrorType.BAD_REQUEST,
),
SUBSCRIPTION_INACTIVE: createError(
403,
"SUBSCRIPTION_INACTIVE",
"Subscription is not active",
AppErrorAction.DEFAULT,
"Subscription Inactive",
ErrorType.BAD_REQUEST,
),
// Database Errors
DATABASE_CONNECTION_ERROR: createError(
500,
"DATABASE_CONNECTION_ERROR",
"Database connection error: {0}",
AppErrorAction.LOG_EXTERNALLY,
"Database Connection Error",
ErrorType.INTERNAL_SERVER_ERROR,
),
DATABASE_QUERY_ERROR: createError(
500,
"DATABASE_QUERY_ERROR",
"Database query error: {0}",
AppErrorAction.LOG_EXTERNALLY,
"Database Query Error",
ErrorType.INTERNAL_SERVER_ERROR,
),
DUPLICATE_KEY_ERROR: createError(
400,
"DUPLICATE_KEY_ERROR",
"The key already exists: {0}",
AppErrorAction.DEFAULT,
"Duplicate Key",
ErrorType.BAD_REQUEST,
),
// External Service Errors
EXTERNAL_SERVICE_ERROR: createError(
502,
"EXTERNAL_SERVICE_ERROR",
"External service error ({0}): {1}",
AppErrorAction.LOG_EXTERNALLY,
"External Service Error",
ErrorType.CONNECTIVITY_ERROR,
),
EXTERNAL_SERVICE_TIMEOUT: createError(
504,
"EXTERNAL_SERVICE_TIMEOUT",
"External service timeout ({0}): Request timed out after {1}ms",
AppErrorAction.LOG_EXTERNALLY,
"External Service Timeout",
ErrorType.CONNECTIVITY_ERROR,
),
} as const
export type AppErrorKey = keyof typeof AppError
/**
* Get formatted message for an error with arguments
*/
export function getErrorMessage(error: AppErrorDefinition, ...args: unknown[]): string {
return formatMessage(error.message, args)
}
/**
* Get error definition by key
*/
export function getErrorByKey(key: AppErrorKey): AppErrorDefinition {
return AppError[key]
}

View file

@ -0,0 +1,373 @@
import { AppErrorAction } from "./app-error-action.js"
import { AppError, AppErrorDefinition, AppErrorKey, getErrorMessage } from "./app-error.js"
import { BaseException, ExceptionContext } from "./base-exception.js"
/**
* Application exception - wraps an AppError definition
* Similar to Java AppException
*/
export class AppException extends BaseException {
private readonly error: AppErrorDefinition
private readonly args: unknown[]
constructor(error: AppErrorDefinition, ...args: unknown[])
constructor(error: AppErrorDefinition, context: ExceptionContext, ...args: unknown[])
constructor(error: AppErrorDefinition, contextOrArg?: ExceptionContext | unknown, ...args: unknown[]) {
// Determine if second parameter is context or an argument
let context: ExceptionContext | undefined
let allArgs: unknown[]
if (
contextOrArg !== undefined &&
typeof contextOrArg === "object" &&
contextOrArg !== null &&
("requestId" in contextOrArg ||
"traceId" in contextOrArg ||
"correlationId" in contextOrArg ||
"userId" in contextOrArg ||
"endpoint" in contextOrArg)
) {
context = contextOrArg as ExceptionContext
allArgs = args
} else if (contextOrArg !== undefined) {
context = undefined
allArgs = [contextOrArg, ...args]
} else {
context = undefined
allArgs = args
}
super(getErrorMessage(error, ...allArgs), context)
this.error = error
this.args = allArgs
}
getHttpStatus(): number {
return this.error.httpErrorCode
}
getMessage(): string {
return getErrorMessage(this.error, ...this.args)
}
getAppErrorCode(): string {
return this.error.appErrorCode
}
getErrorAction(): AppErrorAction {
return this.error.errorAction
}
getTitle(): string {
return this.error.title
}
getErrorType(): string {
return this.error.errorType
}
getReferenceDoc(): string | null {
return this.error.referenceDoc
}
getDownstreamErrorMessage(): string | null {
// Downstream error message is not available for app errors
return null
}
getDownstreamErrorCode(): string | null {
// Downstream error code is not available for app errors
return null
}
/** Get the underlying error definition */
getErrorDefinition(): AppErrorDefinition {
return this.error
}
/** Get the arguments used to format the message */
getArgs(): unknown[] {
return this.args
}
}
/**
* Downstream exception - wraps errors from external services
*/
export class DownstreamException extends BaseException {
private readonly error: AppErrorDefinition
private readonly args: unknown[]
private readonly downstreamMessage: string | null
private readonly downstreamCode: string | null
private readonly serviceName: string
constructor(
error: AppErrorDefinition,
serviceName: string,
downstreamMessage?: string,
downstreamCode?: string,
context?: ExceptionContext,
...args: unknown[]
) {
super(getErrorMessage(error, ...args), context)
this.error = error
this.serviceName = serviceName
this.downstreamMessage = downstreamMessage ?? null
this.downstreamCode = downstreamCode ?? null
this.args = args
}
getHttpStatus(): number {
return this.error.httpErrorCode
}
getAppErrorCode(): string {
return this.error.appErrorCode
}
getErrorAction(): AppErrorAction {
return this.error.errorAction
}
getTitle(): string {
return this.error.title
}
getErrorType(): string {
return this.error.errorType
}
getDownstreamErrorMessage(): string | null {
return this.downstreamMessage
}
getDownstreamErrorCode(): string | null {
return this.downstreamCode
}
getServiceName(): string {
return this.serviceName
}
}
// Convenience factory functions for common errors
export function invalidParameter(param: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.INVALID_PARAMETER, context, param)
}
return new AppException(AppError.INVALID_PARAMETER, param)
}
export function unauthorizedAccess(context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.UNAUTHORIZED_ACCESS, context)
}
return new AppException(AppError.UNAUTHORIZED_ACCESS)
}
export function notFound(resource: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.GENERIC_NOT_FOUND, context, resource)
}
return new AppException(AppError.GENERIC_NOT_FOUND, resource)
}
export function badRequest(reason: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.GENERIC_BAD_REQUEST, context, reason)
}
return new AppException(AppError.GENERIC_BAD_REQUEST, reason)
}
export function internalServerError(details: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.GENERIC_INTERNAL_SERVER_ERROR, context, details)
}
return new AppException(AppError.GENERIC_INTERNAL_SERVER_ERROR, details)
}
export function validationFailure(errors: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.VALIDATION_FAILURE, context, errors)
}
return new AppException(AppError.VALIDATION_FAILURE, errors)
}
export function tooManyRequests(context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.TOO_MANY_REQUESTS, context)
}
return new AppException(AppError.TOO_MANY_REQUESTS)
}
export function jwtAuthenticationFailure(context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.JWT_AUTHENTICATION_FAILURE, context)
}
return new AppException(AppError.JWT_AUTHENTICATION_FAILURE)
}
export function invalidApiKey(context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.INVALID_API_KEY, context)
}
return new AppException(AppError.INVALID_API_KEY)
}
export function subscriptionNotFound(userId: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.SUBSCRIPTION_NOT_FOUND, context, userId)
}
return new AppException(AppError.SUBSCRIPTION_NOT_FOUND, userId)
}
export function subscriptionLimitReached(limit: number, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.SUBSCRIPTION_LIMIT_REACHED, context, limit)
}
return new AppException(AppError.SUBSCRIPTION_LIMIT_REACHED, limit)
}
export function optimizationNotFound(id: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.OPTIMIZATION_NOT_FOUND, context, id)
}
return new AppException(AppError.OPTIMIZATION_NOT_FOUND, id)
}
export function optimizationLimitExceeded(
used: number,
limit: number,
context?: ExceptionContext,
): AppException {
if (context) {
return new AppException(AppError.OPTIMIZATION_LIMIT_EXCEEDED, context, used, limit)
}
return new AppException(AppError.OPTIMIZATION_LIMIT_EXCEEDED, used, limit)
}
export function githubApiError(message: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.GITHUB_API_ERROR, context, message)
}
return new AppException(AppError.GITHUB_API_ERROR, message)
}
export function githubInstallationNotFound(owner: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.GITHUB_INSTALLATION_NOT_FOUND, context, owner)
}
return new AppException(AppError.GITHUB_INSTALLATION_NOT_FOUND, owner)
}
export function databaseError(message: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.DATABASE_CONNECTION_ERROR, context, message)
}
return new AppException(AppError.DATABASE_CONNECTION_ERROR, message)
}
export function externalServiceError(
service: string,
message: string,
context?: ExceptionContext,
): AppException {
if (context) {
return new AppException(AppError.EXTERNAL_SERVICE_ERROR, context, service, message)
}
return new AppException(AppError.EXTERNAL_SERVICE_ERROR, service, message)
}
// New factory functions for additional error types
export function missingRequiredFields(fields: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.MISSING_REQUIRED_FIELDS, context, fields)
}
return new AppException(AppError.MISSING_REQUIRED_FIELDS, fields)
}
export function conflict(resource: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.CONFLICT, context, resource)
}
return new AppException(AppError.CONFLICT, resource)
}
export function unprocessableEntity(reason: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.UNPROCESSABLE_ENTITY, context, reason)
}
return new AppException(AppError.UNPROCESSABLE_ENTITY, reason)
}
export function missingAuthorizationHeader(context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.MISSING_AUTHORIZATION_HEADER, context)
}
return new AppException(AppError.MISSING_AUTHORIZATION_HEADER)
}
export function missingUserId(context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.MISSING_USER_ID, context)
}
return new AppException(AppError.MISSING_USER_ID)
}
export function unauthorized(reason?: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.UNAUTHORIZED, context, reason ?? "")
}
return new AppException(AppError.UNAUTHORIZED, reason ?? "")
}
export function githubNotCollaborator(repo: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.GITHUB_NOT_COLLABORATOR, context, repo)
}
return new AppException(AppError.GITHUB_NOT_COLLABORATOR, repo)
}
export function githubBranchNotFound(branch: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.GITHUB_BRANCH_NOT_FOUND, context, branch)
}
return new AppException(AppError.GITHUB_BRANCH_NOT_FOUND, branch)
}
export function githubPrNotFound(prInfo: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.GITHUB_PR_NOT_FOUND, context, prInfo)
}
return new AppException(AppError.GITHUB_PR_NOT_FOUND, prInfo)
}
export function githubInstallationError(message: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.GITHUB_INSTALLATION_ERROR, context, message)
}
return new AppException(AppError.GITHUB_INSTALLATION_ERROR, message)
}
export function optimizationRejected(reason?: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.OPTIMIZATION_REJECTED, context, reason ?? "")
}
return new AppException(AppError.OPTIMIZATION_REJECTED, reason ?? "")
}
export function subscriptionInactive(context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.SUBSCRIPTION_INACTIVE, context)
}
return new AppException(AppError.SUBSCRIPTION_INACTIVE)
}
export function forbidden(reason?: string, context?: ExceptionContext): AppException {
if (context) {
return new AppException(AppError.FORBIDDEN, context, reason ?? "")
}
return new AppException(AppError.FORBIDDEN, reason ?? "")
}

View file

@ -0,0 +1,74 @@
import { AppErrorAction } from "./app-error-action.js"
/**
* Context map for tracking request context across the exception lifecycle
*/
export interface ExceptionContext {
requestId?: string
traceId?: string
correlationId?: string
userId?: string
endpoint?: string
/** Force logging to Sentry even if error action is DEFAULT */
shouldLogToSentry?: boolean
[key: string]: unknown
}
/**
* Base exception class - abstract class that all application exceptions extend
* Similar to Java BaseException
*/
export abstract class BaseException extends Error {
/** Context map for request tracking */
protected contextMap: ExceptionContext
constructor(message: string, context?: ExceptionContext) {
super(message)
this.name = this.constructor.name
this.contextMap = context ?? {}
// Maintains proper stack trace for where our error was thrown
Error.captureStackTrace(this, this.constructor)
}
/** Get HTTP status code */
abstract getHttpStatus(): number
/** Get application error code */
abstract getAppErrorCode(): string
/** Get error action */
abstract getErrorAction(): AppErrorAction
/** Get error title */
abstract getTitle(): string
/** Get error type */
abstract getErrorType(): string
/** Get downstream error message (for wrapped external errors) */
abstract getDownstreamErrorMessage(): string | null
/** Get downstream error code (for wrapped external errors) */
abstract getDownstreamErrorCode(): string | null
/** Get context map */
getContextMap(): ExceptionContext {
return this.contextMap
}
/** Set context value */
setContext(key: string, value: unknown): void {
this.contextMap[key] = value
}
/** Get context value */
getContext(key: string): unknown {
return this.contextMap[key]
}
/** Check if this exception should be logged to Sentry regardless of error action */
getShouldLogToSentry(): boolean {
return this.contextMap.shouldLogToSentry === true
}
}

View file

@ -0,0 +1,12 @@
/**
* Error type categorization for consistent error classification
*/
export enum ErrorType {
BAD_REQUEST = "BAD_REQUEST",
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR",
NOT_FOUND = "NOT_FOUND",
AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR",
CONNECTIVITY_ERROR = "CONNECTIVITY_ERROR",
ARGUMENT_ERROR = "ARGUMENT_ERROR",
VALIDATION_ERROR = "VALIDATION_ERROR",
}

View file

@ -0,0 +1,378 @@
import { Request, Response, NextFunction } from "express"
import * as Sentry from "@sentry/node"
import { logger, LogContext } from "../utils/logger.js"
import { AppErrorAction } from "./app-error-action.js"
import { AppError, getErrorMessage } from "./app-error.js"
import { AppException, DownstreamException } from "./app-exception.js"
import { BaseException } from "./base-exception.js"
/**
* Error response DTO
*/
export interface ErrorDTO {
code: string
errorType: string
message: string
title: string
referenceDoc?: string | null
downstreamErrorMessage?: string | null
downstreamErrorCode?: string | null
}
/**
* API Response wrapper
*/
export interface ResponseDTO<T> {
status: number
data: T
requestId?: string
timestamp: string
}
/**
* Global exception handler for Express
* Similar to Java GlobalExceptionHandler with @ControllerAdvice
*/
export class GlobalExceptionHandler {
/**
* Log error to console and optionally to Sentry
*/
private static doLog(error: Error, context: LogContext): void {
// Configure Sentry tags
Sentry.setExtra("Stack Trace", error.stack ?? "No stack trace available")
Sentry.setTag("source", "cf-api")
if (context.requestId) {
Sentry.setTag("requestId", context.requestId)
}
if (context.endpoint) {
Sentry.setTag("endpoint", context.endpoint)
}
if (context.userId) {
Sentry.setUser({ id: context.userId })
}
// Check if we should log externally
if (error instanceof BaseException) {
const baseError = error as BaseException
const shouldLogExternally = baseError.getErrorAction() === AppErrorAction.LOG_EXTERNALLY
const forceLogToSentry = baseError.getShouldLogToSentry()
if (shouldLogExternally || forceLogToSentry) {
// Add context map as tags
const contextMap = baseError.getContextMap()
Object.entries(contextMap).forEach(([key, value]) => {
if (value !== undefined && key !== "shouldLogToSentry") {
Sentry.setTag(key, String(value))
}
})
Sentry.captureException(error)
}
} else {
// For non-BaseException errors, always log to Sentry
Sentry.captureException(error)
}
}
/**
* Main error handling middleware
* Should be registered as the last middleware in the Express app
*/
static handle(error: Error, req: Request, res: Response, next: NextFunction): void {
// If response was already sent, delegate to default Express error handler
if (res.headersSent) {
return next(error)
}
const errorContext: LogContext = {
requestId: req.requestId,
traceId: req.traceId,
userId: (req as any).userId,
endpoint: req.path,
operation: "error_handling",
}
// Handle different types of exceptions
if (error instanceof AppException) {
GlobalExceptionHandler.handleAppException(error, req, res, errorContext)
} else if (error instanceof DownstreamException) {
GlobalExceptionHandler.handleDownstreamException(error, req, res, errorContext)
} else if (error instanceof BaseException) {
GlobalExceptionHandler.handleBaseException(error, req, res, errorContext)
} else if (GlobalExceptionHandler.isServerWebInputException(error)) {
GlobalExceptionHandler.handleServerWebInputException(error, req, res, errorContext)
} else {
GlobalExceptionHandler.handleUnknownException(error, req, res, errorContext)
}
}
/**
* Handle AppException (custom application errors)
*/
private static handleAppException(
e: AppException,
req: Request,
res: Response,
context: LogContext,
): void {
const status = e.getHttpStatus()
GlobalExceptionHandler.doLog(e, context)
logger.error(`AppException: ${e.message}`, context, {
errorCode: e.getAppErrorCode(),
errorType: e.getErrorType(),
statusCode: status,
title: e.getTitle(),
})
const response = GlobalExceptionHandler.createResponse<ErrorDTO>(
status,
{
code: e.getAppErrorCode(),
errorType: e.getErrorType(),
message: e.getMessage(),
title: e.getTitle(),
referenceDoc: e.getReferenceDoc(),
},
req.requestId,
)
res.status(status).json(response)
}
/**
* Handle DownstreamException (errors from external services)
*/
private static handleDownstreamException(
e: DownstreamException,
req: Request,
res: Response,
context: LogContext,
): void {
const status = e.getHttpStatus()
GlobalExceptionHandler.doLog(e, context)
logger.error(`DownstreamException: ${e.message}`, context, {
errorCode: e.getAppErrorCode(),
errorType: e.getErrorType(),
statusCode: status,
downstreamMessage: e.getDownstreamErrorMessage(),
downstreamCode: e.getDownstreamErrorCode(),
service: e.getServiceName(),
})
const response = GlobalExceptionHandler.createResponse<ErrorDTO>(
status,
{
code: e.getAppErrorCode(),
errorType: e.getErrorType(),
message: e.message,
title: e.getTitle(),
downstreamErrorMessage: e.getDownstreamErrorMessage(),
downstreamErrorCode: e.getDownstreamErrorCode(),
},
req.requestId,
)
res.status(status).json(response)
}
/**
* Handle generic BaseException
*/
private static handleBaseException(
e: BaseException,
req: Request,
res: Response,
context: LogContext,
): void {
const status = e.getHttpStatus()
GlobalExceptionHandler.doLog(e, context)
logger.error(`BaseException: ${e.message}`, context, {
errorCode: e.getAppErrorCode(),
errorType: e.getErrorType(),
statusCode: status,
})
const response = GlobalExceptionHandler.createResponse<ErrorDTO>(
status,
{
code: e.getAppErrorCode(),
errorType: e.getErrorType(),
message: e.message,
title: e.getTitle(),
},
req.requestId,
)
res.status(status).json(response)
}
/**
* Check if error is similar to ServerWebInputException (Express body parser errors)
*/
private static isServerWebInputException(error: Error): boolean {
return (
error.name === "SyntaxError" ||
error.name === "PayloadTooLargeError" ||
(error as any).type === "entity.parse.failed" ||
(error as any).type === "entity.too.large"
)
}
/**
* Handle server input exceptions (malformed JSON, etc.)
*/
private static handleServerWebInputException(
e: Error,
req: Request,
res: Response,
context: LogContext,
): void {
const appError = AppError.GENERIC_BAD_REQUEST
const status = appError.httpErrorCode
GlobalExceptionHandler.doLog(e, context)
let errorMessage = e.message
if ((e as any).body) {
errorMessage = `Malformed request body: ${e.message}`
}
logger.warn(`ServerWebInputException: ${errorMessage}`, context, {
errorCode: appError.appErrorCode,
errorType: appError.errorType,
statusCode: status,
})
const response = GlobalExceptionHandler.createResponse<ErrorDTO>(
status,
{
code: appError.appErrorCode,
errorType: appError.errorType,
message: getErrorMessage(appError, errorMessage),
title: appError.title,
},
req.requestId,
)
res.status(status).json(response)
}
/**
* Handle unknown/unexpected exceptions
*/
private static handleUnknownException(
e: Error,
req: Request,
res: Response,
context: LogContext,
): void {
const appError = AppError.GENERIC_INTERNAL_SERVER_ERROR
const status = appError.httpErrorCode
GlobalExceptionHandler.doLog(e, context)
logger.errorWithSentry(`UnknownException: ${e.message}`, context, {
errorCode: appError.appErrorCode,
errorType: appError.errorType,
statusCode: status,
stack: e.stack,
})
// In production, hide internal error details
const message =
process.env.NODE_ENV === "production"
? getErrorMessage(appError, "An unexpected error occurred")
: getErrorMessage(appError, e.message)
const response = GlobalExceptionHandler.createResponse<ErrorDTO>(
status,
{
code: appError.appErrorCode,
errorType: appError.errorType,
message,
title: appError.title,
},
req.requestId,
)
res.status(status).json(response)
}
/**
* Create a standard response DTO
*/
private static createResponse<T>(status: number, data: T, requestId?: string): ResponseDTO<T> {
return {
status,
data,
requestId,
timestamp: new Date().toISOString(),
}
}
/**
* Handle unhandled promise rejections
*/
static async handleUnhandledRejection(reason: unknown, promise: Promise<unknown>): Promise<void> {
const context: LogContext = {
operation: "unhandled_rejection",
}
const error = reason instanceof Error ? reason : new Error(String(reason))
logger.errorWithSentry(
"Unhandled Promise Rejection",
context,
{
reason: String(reason),
promise: String(promise),
isOperational: false,
},
error,
)
// In production, gracefully shutdown
if (process.env.NODE_ENV === "production") {
try {
await Sentry.close(2000)
} catch (flushError) {
console.error("Failed to flush Sentry events before exit:", flushError)
}
process.exit(1)
}
}
/**
* Handle uncaught exceptions
*/
static async handleUncaughtException(error: Error): Promise<void> {
const context: LogContext = {
operation: "uncaught_exception",
}
logger.errorWithSentry(
"Uncaught Exception",
context,
{
stack: error.stack,
isOperational: false,
},
error,
)
try {
await Sentry.close(2000)
} catch (flushError) {
console.error("Failed to flush Sentry events before exit:", flushError)
}
// Always exit on uncaught exceptions
process.exit(1)
}
}

View file

@ -0,0 +1,64 @@
// Error Types and Actions
export { ErrorType } from "./error-type.js"
export { AppErrorAction } from "./app-error-action.js"
// Error Codes
export {
AppErrorCode,
type AppErrorCodeDefinition,
type AppErrorCodeKey,
} from "./app-error-code.js"
// Error Definitions
export {
AppError,
type AppErrorDefinition,
type AppErrorKey,
getErrorMessage,
getErrorByKey,
} from "./app-error.js"
// Exception Classes
export { BaseException, type ExceptionContext } from "./base-exception.js"
export {
AppException,
DownstreamException,
// Convenience factory functions
invalidParameter,
unauthorizedAccess,
notFound,
badRequest,
internalServerError,
validationFailure,
tooManyRequests,
jwtAuthenticationFailure,
invalidApiKey,
subscriptionNotFound,
subscriptionLimitReached,
optimizationNotFound,
optimizationLimitExceeded,
githubApiError,
githubInstallationNotFound,
databaseError,
externalServiceError,
missingRequiredFields,
conflict,
unprocessableEntity,
missingAuthorizationHeader,
missingUserId,
unauthorized,
githubNotCollaborator,
githubBranchNotFound,
githubPrNotFound,
githubInstallationError,
optimizationRejected,
subscriptionInactive,
forbidden,
} from "./app-exception.js"
// Global Exception Handler
export {
GlobalExceptionHandler,
type ErrorDTO,
type ResponseDTO,
} from "./global-exception-handler.js"

View file

@ -6,6 +6,11 @@ import {
getApprovalEmoji,
getRejectionEmoji,
} from "../config/approval-config.js"
import {
missingRequiredFields,
optimizationNotFound,
internalServerError,
} from "../exceptions/index.js"
const prisma = new PrismaClient()
const SLACK_CHANNEL = process.env.SLACK_APPROVAL_CHANNEL_ID || process.env.SLACK_CHANNEL_ID
@ -361,10 +366,7 @@ export async function checkApprovalStatus(req, res) {
const { traceId } = req.query
if (!traceId) {
return res.status(400).json({
status: "error",
message: "Missing traceId parameter",
})
throw missingRequiredFields("traceId")
}
// Find the optimization record
@ -381,10 +383,7 @@ export async function checkApprovalStatus(req, res) {
})
if (!optimization) {
return res.status(404).json({
status: "not_found",
message: "Optimization not found",
})
throw optimizationNotFound(traceId as string)
}
// If approval not required, consider it approved
@ -411,11 +410,11 @@ export async function checkApprovalStatus(req, res) {
details: optimization,
})
} catch (error) {
if (error && typeof error === "object" && "getHttpStatus" in error) {
throw error
}
console.error(`Error checking approval status: ${error}`)
return res.status(500).json({
status: "error",
message: "Error checking approval status",
})
throw internalServerError("Error checking approval status")
}
}

View file

@ -4,82 +4,36 @@ import "./instrument.js"
import express from "express"
import cors from "cors"
import { ghAppMiddleware, ghAppPathPrefix, githubApp } from "./github/github-app.js"
import { addAsync } from "@awaitjs/express"
import { AsyncExpressApp } from "./types.js"
import { logger } from "./utils/logger.js"
import { posthog } from "./analytics.js"
import { suggestPrChanges } from "./endpoints/suggest-pr-changes.js"
import { addRepositoryManually, createPr } from "./endpoints/create-pr.js"
import { isGitHubAppInstalled } from "./endpoints/is-github-app-installed.js"
import {
enhancedRequestLogger,
logRequestBody,
logAuthEvent,
} from "./middlewares/enhanced-logging.js"
import { healthcheck } from "./endpoints/healthcheck.js"
import { checkForValidAPIKey } from "./middlewares/check-valid-api-key.js"
import { trackEndpointCalls } from "./middlewares/track-endpoint-calls.js"
import { verifyExistingOptimizations } from "./endpoints/verify-existing-optimizations.js"
import { getUser } from "./endpoints/cli-get-user.js"
import * as Sentry from "@sentry/node"
import { stripeWebhookHandler } from "./endpoints/stripe-webhook.js"
import { trackUsage } from "./middlewares/track-usage.js"
import { testSentry } from "./endpoints/sentry-test.js"
import { idLimiter } from "./middlewares/rate-limit.js"
import {
add_optimized_code_context,
is_code_being_optimized_again,
} from "./endpoints/code-context-hash.js"
import { optimizationSuccess } from "./endpoints/optimiaztion-success.js"
import { handleSlackEvents } from "./endpoints/slack-events.js"
import createStagingReview from "./endpoints/create-staging.js"
import getStagingCode from "./endpoints/get-staging-code.js"
import commitStagingCode from "./endpoints/commit-staging-code.js"
// cron.js
import cron from "node-cron"
import { syncOrgsWithMembers } from "./github/github-utils.js"
import { sendStatusEmail } from "./resend/email-service.js"
import { GlobalErrorHandler } from "./utils/error-handler.js"
import { GlobalExceptionHandler } from "./exceptions/index.js"
import { githubApp } from "./github/github-app.js"
import { enhancedRequestLogger, logRequestBody } from "./middlewares/enhanced-logging.js"
import { registerRoutes } from "./routes/index.js"
import { initializeCronJobs } from "./cron/index.js"
import { DEFAULT_PORT, JSON_BODY_LIMIT, GITHUB_WEBHOOK_PATH } from "./constants/index.js"
import { sendOptimizationCompletedEmail } from "./endpoints/send-completed-optimization-email.js"
import { setupGithubActions } from "./endpoints/setup-github-actions.js"
// ========================================
// APPLICATION SETUP
// ========================================
const port = process.env.PORT ?? 3001
// Define a custom type for the wrapped Express app
const app = express()
const port = process.env.PORT ?? DEFAULT_PORT
const appExpress = addAsync(express()) as any as AsyncExpressApp
// Run every day at midnight UTC (time)
let isRunning = false
// ========================================
// CRON JOBS
// ========================================
cron.schedule("0 0 * * *", async () => {
if (isRunning) return
isRunning = true
const startTime = new Date()
initializeCronJobs(githubApp)
try {
await syncOrgsWithMembers(githubApp)
console.log("Finished syncOrgsWithMembers cron job.")
isRunning = false
} catch (error: any) {
console.error("Error running syncOrgsWithMembers cron job:", error)
Sentry.captureException(error)
const endTime = new Date()
const duration = endTime.getTime() - startTime.getTime()
const durationHms = `${Math.floor(duration / 3600000)}h ${Math.floor((duration % 3600000) / 60000)}m ${Math.floor((duration % 60000) / 1000)}s`
await sendStatusEmail("Failed", startTime, endTime, durationHms, error.message || String(error))
isRunning = false
}
})
// ToDO:The request handler must be the first middleware on the app
// appExpress.use(
// Sentry.Handlers.requestHandler({ // arc: obsolete in sentry 8.x.x
// request: ["userId"], //TODO attach the userid to sentry logs some other way
// }),
// )
// ========================================
// MIDDLEWARE SETUP
// ========================================
// Basic middleware - Enhanced logging first
// Enhanced logging (must be first)
appExpress.use(enhancedRequestLogger)
// CORS handling
@ -89,190 +43,50 @@ if (process.env.NODE_ENV !== "PRODUCTION") {
})
appExpress.use(cors({ origin: "*" }))
}
// Mount the github webhook middleware onto the express application
// MUST be mounted before express.json() middleware
// Log GitHub webhook middleware mounting
logger.info("Mounting GitHub webhook middleware", {
operation: "server_startup",
path: ghAppPathPrefix,
path: GITHUB_WEBHOOK_PATH,
})
//Caution/Note: DO NOT use express.raw() or express.json() before ghAppMiddleware for this route. Let ghAppMiddleware handle the raw stream directly.
// ========================================
// ROUTE REGISTRATION
// ========================================
appExpress.postAsync(`${ghAppPathPrefix}/webhooks`, async (req, res, next) => {
// Use postAsync, handler is async
const eventType = (req.headers["x-github-event"] as string) || "unknown_event"
const deliveryId = (req.headers["x-github-delivery"] as string) || "unknown_delivery"
const contentLength = req.headers["content-length"]
// Register webhook routes first (before body parsers)
// Then body parser middleware
appExpress.use(express.json({ limit: JSON_BODY_LIMIT }))
logger.info("Processing GitHub webhook", {
requestId: req.requestId,
traceId: req.traceId,
operation: "github_webhook",
eventType,
deliveryId,
contentLength,
})
try {
// AWAIT Octokit's middleware. It will handle the request and response.
// ghAppMiddleware will attempt to handle the request:
// - Verify signature
// - Parse payload
// - Find and execute a registered event handler
// - Send an HTTP response (e.g., 200 for success/handler found, 401 for bad signature, 500 if a handler errors)
// - Or call next() if no handler is found for a valid event.
await ghAppMiddleware(req, res, next) // Pass 'next' if ghAppMiddleware is designed to call it on error
// After await, ghAppMiddleware should have handled the response if the event was processed.
// If it didn't (e.g., an error it didn't send a response for, or path not matched by its internal logic),
// then res.headersSent would be false.
if (!res.headersSent) {
logger.warn("GitHub webhook middleware did not send response", {
requestId: req.requestId,
traceId: req.traceId,
operation: "github_webhook",
eventType,
deliveryId,
})
res
.status(200)
.send(
"Webhook acknowledged by server; no specific listener action taken or default passthrough.",
)
}
} catch (error) {
// This block catches:
// 1. Errors thrown if ghAppMiddleware's promise itself rejects (e.g., the original TypeError if it still occurred).
// 2. Errors passed by ghAppMiddleware via next(error).
logger.error(
"Error during GitHub webhook processing",
{
requestId: req.requestId,
traceId: req.traceId,
operation: "github_webhook",
eventType,
deliveryId,
},
{},
error as Error,
)
Sentry.captureException(error)
if (!res.headersSent) {
// If ghAppMiddleware (or any subsequent step if next(error) was called) crashed before sending a response.
res
.status(200)
.send("Webhook received, but an unexpected server error occurred during processing.")
}
}
})
// Mount Stripe webhook handler (must be before body parsers)
appExpress.post(
"/cfapi/webhooks/stripe",
express.raw({ type: "application/json" }),
stripeWebhookHandler,
)
// General middleware: MUST be after github webhook middleware
appExpress.use(express.json({ limit: "10mb" }))
// CORS handling
// Log request body in development
if (process.env.NODE_ENV !== "PRODUCTION") {
// Log request body for debugging purposes
appExpress.use(logRequestBody)
}
// Basic Public routes
appExpress.get("/", (req, res) => {
return res.status(200).send("OK")
})
// Register all routes
registerRoutes(appExpress)
appExpress.get("/healthcheck", healthcheck)
appExpress.get("/cfapi/test-sentry", testSentry)
// ========================================
// ERROR HANDLING
// ========================================
appExpress.postAsync("/cfapi/slack-events", handleSlackEvents)
// All endpoints defined after this will require a valid API key
// Add authentication event logging before API key check
appExpress.use(logAuthEvent)
// @ts-expect-error: TS2555 // Protected routes
appExpress.useAsync(checkForValidAPIKey)
appExpress.use(GlobalExceptionHandler.handle)
//rate limiter
appExpress.use(idLimiter)
// ========================================
// SERVER STARTUP
// ========================================
// Posthog tracks calls to all endpoints defined after this
appExpress.use(trackEndpointCalls)
// Add usage tracking to optimization endpoints
const optimizationEndpoints = [
"/cfapi/suggest-pr-changes",
"/cfapi/create-pr",
"/cfapi/create-staging",
]
// Apply usage tracking to optimization endpoints
optimizationEndpoints.forEach(endpoint => {
appExpress.use(endpoint, trackUsage)
})
// API routes
appExpress.get("/cfapi/cli-get-user", getUser)
// Return the list of ALL repositories where Codeflash is installed
// Seems to not be used anywhere; scheduled for deletion by June 30, 2024
// appExpress.getAsync("/cfapi/installed_repositories", installedRepositories)
appExpress.postAsync("/cfapi/test-repo", addRepositoryManually)
appExpress.postAsync("/cfapi/send-completion-email", sendOptimizationCompletedEmail)
appExpress.postAsync("/cfapi/mark-as-success", optimizationSuccess)
appExpress.postAsync("/cfapi/suggest-pr-changes", suggestPrChanges)
appExpress.postAsync("/cfapi/create-pr", createPr)
appExpress.postAsync("/cfapi/setup-github-actions", setupGithubActions)
appExpress.postAsync("/cfapi/create-staging", createStagingReview)
appExpress.postAsync("/cfapi/get-staging-code", getStagingCode)
appExpress.postAsync("/cfapi/commit-staging-code", commitStagingCode)
appExpress.getAsync("/cfapi/is-github-app-installed", isGitHubAppInstalled)
appExpress.postAsync("/cfapi/verify-existing-optimizations", verifyExistingOptimizations)
appExpress.postAsync("/cfapi/is-already-optimized", is_code_being_optimized_again)
appExpress.postAsync("/cfapi/add-code-hash", add_optimized_code_context)
// Subscription management endpoints
import {
getSubscription,
createCheckout,
cancelSubscription,
} from "./endpoints/subscription-management.js"
appExpress.getAsync("/cfapi/subscription", getSubscription)
appExpress.postAsync("/cfapi/create-checkout", createCheckout)
appExpress.postAsync("/cfapi/cancel-subscription", cancelSubscription)
// The error handler must be registered before any other error middleware and after all controllers
// Replace Sentry's basic error handler with our enhanced global error handler
appExpress.use(GlobalErrorHandler.handle)
// Start the server
appExpress.listen(Number(port), () => {
logger.info("CF API server started successfully", {
operation: "server_startup",
port: Number(port),
environment: process.env.NODE_ENV,
githubWebhookPath: ghAppPathPrefix,
githubWebhookPath: GITHUB_WEBHOOK_PATH,
})
})
posthog?.shutdown()
// Handle unhandled promise rejections and uncaught exceptions
process.on("unhandledRejection", GlobalErrorHandler.handleUnhandledRejection)
process.on("uncaughtException", GlobalErrorHandler.handleUncaughtException)
process.on("unhandledRejection", GlobalExceptionHandler.handleUnhandledRejection)
process.on("uncaughtException", GlobalExceptionHandler.handleUncaughtException)

View file

@ -4,6 +4,11 @@ import { NextFunction } from "express"
import { Response } from "express"
import { AuthStrategyFactory } from "./Auth/auth-strategy-factory.js"
import { logger } from "../utils/logger.js"
import {
missingAuthorizationHeader,
invalidApiKey,
internalServerError,
} from "../exceptions/index.js"
// Middleware to check for valid API key
export async function checkForValidAPIKey(
req: AuthorizedUserReq,
@ -34,7 +39,7 @@ export async function checkForValidAPIKey(
},
disableGeoip: false,
})
return res.status(401).send("Authorization header is missing")
return next(missingAuthorizationHeader({ requestId: req.requestId, endpoint: req.path }))
}
const apiKey = authHeader.replace(/^Bearer\s+/, "")
@ -77,7 +82,7 @@ export async function checkForValidAPIKey(
disableGeoip: false,
})
return res.status(403).send("Invalid API key")
return next(invalidApiKey({ requestId: req.requestId, endpoint: req.path }))
}
// Success - attach userId to request
@ -109,6 +114,6 @@ export async function checkForValidAPIKey(
error as Error,
)
return res.status(500).send("Internal server error")
return next(internalServerError("Authentication service error", { requestId: req.requestId, endpoint: req.path }))
}
}

View file

@ -2,8 +2,7 @@ import { Request, Response, NextFunction } from "express"
import { logger, LogContext } from "../utils/logger.js"
import { addTracingHeaders, extractOrGenerateRequestId } from "../utils/request-tracing.js"
import * as Sentry from "@sentry/node"
const SENSITIVE_KEYS = ["password", "token", "secret", "key", "authorization", "access_token"]
import { SENSITIVE_KEYS } from "../constants/index.js"
function safeRedact(payload: any): any {
if (payload == null || typeof payload !== "object") {

View file

@ -1,8 +1,12 @@
import { Response, NextFunction } from "express"
import { prisma, checkAndResetSubscriptionPeriod, SUBSCRIPTION_PLANS } from "@codeflash-ai/common"
import * as Sentry from "@sentry/node"
import { AuthorizedUserReq } from "../types.js"
import { logger } from "../utils/logger.js"
import {
missingUserId,
subscriptionInactive,
internalServerError,
} from "../exceptions/index.js"
export async function trackUsage(req: AuthorizedUserReq, res: Response, next: NextFunction) {
const userId = req.userId
@ -17,7 +21,7 @@ export async function trackUsage(req: AuthorizedUserReq, res: Response, next: Ne
operation: "usage_tracking",
})
return res.status(401).json({ error: "User ID is missing" })
return next(missingUserId({ requestId: req.requestId, endpoint: req.path }))
}
try {
@ -94,10 +98,7 @@ export async function trackUsage(req: AuthorizedUserReq, res: Response, next: Ne
status: subscription.subscription_status,
})
return res.status(403).json({
error: "Subscription is not active",
status: subscription.subscription_status,
})
return next(subscriptionInactive({ requestId: req.requestId, userId, endpoint: req.path }))
}
// Check if we need to reset monthly usage (lazy reset)
@ -129,7 +130,7 @@ export async function trackUsage(req: AuthorizedUserReq, res: Response, next: Ne
} catch (error) {
// Log usage tracking error - logger handles environment filtering automatically
// Production: ERROR level (critical infrastructure issue), Development: ERROR level (critical infrastructure issue)
logger.error(
logger.errorWithSentry(
"Error tracking usage",
{
requestId: req.requestId,
@ -141,9 +142,7 @@ export async function trackUsage(req: AuthorizedUserReq, res: Response, next: Ne
{},
error as Error,
)
// TODO: remove the Sentry capture exception and replace with logger.errorWithSentry
Sentry.captureException(error)
return res.status(500).json({ error: "Internal server error" })
return next(internalServerError("Error tracking usage", { requestId: req.requestId, userId, endpoint: req.path }))
}
}

View file

@ -3,6 +3,7 @@ import dotenv from "dotenv"
import fs from "fs"
import path from "path"
import { marked } from "marked"
import { STATUS_EMAIL_RECIPIENTS, CRON_EMAIL_SUBJECT_PREFIX } from "../constants/index.js"
dotenv.config()
let resend: Resend | null = null
@ -109,9 +110,9 @@ export async function sendStatusEmail(
.replace("{{duration}}", duration)
.replace("{{errorMessage}}", errorMessage || "")
await sendEmail({
to: "sarthak@codeflash.ai",
cc: ["hesham@codeflash.ai"],
subject: `Cron Job syncOrgsWithMembers - ${status}`,
to: STATUS_EMAIL_RECIPIENTS.to,
cc: STATUS_EMAIL_RECIPIENTS.cc,
subject: `${CRON_EMAIL_SUBJECT_PREFIX} - ${status}`,
html,
})
}

View file

@ -0,0 +1,13 @@
import { Router } from "express"
import { addAsync } from "@awaitjs/express"
import { isGitHubAppInstalled } from "../endpoints/is-github-app-installed.js"
import { setupGithubActions } from "../endpoints/setup-github-actions.js"
import { ROUTES } from "../constants/index.js"
const router = addAsync(Router()) as any
// GitHub integration endpoints
router.getAsync(ROUTES.IS_GITHUB_APP_INSTALLED, isGitHubAppInstalled)
router.postAsync(ROUTES.SETUP_GITHUB_ACTIONS, setupGithubActions)
export default router

67
js/cf-api/routes/index.ts Normal file
View file

@ -0,0 +1,67 @@
import { AsyncExpressApp } from "../types.js"
import { API_BASE_ROUTE } from "../constants/index.js"
// Route modules
import { rootRoutes, publicApiRoutes } from "./public.routes.js"
import webhookRoutes from "./webhook.routes.js"
import optimizationRoutes from "./optimization.routes.js"
import githubRoutes from "./github.routes.js"
import subscriptionRoutes from "./subscription.routes.js"
import userRoutes from "./user.routes.js"
// Middleware
import { checkForValidAPIKey } from "../middlewares/check-valid-api-key.js"
import { trackEndpointCalls } from "../middlewares/track-endpoint-calls.js"
import { idLimiter } from "../middlewares/rate-limit.js"
import { logAuthEvent } from "../middlewares/enhanced-logging.js"
/**
* Register all routes on the Express application
*
* Route registration order:
* 1. Webhook routes (must be before body parsers for raw body access)
* 2. Public routes (no authentication required)
* 3. Protected routes (require API key authentication)
*/
export function registerRoutes(app: AsyncExpressApp): void {
// ========================================
// WEBHOOK ROUTES (before body parsers)
// ========================================
app.use(webhookRoutes)
// ========================================
// PUBLIC ROUTES (no authentication)
// ========================================
app.use(rootRoutes)
app.use(API_BASE_ROUTE, publicApiRoutes)
// ========================================
// PROTECTED ROUTES (authentication required)
// ========================================
// Authentication middleware
app.use(logAuthEvent)
// @ts-expect-error: TS2555
app.useAsync(checkForValidAPIKey)
// Rate limiting
app.use(idLimiter)
// Analytics tracking
app.use(trackEndpointCalls)
// Protected route modules - all prefixed with API_BASE_ROUTE
app.use(API_BASE_ROUTE, optimizationRoutes)
app.use(API_BASE_ROUTE, githubRoutes)
app.use(API_BASE_ROUTE, subscriptionRoutes)
app.use(API_BASE_ROUTE, userRoutes)
}
export {
rootRoutes,
publicApiRoutes,
webhookRoutes,
optimizationRoutes,
githubRoutes,
subscriptionRoutes,
userRoutes,
}

View file

@ -0,0 +1,36 @@
import { Router } from "express"
import { addAsync } from "@awaitjs/express"
import { suggestPrChanges } from "../endpoints/suggest-pr-changes.js"
import { createPr, addRepositoryManually } from "../endpoints/create-pr.js"
import { verifyExistingOptimizations } from "../endpoints/verify-existing-optimizations.js"
import {
add_optimized_code_context,
is_code_being_optimized_again,
} from "../endpoints/code-context-hash.js"
import { optimizationSuccess } from "../endpoints/optimiaztion-success.js"
import { trackUsage } from "../middlewares/track-usage.js"
import createStagingReview from "../endpoints/create-staging.js"
import getStagingCode from "../endpoints/get-staging-code.js"
import commitStagingCode from "../endpoints/commit-staging-code.js"
import { ROUTES, USAGE_TRACKED_ROUTES } from "../constants/index.js"
const router = addAsync(Router()) as any
// Apply usage tracking to optimization endpoints
USAGE_TRACKED_ROUTES.forEach(endpoint => {
router.use(endpoint, trackUsage)
})
// Optimization endpoints
router.postAsync(ROUTES.SUGGEST_PR_CHANGES, suggestPrChanges)
router.postAsync(ROUTES.CREATE_PR, createPr)
router.postAsync(ROUTES.VERIFY_EXISTING_OPTIMIZATIONS, verifyExistingOptimizations)
router.postAsync(ROUTES.IS_ALREADY_OPTIMIZED, is_code_being_optimized_again)
router.postAsync(ROUTES.ADD_CODE_HASH, add_optimized_code_context)
router.postAsync(ROUTES.MARK_AS_SUCCESS, optimizationSuccess)
router.postAsync(ROUTES.CREATE_STAGING, createStagingReview)
router.postAsync(ROUTES.GET_STAGING_CODE, getStagingCode)
router.postAsync(ROUTES.COMMIT_STAGING_CODE, commitStagingCode)
router.postAsync(ROUTES.TEST_REPO, addRepositoryManually)
export default router

View file

@ -0,0 +1,20 @@
import { Router } from "express"
import { healthcheck } from "../endpoints/healthcheck.js"
import { testSentry } from "../endpoints/sentry-test.js"
import { handleSlackEvents } from "../endpoints/slack-events.js"
import { ROUTES } from "../constants/index.js"
// Root-level public routes (no prefix)
export const rootRoutes = Router()
rootRoutes.get(ROUTES.ROOT, (req, res) => {
return res.status(200).send("OK")
})
rootRoutes.get(ROUTES.HEALTHCHECK, healthcheck)
// Public API routes (mounted under API_BASE_ROUTE)
export const publicApiRoutes = Router()
publicApiRoutes.get(ROUTES.TEST_SENTRY, testSentry)
publicApiRoutes.post(ROUTES.SLACK_EVENTS, handleSlackEvents as any)

View file

@ -0,0 +1,17 @@
import { Router } from "express"
import { addAsync } from "@awaitjs/express"
import {
getSubscription,
createCheckout,
cancelSubscription,
} from "../endpoints/subscription-management.js"
import { ROUTES } from "../constants/index.js"
const router = addAsync(Router()) as any
// Subscription management endpoints
router.getAsync(ROUTES.SUBSCRIPTION, getSubscription)
router.postAsync(ROUTES.CREATE_CHECKOUT, createCheckout)
router.postAsync(ROUTES.CANCEL_SUBSCRIPTION, cancelSubscription)
export default router

View file

@ -0,0 +1,15 @@
import { Router } from "express"
import { getUser } from "../endpoints/cli-get-user.js"
import { sendOptimizationCompletedEmail } from "../endpoints/send-completed-optimization-email.js"
import { addAsync } from "@awaitjs/express"
import { ROUTES } from "../constants/index.js"
const router = addAsync(Router()) as any
// User endpoints
router.get(ROUTES.CLI_GET_USER, getUser)
// Email endpoints
router.postAsync(ROUTES.SEND_COMPLETION_EMAIL, sendOptimizationCompletedEmail)
export default router

View file

@ -0,0 +1,70 @@
import { Router, Request, Response, NextFunction } from "express"
import express from "express"
import { ghAppMiddleware } from "../github/github-app.js"
import { stripeWebhookHandler } from "../endpoints/stripe-webhook.js"
import { logger } from "../utils/logger.js"
import * as Sentry from "@sentry/node"
import { ROUTES } from "../constants/index.js"
const router = Router()
// GitHub webhook handler
// MUST be mounted before express.json() middleware
router.post(ROUTES.GITHUB_WEBHOOKS, async (req: Request, res: Response, next: NextFunction) => {
const eventType = (req.headers["x-github-event"] as string) || "unknown_event"
const deliveryId = (req.headers["x-github-delivery"] as string) || "unknown_delivery"
const contentLength = req.headers["content-length"]
logger.info("Processing GitHub webhook", {
requestId: req.requestId,
traceId: req.traceId,
operation: "github_webhook",
eventType,
deliveryId,
contentLength,
})
try {
await ghAppMiddleware(req, res, next)
if (!res.headersSent) {
logger.warn("GitHub webhook middleware did not send response", {
requestId: req.requestId,
traceId: req.traceId,
operation: "github_webhook",
eventType,
deliveryId,
})
res
.status(200)
.send(
"Webhook acknowledged by server; no specific listener action taken or default passthrough.",
)
}
} catch (error) {
logger.error(
"Error during GitHub webhook processing",
{
requestId: req.requestId,
traceId: req.traceId,
operation: "github_webhook",
eventType,
deliveryId,
},
{},
error as Error,
)
Sentry.captureException(error)
if (!res.headersSent) {
res
.status(500)
.send("Webhook received, but an unexpected server error occurred during processing.")
}
}
})
// Stripe webhook handler (must use raw body parser)
router.post(ROUTES.STRIPE_WEBHOOKS, express.raw({ type: "application/json" }), stripeWebhookHandler)
export default router

View file

@ -1,5 +1,6 @@
import * as Sentry from "@sentry/node"
import { posthog } from "../analytics.js"
import { MAX_PENDING_LOGS, SERVICE_NAME } from "../constants/index.js"
// Log levels in order of severity
export enum LogLevel {
@ -57,7 +58,6 @@ export class Logger {
private config: LoggerConfig
private isProduction: boolean
private pendingLogs = 0
private readonly MAX_PENDING_LOGS = 500 // Safety limit to prevent queue overflow
constructor() {
this.isProduction = process.env.NODE_ENV === "production"
@ -92,7 +92,7 @@ export class Logger {
enableConsole: true,
enableSentry: this.isProduction,
enablePostHog: this.isProduction,
serviceName: "cf-api",
serviceName: SERVICE_NAME,
}
// Store test mode flag
@ -176,7 +176,7 @@ export class Logger {
}
// Safety check: drop non-critical logs if too many pending
if (this.pendingLogs >= this.MAX_PENDING_LOGS) {
if (this.pendingLogs >= MAX_PENDING_LOGS) {
// Always process ERROR logs, drop others
if (entry.level < LogLevel.ERROR) {
return // Silently drop non-critical logs when queue is full

View file

@ -337,9 +337,20 @@ export async function createPullRequest({
if (contentType?.includes("json")) {
const error = await response.json()
errorMessage = error.message || error.error || errorMessage
} else if (contentType?.includes("html")) {
// HTML error page - provide a meaningful message based on status
if (response.status === 404) {
errorMessage = "API endpoint not found. Please check if the API server is running."
} else if (response.status === 502 || response.status === 503) {
errorMessage = "API server is unavailable. Please try again later."
} else {
errorMessage = `Server error (${response.status}). Please try again later.`
}
} else {
const text = await response.text()
if (text) errorMessage = text
if (text && !text.includes("<!DOCTYPE") && !text.includes("<html")) {
errorMessage = text
}
}
// Log to Sentry
@ -352,10 +363,6 @@ export async function createPullRequest({
},
})
if (response.status === 400) {
return createErrorResponse("Invalid request data")
}
return createErrorResponse(errorMessage)
} catch (error: any) {
const errorMessage = error?.message || "Something went wrong. Please try again."

View file

@ -598,9 +598,10 @@ export default function OptimizationReviewPage() {
window.open(constructedUrl, "_blank")
}
}, 1000)
} catch (error) {
} catch (error: any) {
console.error("[handleCreatePR] Exception:", error)
toast.error("Failed to create pull request", {
const errorMessage = error?.message || "Failed to create pull request"
toast.error(errorMessage, {
duration: 5000,
})
} finally {

View file

@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"
import { ThemeProvider } from "@/components/theme-provider"
import { UserProvider } from "@auth0/nextjs-auth0/client"
import { Toaster } from "@/components/ui/toaster"
import { Toaster as SonnerToaster } from "sonner"
import { getSession } from "@auth0/nextjs-auth0"
import Script from "next/script"
import { PHProvider } from "./providers"
@ -105,6 +106,7 @@ export default async function RootLayout({
</PrivacyModeProvider>
</ViewModeProvider>
<Toaster />
<SonnerToaster position="top-right" richColors />
</ThemeProvider>
</UserProvider>
</body>