mirror of
https://github.com/codeflash-ai/codeflash-internal.git
synced 2026-05-04 18:25:18 +00:00
parent
21c2900e31
commit
45e22e0f94
46 changed files with 2757 additions and 1029 deletions
|
|
@ -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
|
||||
|
|
|
|||
117
js/cf-api/constants/index.ts
Normal file
117
js/cf-api/constants/index.ts
Normal 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
37
js/cf-api/cron/index.ts
Normal 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")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
js/cf-api/exceptions/app-error-action.ts
Normal file
9
js/cf-api/exceptions/app-error-action.ts
Normal 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",
|
||||
}
|
||||
205
js/cf-api/exceptions/app-error-code.ts
Normal file
205
js/cf-api/exceptions/app-error-code.ts
Normal 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
|
||||
484
js/cf-api/exceptions/app-error.ts
Normal file
484
js/cf-api/exceptions/app-error.ts
Normal 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]
|
||||
}
|
||||
373
js/cf-api/exceptions/app-exception.ts
Normal file
373
js/cf-api/exceptions/app-exception.ts
Normal 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 ?? "")
|
||||
}
|
||||
74
js/cf-api/exceptions/base-exception.ts
Normal file
74
js/cf-api/exceptions/base-exception.ts
Normal 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
|
||||
}
|
||||
}
|
||||
12
js/cf-api/exceptions/error-type.ts
Normal file
12
js/cf-api/exceptions/error-type.ts
Normal 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",
|
||||
}
|
||||
378
js/cf-api/exceptions/global-exception-handler.ts
Normal file
378
js/cf-api/exceptions/global-exception-handler.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
64
js/cf-api/exceptions/index.ts
Normal file
64
js/cf-api/exceptions/index.ts
Normal 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"
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
13
js/cf-api/routes/github.routes.ts
Normal file
13
js/cf-api/routes/github.routes.ts
Normal 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
67
js/cf-api/routes/index.ts
Normal 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,
|
||||
}
|
||||
36
js/cf-api/routes/optimization.routes.ts
Normal file
36
js/cf-api/routes/optimization.routes.ts
Normal 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
|
||||
20
js/cf-api/routes/public.routes.ts
Normal file
20
js/cf-api/routes/public.routes.ts
Normal 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)
|
||||
17
js/cf-api/routes/subscription.routes.ts
Normal file
17
js/cf-api/routes/subscription.routes.ts
Normal 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
|
||||
15
js/cf-api/routes/user.routes.ts
Normal file
15
js/cf-api/routes/user.routes.ts
Normal 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
|
||||
70
js/cf-api/routes/webhook.routes.ts
Normal file
70
js/cf-api/routes/webhook.routes.ts
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue