mirror of
https://github.com/codeflash-ai/codeflash-internal.git
synced 2026-05-04 18:25:18 +00:00
Implement Tests for CF-API Flow (#1634)
Co-authored-by: Sarthak Agarwal <sarthak.saga@gmail.com>
This commit is contained in:
parent
1ff32e2144
commit
6f5c2d7ad8
36 changed files with 9051 additions and 669 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -172,7 +172,7 @@ cython_debug/
|
|||
|
||||
# IDE settings
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
|
|
|||
23
js/cf-api/.env.test
Normal file
23
js/cf-api/.env.test
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
NODE_ENV=test
|
||||
DATABASE_URL=postgresql://test:test@localhost:5432/test_db
|
||||
KEY_VAULT_NAME=test-keyvault
|
||||
STRIPE_SECRET_KEY=sk_test_mock
|
||||
AUTH0_ISSUER_BASE_URL=https://test.auth0.com
|
||||
AUTH0_CLIENT_ID=test-client-id
|
||||
AUTH0_CLIENT_SECRET=test-client-secret
|
||||
AUTH0_MANAGEMENT_CLIENT_ID=test-management-client-id
|
||||
AUTH0_MANAGEMENT_CLIENT_SECRET=test-management-client-secret
|
||||
GH_APP_ID=12345
|
||||
GH_APP_USER_ID=67890
|
||||
AZURE_CLIENT_ID=test-azure-client-id
|
||||
AZURE_TENANT_ID=test-azure-tenant-id
|
||||
AZURE_CLIENT_SECRET=test-azure-secret
|
||||
SLACK_TOKEN=xoxb-test-token
|
||||
SLACK_CHANNEL_ID=C123456789
|
||||
NOTION_DATABASE_ID=test-notion-db-id
|
||||
NOTION_API_TOKEN=secret_test_token
|
||||
SECRET_KEY=test-secret-key
|
||||
APPROVAL_REQUIRED_REPOS={}
|
||||
QUALITY_MONITORING_REPOS={}
|
||||
DISABLE_APPROVAL_SYSTEM=true
|
||||
WEBAPP_URL=http://localhost:3000
|
||||
|
|
@ -1,14 +1,29 @@
|
|||
import { ManagementClient } from "auth0"
|
||||
|
||||
// Create a singleton instance or allow injection
|
||||
let managementClient: ManagementClient | null = null
|
||||
|
||||
export function getManagementClient(): ManagementClient {
|
||||
if (!managementClient) {
|
||||
managementClient = new ManagementClient({
|
||||
domain: process.env.AUTH0_ISSUER_BASE_URL ?? "",
|
||||
clientId: process.env.AUTH0_MANAGEMENT_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.AUTH0_MANAGEMENT_CLIENT_SECRET ?? "",
|
||||
})
|
||||
}
|
||||
return managementClient
|
||||
}
|
||||
|
||||
// For testing purposes
|
||||
export function setManagementClient(client: ManagementClient | null) {
|
||||
managementClient = client
|
||||
}
|
||||
|
||||
export async function userNickname(userId: string): Promise<string | null> {
|
||||
const m = new ManagementClient({
|
||||
domain: process.env.AUTH0_ISSUER_BASE_URL ?? "",
|
||||
clientId: process.env.AUTH0_MANAGEMENT_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.AUTH0_MANAGEMENT_CLIENT_SECRET ?? "",
|
||||
})
|
||||
const m = getManagementClient()
|
||||
try {
|
||||
const user = await m.users.get({ id: userId, fields: "nickname" })
|
||||
return user.data?.nickname
|
||||
return user.data?.nickname ?? null
|
||||
} catch (error) {
|
||||
console.log("Error getting user nickname:", error)
|
||||
return null
|
||||
|
|
|
|||
60
js/cf-api/auth0-mgmt.unit.test.ts
Normal file
60
js/cf-api/auth0-mgmt.unit.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"
|
||||
import { userNickname, setManagementClient } from "./auth0-mgmt"
|
||||
|
||||
describe("userNickname", () => {
|
||||
let mockUsersGet: jest.MockedFunction<any>
|
||||
let mockManagementClient: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock for users.get
|
||||
mockUsersGet = jest.fn()
|
||||
|
||||
// Create mock management client
|
||||
mockManagementClient = {
|
||||
users: {
|
||||
get: mockUsersGet,
|
||||
},
|
||||
}
|
||||
|
||||
// Inject the mock
|
||||
setManagementClient(mockManagementClient)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up
|
||||
setManagementClient(null)
|
||||
})
|
||||
|
||||
it("should return nickname when user is found", async () => {
|
||||
mockUsersGet.mockResolvedValue({ data: { nickname: "cool-user" } })
|
||||
|
||||
const nickname = await userNickname("user-123")
|
||||
|
||||
expect(nickname).toBe("cool-user")
|
||||
expect(mockUsersGet).toHaveBeenCalledWith({ id: "user-123", fields: "nickname" })
|
||||
})
|
||||
|
||||
it("should return null when nickname is missing", async () => {
|
||||
mockUsersGet.mockResolvedValue({ data: {} })
|
||||
|
||||
const nickname = await userNickname("user-456")
|
||||
|
||||
expect(nickname).toBeNull()
|
||||
expect(mockUsersGet).toHaveBeenCalledWith({ id: "user-456", fields: "nickname" })
|
||||
})
|
||||
|
||||
it("should return null and log error on exception", async () => {
|
||||
const error = new Error("API error")
|
||||
mockUsersGet.mockRejectedValue(error)
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
const nickname = await userNickname("user-789")
|
||||
|
||||
expect(nickname).toBeNull()
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error getting user nickname:", error)
|
||||
expect(mockUsersGet).toHaveBeenCalledWith({ id: "user-789", fields: "nickname" })
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,14 +1,27 @@
|
|||
import { Client, LogLevel } from "@notionhq/client"
|
||||
|
||||
export async function collectEmail(req, res): Promise<void> {
|
||||
console.log("collecting waitlist emails")
|
||||
try {
|
||||
const { email, userId, username } = req.body
|
||||
const notion = new Client({
|
||||
let notionClient: Client | null = null
|
||||
|
||||
export function getNotionClient(): Client {
|
||||
if (!notionClient) {
|
||||
notionClient = new Client({
|
||||
auth: process.env.NOTION_API_TOKEN,
|
||||
logLevel: LogLevel.DEBUG,
|
||||
})
|
||||
// const email: string = req.query.email as string
|
||||
}
|
||||
return notionClient
|
||||
}
|
||||
|
||||
// For testing purposes
|
||||
export function setNotionClient(client: Client | null) {
|
||||
notionClient = client
|
||||
}
|
||||
|
||||
export async function collectEmail(req: any, res: any): Promise<void> {
|
||||
console.log("collecting waitlist emails")
|
||||
try {
|
||||
const { email, userId, username } = req.body
|
||||
const notion = getNotionClient()
|
||||
|
||||
await notion.pages.create({
|
||||
parent: {
|
||||
|
|
@ -34,7 +47,6 @@ export async function collectEmail(req, res): Promise<void> {
|
|||
],
|
||||
},
|
||||
github_id: {
|
||||
// What is a notion text type?
|
||||
rich_text: [
|
||||
{
|
||||
text: {
|
||||
|
|
@ -46,7 +58,7 @@ export async function collectEmail(req, res): Promise<void> {
|
|||
},
|
||||
})
|
||||
res.status(200).send("Email collected successfully.")
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.log(`Error in /cfapi/collect-email: ${error}`)
|
||||
console.log(`Error message: ${error.message}`)
|
||||
console.log(`Error stack: ${error.stack}`)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
createAppInstallation,
|
||||
createRepositoryMember,
|
||||
getAppInstallationByInstalltionId,
|
||||
prisma,
|
||||
upsertRepository,
|
||||
} from "@codeflash-ai/common"
|
||||
import { AuthorizedUserReq } from "types.js"
|
||||
|
|
@ -40,7 +41,176 @@ import {
|
|||
} from "../github/optimization_approval.js"
|
||||
import { registerRepositoryAndMember } from "./utils/github-repo-setup.js"
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
// Define a comprehensive interface for PR title and body generation
|
||||
export interface PrContentBuilder {
|
||||
buildResultHeader: typeof buildResultHeader
|
||||
buildBenchmarkInfo: typeof buildBenchmarkInfo
|
||||
buildResultDetails: typeof buildResultDetails
|
||||
buildResultTestReport: typeof buildResultTestReport
|
||||
buildResultFooter: typeof buildResultFooter
|
||||
buildPrTitle: typeof buildPrTitle
|
||||
}
|
||||
|
||||
// Create a standalone PR title and body function with injectable dependencies
|
||||
export function createStandalonePRTitleAndBody(
|
||||
prCommentFields: PrCommentFields,
|
||||
existingTests: string,
|
||||
generatedTests: string,
|
||||
coverage_message: string,
|
||||
newBranchName: string,
|
||||
builder: PrContentBuilder,
|
||||
): { title: string; body: string } {
|
||||
const prCommentHeader = builder.buildResultHeader(prCommentFields)
|
||||
|
||||
const benchmarkInfo =
|
||||
prCommentFields.benchmark_details && prCommentFields.benchmark_details.length > 0
|
||||
? builder.buildBenchmarkInfo(prCommentFields)
|
||||
: ""
|
||||
|
||||
const prCommentBody = builder.buildResultDetails(prCommentFields)
|
||||
const prCommentTestReport = builder.buildResultTestReport(
|
||||
prCommentFields,
|
||||
existingTests,
|
||||
generatedTests,
|
||||
coverage_message,
|
||||
)
|
||||
const prCommentFooter = builder.buildResultFooter(newBranchName)
|
||||
const title: string = builder.buildPrTitle(
|
||||
prCommentFields.function_name,
|
||||
prCommentFields.speedup_pct,
|
||||
prCommentFields.speedup_x,
|
||||
)
|
||||
|
||||
const body: string = benchmarkInfo
|
||||
? `${prCommentHeader}\n${benchmarkInfo}\n${prCommentBody}\n${prCommentTestReport}\n${prCommentFooter}`
|
||||
: `${prCommentHeader}\n${prCommentBody}\n${prCommentTestReport}\n${prCommentFooter}`
|
||||
|
||||
return { title, body }
|
||||
}
|
||||
|
||||
// Create a wrapper function for createNewPullRequest to match test expectations
|
||||
export async function createStandalonePullRequest(
|
||||
installationOctokit: any,
|
||||
owner: string,
|
||||
repo: string,
|
||||
title: string,
|
||||
body: string,
|
||||
newBranchName: string,
|
||||
baseBranch: string,
|
||||
) {
|
||||
return await createNewPullRequest(
|
||||
installationOctokit,
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
newBranchName,
|
||||
baseBranch,
|
||||
)
|
||||
}
|
||||
|
||||
// Dependencies interface for easier testing
|
||||
export interface CreatePrDependencies {
|
||||
prisma: PrismaClient
|
||||
userNickname: typeof userNickname
|
||||
getInstallationOctokitByOwner: typeof getInstallationOctokitByOwner
|
||||
isUserCollaborator: typeof isUserCollaborator
|
||||
requiresApproval: typeof requiresApproval
|
||||
requestApproval: typeof requestApproval
|
||||
triggerCreatePr: typeof triggerCreatePr
|
||||
posthog: typeof posthog
|
||||
isDiffContentsWellFormed: typeof isDiffContentsWellFormed
|
||||
githubApp: typeof githubApp
|
||||
}
|
||||
|
||||
// Dependencies for triggerCreatePr
|
||||
export interface TriggerCreatePrDependencies {
|
||||
prisma: PrismaClient
|
||||
fileDiffsToMap: typeof fileDiffsToMap
|
||||
buildPrTitle: typeof buildPrTitle
|
||||
createNewBranchFromDiffContents: typeof createNewBranchFromDiffContents
|
||||
createStandalonePullRequest: typeof createStandalonePullRequest
|
||||
addLabelToPullRequest: typeof addLabelToPullRequest
|
||||
assignReviewer: typeof assignReviewer
|
||||
posthog: typeof posthog
|
||||
createStandalonePRTitleAndBody: typeof createStandalonePRTitleAndBody
|
||||
prContentBuilder: PrContentBuilder
|
||||
}
|
||||
|
||||
// Default dependencies
|
||||
const defaultPrContentBuilder: PrContentBuilder = {
|
||||
buildResultHeader,
|
||||
buildBenchmarkInfo,
|
||||
buildResultDetails,
|
||||
buildResultTestReport,
|
||||
buildResultFooter,
|
||||
buildPrTitle,
|
||||
}
|
||||
|
||||
let dependencies: CreatePrDependencies = {
|
||||
prisma: new PrismaClient(),
|
||||
userNickname,
|
||||
getInstallationOctokitByOwner,
|
||||
isUserCollaborator,
|
||||
requiresApproval,
|
||||
requestApproval,
|
||||
triggerCreatePr,
|
||||
posthog,
|
||||
isDiffContentsWellFormed,
|
||||
githubApp,
|
||||
}
|
||||
|
||||
let triggerCreatePrDeps: TriggerCreatePrDependencies = {
|
||||
prisma: new PrismaClient(),
|
||||
fileDiffsToMap,
|
||||
buildPrTitle,
|
||||
createNewBranchFromDiffContents,
|
||||
createStandalonePullRequest,
|
||||
addLabelToPullRequest,
|
||||
assignReviewer,
|
||||
posthog,
|
||||
createStandalonePRTitleAndBody,
|
||||
prContentBuilder: defaultPrContentBuilder,
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setCreatePrDependencies(deps: Partial<CreatePrDependencies>) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetCreatePrDependencies() {
|
||||
dependencies = {
|
||||
prisma: new PrismaClient(),
|
||||
userNickname,
|
||||
getInstallationOctokitByOwner,
|
||||
isUserCollaborator,
|
||||
requiresApproval,
|
||||
requestApproval,
|
||||
triggerCreatePr,
|
||||
posthog,
|
||||
isDiffContentsWellFormed,
|
||||
githubApp,
|
||||
}
|
||||
}
|
||||
|
||||
export function setTriggerCreatePrDependencies(deps: Partial<TriggerCreatePrDependencies>) {
|
||||
triggerCreatePrDeps = { ...triggerCreatePrDeps, ...deps }
|
||||
}
|
||||
|
||||
export function resetTriggerCreatePrDependencies() {
|
||||
triggerCreatePrDeps = {
|
||||
prisma: new PrismaClient(),
|
||||
fileDiffsToMap,
|
||||
buildPrTitle,
|
||||
createNewBranchFromDiffContents,
|
||||
createStandalonePullRequest,
|
||||
addLabelToPullRequest,
|
||||
assignReviewer,
|
||||
posthog,
|
||||
createStandalonePRTitleAndBody,
|
||||
prContentBuilder: defaultPrContentBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPr(req: Request, res: Response) {
|
||||
try {
|
||||
|
|
@ -56,31 +226,38 @@ export async function createPr(req: Request, res: Response) {
|
|||
} = req.body
|
||||
|
||||
const userId = (req as any).userId
|
||||
//traceId is optional to allow for backwards compatibility, can make this required in the future
|
||||
const traceId = req.body.traceId || ""
|
||||
//coveragePct is optional to allow for backwards compatibility, can make this required in the future
|
||||
|
||||
if (!repo || !owner || !baseBranch || !isDiffContentsWellFormed(diffContents)) {
|
||||
if (!repo || !owner || !baseBranch || !dependencies.isDiffContentsWellFormed(diffContents)) {
|
||||
res.status(400).send("Missing or malformed fields")
|
||||
return
|
||||
}
|
||||
|
||||
const nickname: string | null = await userNickname(userId)
|
||||
const nickname: string | null = await dependencies.userNickname(userId)
|
||||
if (nickname == null) {
|
||||
res.status(401).json({ error: "Unauthorized" }) // Error getting user nickname
|
||||
res.status(401).json({ error: "Unauthorized" })
|
||||
return
|
||||
}
|
||||
|
||||
const installationOctokit = await getInstallationOctokitByOwner(githubApp, owner, repo)
|
||||
const installationOctokit = await dependencies.getInstallationOctokitByOwner(
|
||||
dependencies.githubApp,
|
||||
owner,
|
||||
repo,
|
||||
)
|
||||
if (installationOctokit instanceof Error) {
|
||||
res.status(401).send(installationOctokit.message)
|
||||
return
|
||||
}
|
||||
|
||||
const isCollaborator = await isUserCollaborator(installationOctokit, owner, repo, nickname)
|
||||
const isCollaborator = await dependencies.isUserCollaborator(
|
||||
installationOctokit,
|
||||
owner,
|
||||
repo,
|
||||
nickname,
|
||||
)
|
||||
if (!isCollaborator) {
|
||||
console.log(`${nickname} is not a collaborator on ${owner}/${repo}`)
|
||||
res.status(401).json({ error: "Unauthorized" }) // User is not a collaborator
|
||||
res.status(401).json({ error: "Unauthorized" })
|
||||
return
|
||||
}
|
||||
// TODO: Remove this background upsert logic after ensuring all old repositories have been saved.
|
||||
|
|
@ -91,9 +268,8 @@ export async function createPr(req: Request, res: Response) {
|
|||
Sentry.captureException(err)
|
||||
})
|
||||
// Now check if approval is required
|
||||
if (traceId && requiresApproval(owner, repo)) {
|
||||
// Check existing approval status
|
||||
const optimization = await prisma.optimization_features.findUnique({
|
||||
if (traceId && dependencies.requiresApproval(owner, repo)) {
|
||||
const optimization = await dependencies.prisma.optimization_features.findUnique({
|
||||
where: { trace_id: traceId },
|
||||
select: {
|
||||
approval_required: true,
|
||||
|
|
@ -101,7 +277,6 @@ export async function createPr(req: Request, res: Response) {
|
|||
},
|
||||
})
|
||||
|
||||
// Handle previously rejected requests
|
||||
if (optimization?.approval_status === "rejected") {
|
||||
return res.status(403).json({
|
||||
status: "rejected",
|
||||
|
|
@ -109,10 +284,9 @@ export async function createPr(req: Request, res: Response) {
|
|||
})
|
||||
}
|
||||
|
||||
// Handle approved requests
|
||||
if (optimization?.approval_status === "approved") {
|
||||
console.log(`Request ${traceId} was previously approved, continuing with PR creation`)
|
||||
const prNumber = await triggerCreatePr(
|
||||
const prNumber = await dependencies.triggerCreatePr(
|
||||
owner,
|
||||
repo,
|
||||
baseBranch,
|
||||
|
|
@ -132,10 +306,7 @@ export async function createPr(req: Request, res: Response) {
|
|||
} else {
|
||||
return res.status(500).send("Error creating pull request")
|
||||
}
|
||||
}
|
||||
// Handle new or pending requests
|
||||
else {
|
||||
// Store request data for later processing
|
||||
} else {
|
||||
const requestData = {
|
||||
type: "create-pr",
|
||||
owner,
|
||||
|
|
@ -149,8 +320,7 @@ export async function createPr(req: Request, res: Response) {
|
|||
userId,
|
||||
}
|
||||
|
||||
// Request approval
|
||||
await requestApproval(
|
||||
await dependencies.requestApproval(
|
||||
traceId,
|
||||
owner,
|
||||
repo,
|
||||
|
|
@ -167,8 +337,7 @@ export async function createPr(req: Request, res: Response) {
|
|||
}
|
||||
}
|
||||
|
||||
// No approval required, proceed with PR creation
|
||||
const prNumber = await triggerCreatePr(
|
||||
const prNumber = await dependencies.triggerCreatePr(
|
||||
owner,
|
||||
repo,
|
||||
baseBranch,
|
||||
|
|
@ -228,7 +397,7 @@ export async function createPr(req: Request, res: Response) {
|
|||
if (error instanceof Error) {
|
||||
console.log(`Error message: ${error.message}`)
|
||||
console.log(`Error stack: ${error.stack}`)
|
||||
posthog.capture({
|
||||
dependencies.posthog.capture({
|
||||
distinctId: (req as any).userId,
|
||||
event: `cfapi-create-pr-failed-error-creating-standalone-pr`,
|
||||
properties: {
|
||||
|
|
@ -257,23 +426,21 @@ export async function triggerCreatePr(
|
|||
traceId = "",
|
||||
): Promise<number> {
|
||||
try {
|
||||
// User checks are already done in createPr
|
||||
const diffContentsMap: Map<string, FileDiffContent> = fileDiffsToMap(diffContents)
|
||||
const diffContentsMap: Map<string, FileDiffContent> =
|
||||
triggerCreatePrDeps.fileDiffsToMap(diffContents)
|
||||
|
||||
// Generate a Base36-encoded timestamp
|
||||
const timestamp = Date.now() // Current timestamp in milliseconds
|
||||
const encodedTimestamp = timestamp.toString(36) // e.g., 'kf12oi0'
|
||||
const timestamp = Date.now()
|
||||
const encodedTimestamp = timestamp.toString(36)
|
||||
|
||||
// If you change this, please also change the regex in github-app.ts in the pull_request.closed event
|
||||
const newBranchName = `codeflash/optimize-${prCommentFields.function_name}-${encodedTimestamp}`
|
||||
const commitMessage =
|
||||
buildPrTitle(
|
||||
triggerCreatePrDeps.buildPrTitle(
|
||||
prCommentFields.function_name,
|
||||
prCommentFields.speedup_pct,
|
||||
prCommentFields.speedup_x,
|
||||
) + `\n${prCommentFields.optimization_explanation}`
|
||||
|
||||
const branchCreated = await createNewBranchFromDiffContents(
|
||||
const branchCreated = await triggerCreatePrDeps.createNewBranchFromDiffContents(
|
||||
installationOctokit,
|
||||
owner,
|
||||
repo,
|
||||
|
|
@ -287,15 +454,17 @@ export async function triggerCreatePr(
|
|||
throw new Error(`Failed to create branch ${newBranchName}`)
|
||||
}
|
||||
|
||||
let { title, body } = getStandalonePRTitleAndBody(
|
||||
// Use the injectable function instead of the hardcoded one
|
||||
const { title, body } = triggerCreatePrDeps.createStandalonePRTitleAndBody(
|
||||
prCommentFields,
|
||||
existingTests,
|
||||
generatedTests,
|
||||
coverage_message,
|
||||
newBranchName,
|
||||
triggerCreatePrDeps.prContentBuilder,
|
||||
)
|
||||
// Open a new standalone Codeflash PR (that doesn't reference an original PR, likely just to main)
|
||||
const newPrData = await createNewPullRequest(
|
||||
|
||||
const newPrData = await triggerCreatePrDeps.createStandalonePullRequest(
|
||||
installationOctokit,
|
||||
owner,
|
||||
repo,
|
||||
|
|
@ -304,8 +473,9 @@ export async function triggerCreatePr(
|
|||
newBranchName,
|
||||
baseBranch,
|
||||
)
|
||||
|
||||
try {
|
||||
await prisma.optimization_events.update({
|
||||
await triggerCreatePrDeps.prisma.optimization_events.update({
|
||||
where: { trace_id: traceId },
|
||||
data: {
|
||||
pr_id: String(newPrData.data.id),
|
||||
|
|
@ -316,14 +486,23 @@ export async function triggerCreatePr(
|
|||
} catch (eventError) {
|
||||
console.error("Failed to update optimization event:", eventError)
|
||||
}
|
||||
await addLabelToPullRequest(installationOctokit, owner, repo, newPrData.data.number)
|
||||
|
||||
// Assign the user who initiated the create-pr as the reviewer
|
||||
await assignReviewer(installationOctokit, owner, repo, newPrData.data.number, nickname)
|
||||
await triggerCreatePrDeps.addLabelToPullRequest(
|
||||
installationOctokit,
|
||||
owner,
|
||||
repo,
|
||||
newPrData.data.number,
|
||||
)
|
||||
await triggerCreatePrDeps.assignReviewer(
|
||||
installationOctokit,
|
||||
owner,
|
||||
repo,
|
||||
newPrData.data.number,
|
||||
nickname,
|
||||
)
|
||||
|
||||
// Respond with the new PR details
|
||||
console.log(`Created new PR #${newPrData.data.number} with branch ${newPrData.data.head.ref}`)
|
||||
posthog.capture({
|
||||
triggerCreatePrDeps.posthog.capture({
|
||||
distinctId: userId,
|
||||
event: `cfapi-create-pr-success-standalone-pr-created`,
|
||||
properties: {
|
||||
|
|
@ -336,7 +515,7 @@ export async function triggerCreatePr(
|
|||
})
|
||||
|
||||
if (traceId !== "") {
|
||||
let pull_request_db = await prisma.optimization_features.findUnique({
|
||||
let pull_request_db = await triggerCreatePrDeps.prisma.optimization_features.findUnique({
|
||||
where: {
|
||||
trace_id: traceId,
|
||||
},
|
||||
|
|
@ -346,14 +525,13 @@ export async function triggerCreatePr(
|
|||
})
|
||||
|
||||
if (pull_request_db) {
|
||||
// the trace_id is not in the database then ignore it, because it should already exist by this stage.
|
||||
if (pull_request_db.pull_request === null || pull_request_db.pull_request === undefined) {
|
||||
pull_request_db.pull_request = {}
|
||||
}
|
||||
|
||||
;(pull_request_db.pull_request as any).new_pr_url = newPrData.data.html_url
|
||||
|
||||
await prisma.optimization_features.update({
|
||||
await triggerCreatePrDeps.prisma.optimization_features.update({
|
||||
where: {
|
||||
trace_id: traceId,
|
||||
},
|
||||
|
|
@ -376,17 +554,14 @@ export async function addRepositoryManually(req: AuthorizedUserReq, res: Respons
|
|||
try {
|
||||
const { repositories_added, installation } = req.body
|
||||
|
||||
// Check if required fields are missing
|
||||
if (!repositories_added || !installation?.id) {
|
||||
res.status(400).send("Missing required fields")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the installation exists, if not, create it
|
||||
const installationExists = await getAppInstallationByInstalltionId(installation.id)
|
||||
|
||||
if (!installationExists) {
|
||||
// Create the installation if it doesn't exist
|
||||
await createAppInstallation({
|
||||
installation_id: installation.id,
|
||||
account_id: installation.account.id,
|
||||
|
|
@ -396,10 +571,8 @@ export async function addRepositoryManually(req: AuthorizedUserReq, res: Respons
|
|||
console.log(`Installation created for ID: ${installation.id}`)
|
||||
}
|
||||
|
||||
// Process each repository in the list of added repositories
|
||||
for (const repo of repositories_added) {
|
||||
try {
|
||||
// Upsert logic for repository creation or update
|
||||
await prisma.repositories.upsert({
|
||||
where: { github_repo_id: String(repo.id) },
|
||||
update: {
|
||||
|
|
|
|||
|
|
@ -1,47 +1,98 @@
|
|||
import { userNickname } from "../auth0-mgmt.js"
|
||||
import { getInstallationOctokitByOwner, isUserCollaborator } from "../github/github-utils.js"
|
||||
import { githubApp } from "../github/github-app.js"
|
||||
import { Request, Response } from "express"
|
||||
import { AuthorizedUserReq } from "types.js"
|
||||
|
||||
// @ts-ignore
|
||||
export async function isGitHubAppInstalled(req, res): Promise<void> {
|
||||
const { owner, repo } = req.query
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).send("Missing owner or repo query parameters")
|
||||
// Dependencies interface for easier testing
|
||||
export interface IsGitHubAppInstalledDependencies {
|
||||
userNickname: typeof userNickname
|
||||
getInstallationOctokitByOwner: typeof getInstallationOctokitByOwner
|
||||
isUserCollaborator: typeof isUserCollaborator
|
||||
githubApp: typeof githubApp
|
||||
}
|
||||
|
||||
// Default dependencies
|
||||
let dependencies: IsGitHubAppInstalledDependencies = {
|
||||
userNickname,
|
||||
getInstallationOctokitByOwner,
|
||||
isUserCollaborator,
|
||||
githubApp,
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setIsGitHubAppInstalledDependencies(
|
||||
deps: Partial<IsGitHubAppInstalledDependencies>,
|
||||
) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetIsGitHubAppInstalledDependencies() {
|
||||
dependencies = {
|
||||
userNickname,
|
||||
getInstallationOctokitByOwner,
|
||||
isUserCollaborator,
|
||||
githubApp,
|
||||
}
|
||||
}
|
||||
|
||||
export async function isGitHubAppInstalled(req: Request, res: Response): Promise<void> {
|
||||
const { owner, repo } = req.query
|
||||
|
||||
if (!owner || !repo) {
|
||||
res.status(400).send("Missing owner or repo query parameters")
|
||||
return
|
||||
}
|
||||
|
||||
const ownerStr = String(owner).trim()
|
||||
const repoStr = String(repo).trim()
|
||||
|
||||
if (ownerStr === "" || repoStr === "") {
|
||||
return res.status(400).send("Invalid owner or repo query parameters")
|
||||
}
|
||||
const nickname = await userNickname(req.userId)
|
||||
if (nickname == null) {
|
||||
return res.status(401).json({ error: "Unauthorized" }) // Error getting user nickname
|
||||
res.status(400).send("Invalid owner or repo query parameters")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const installationOctokit = await getInstallationOctokitByOwner(githubApp, ownerStr, repoStr)
|
||||
if (installationOctokit instanceof Error) {
|
||||
return res.status(401).send(installationOctokit.message)
|
||||
const nickname = await dependencies.userNickname((req as AuthorizedUserReq).userId)
|
||||
if (nickname == null) {
|
||||
res.status(401).send("Unauthorized") // Error getting user nickname
|
||||
return
|
||||
}
|
||||
const isCollaborator = await isUserCollaborator(
|
||||
|
||||
const installationOctokit = await dependencies.getInstallationOctokitByOwner(
|
||||
dependencies.githubApp,
|
||||
ownerStr,
|
||||
repoStr,
|
||||
)
|
||||
|
||||
if (installationOctokit instanceof Error) {
|
||||
res.status(401).send(installationOctokit.message)
|
||||
return
|
||||
}
|
||||
|
||||
const isCollaborator = await dependencies.isUserCollaborator(
|
||||
installationOctokit,
|
||||
ownerStr,
|
||||
repoStr,
|
||||
nickname,
|
||||
)
|
||||
|
||||
if (!isCollaborator) {
|
||||
return res
|
||||
res
|
||||
.status(403)
|
||||
.send(
|
||||
`The authenticated user is not a collaborator on the repository ${ownerStr}/${repoStr}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
res.json(true)
|
||||
} catch (error: any) {
|
||||
if (error.status === 404) {
|
||||
return res
|
||||
.status(404)
|
||||
.send(`GitHub App is not installed on the repository ${ownerStr}/${repoStr}`)
|
||||
res.status(404).send(`GitHub App is not installed on the repository ${ownerStr}/${repoStr}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.error("Error checking GitHub App installation or collaborator status:", error)
|
||||
res.status(500).send("Error checking GitHub App installation or collaborator status")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,58 @@
|
|||
import { prisma } from "@codeflash-ai/common"
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export async function optimizationSuccess(req: any, res: any): Promise<void> {
|
||||
// Dependencies interface for easier testing
|
||||
export interface OptimizationSuccessDependencies {
|
||||
prisma: {
|
||||
optimization_events: {
|
||||
updateMany: (params: any) => Promise<{ count: number }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default dependencies
|
||||
let dependencies: OptimizationSuccessDependencies = {
|
||||
prisma,
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setOptimizationSuccessDependencies(deps: Partial<OptimizationSuccessDependencies>) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetOptimizationSuccessDependencies() {
|
||||
dependencies = {
|
||||
prisma,
|
||||
}
|
||||
}
|
||||
|
||||
export async function optimizationSuccess(req: Request, res: Response): Promise<void> {
|
||||
const { trace_id, is_optimization_found } = req.body
|
||||
|
||||
if (typeof trace_id === "undefined" || typeof is_optimization_found !== "boolean") {
|
||||
return res
|
||||
// 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
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await prisma.optimization_events.updateMany({
|
||||
const result = await dependencies.prisma.optimization_events.updateMany({
|
||||
where: { trace_id: trace_id },
|
||||
data: { is_optimization_found: is_optimization_found },
|
||||
})
|
||||
|
||||
if (result.count === 0) {
|
||||
return res.status(404).json({ error: "Optimization event not found." })
|
||||
res.status(404).json({ error: "Optimization event not found." })
|
||||
return
|
||||
}
|
||||
return res.status(200).json({ message: "Optimization status updated." })
|
||||
|
||||
res.status(200).json({ message: "Optimization status updated." })
|
||||
return
|
||||
} catch (error) {
|
||||
console.error("Error in markOptimizationSuccess:", error)
|
||||
return res.status(500).json({ error: "Internal server error." })
|
||||
res.status(500).json({ error: "Internal server error." })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,54 @@ import * as crypto from "crypto"
|
|||
import { posthog } from "../analytics.js"
|
||||
import { processReaction } from "../github/optimization_approval.js"
|
||||
import * as Sentry from "@sentry/node"
|
||||
const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET
|
||||
|
||||
// Dependencies interface for easier testing
|
||||
export interface HandleSlackEventsDependencies {
|
||||
crypto: typeof crypto
|
||||
posthog: typeof posthog
|
||||
processReaction: typeof processReaction
|
||||
Sentry: typeof Sentry
|
||||
getSlackSigningSecret: () => string | undefined
|
||||
getCurrentTime: () => number
|
||||
console: typeof console
|
||||
}
|
||||
|
||||
// Default dependencies
|
||||
let dependencies: HandleSlackEventsDependencies = {
|
||||
crypto,
|
||||
posthog,
|
||||
processReaction,
|
||||
Sentry,
|
||||
getSlackSigningSecret: () => process.env.SLACK_SIGNING_SECRET,
|
||||
getCurrentTime: () => Math.floor(Date.now() / 1000),
|
||||
console,
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setHandleSlackEventsDependencies(deps: Partial<HandleSlackEventsDependencies>) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetHandleSlackEventsDependencies() {
|
||||
dependencies = {
|
||||
crypto,
|
||||
posthog,
|
||||
processReaction,
|
||||
Sentry,
|
||||
getSlackSigningSecret: () => process.env.SLACK_SIGNING_SECRET,
|
||||
getCurrentTime: () => Math.floor(Date.now() / 1000),
|
||||
console,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the request is coming from Slack
|
||||
*/
|
||||
function verifySlackRequest(req: Request): boolean {
|
||||
export function verifySlackRequest(req: Request): boolean {
|
||||
const SLACK_SIGNING_SECRET = dependencies.getSlackSigningSecret()
|
||||
|
||||
if (!SLACK_SIGNING_SECRET) {
|
||||
console.warn("SLACK_SIGNING_SECRET not configured. Skipping request verification.")
|
||||
dependencies.console.warn("SLACK_SIGNING_SECRET not configured. Skipping request verification.")
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -22,7 +62,7 @@ function verifySlackRequest(req: Request): boolean {
|
|||
}
|
||||
|
||||
// Prevent replay attacks - reject requests older than 5 minutes
|
||||
const currentTime = Math.floor(Date.now() / 1000)
|
||||
const currentTime = dependencies.getCurrentTime()
|
||||
if (Math.abs(currentTime - parseInt(slackTimestamp)) > 300) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -31,10 +71,10 @@ function verifySlackRequest(req: Request): boolean {
|
|||
|
||||
const baseString = `v0:${slackTimestamp}:${requestBody}`
|
||||
|
||||
const hmac = crypto.createHmac("sha256", SLACK_SIGNING_SECRET)
|
||||
const hmac = dependencies.crypto.createHmac("sha256", SLACK_SIGNING_SECRET)
|
||||
const signature = "v0=" + hmac.update(baseString).digest("hex")
|
||||
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(slackSignature))
|
||||
return dependencies.crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(slackSignature))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -44,13 +84,13 @@ export async function handleSlackEvents(req: Request, res: Response) {
|
|||
try {
|
||||
// Verify the request is from Slack
|
||||
if (!verifySlackRequest(req)) {
|
||||
console.error("Failed to verify Slack request")
|
||||
dependencies.console.error("Failed to verify Slack request")
|
||||
return res.status(403).send("Invalid request")
|
||||
}
|
||||
|
||||
// Handle URL verification (required when setting up events)
|
||||
if (req.body.type === "url_verification") {
|
||||
console.log("Handling Slack URL verification challenge")
|
||||
dependencies.console.log("Handling Slack URL verification challenge")
|
||||
return res.json({ challenge: req.body.challenge })
|
||||
}
|
||||
|
||||
|
|
@ -59,13 +99,13 @@ export async function handleSlackEvents(req: Request, res: Response) {
|
|||
|
||||
const event = req.body.event
|
||||
if (event) {
|
||||
console.log(`Processing Slack event: ${event.type}`)
|
||||
dependencies.console.log(`Processing Slack event: ${event.type}`)
|
||||
|
||||
if (event.type === "reaction_added") {
|
||||
const processed = await processReaction(event)
|
||||
const processed = await dependencies.processReaction(event)
|
||||
|
||||
if (processed) {
|
||||
posthog.capture({
|
||||
dependencies.posthog.capture({
|
||||
distinctId: "system",
|
||||
event: "slack-approval-reaction-processed",
|
||||
properties: {
|
||||
|
|
@ -77,15 +117,15 @@ export async function handleSlackEvents(req: Request, res: Response) {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error handling Slack event: ${error}`)
|
||||
dependencies.console.error(`Error handling Slack event: ${error}`)
|
||||
|
||||
// If we haven't sent a response yet, send an error
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send("Error processing event")
|
||||
}
|
||||
Sentry.captureException(error)
|
||||
dependencies.Sentry.captureException(error)
|
||||
// Log to monitoring
|
||||
posthog.capture({
|
||||
dependencies.posthog.capture({
|
||||
distinctId: "system",
|
||||
event: "slack-optimization-approval-error",
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -18,11 +18,46 @@ interface SubscriptionData {
|
|||
current_period_end: Date
|
||||
}
|
||||
|
||||
// Dependencies interface for easier testing
|
||||
export interface StripeWebhookDependencies {
|
||||
stripe: typeof stripe
|
||||
prisma: typeof prisma
|
||||
Sentry: typeof Sentry
|
||||
getWebhookSecret: () => string | undefined
|
||||
}
|
||||
|
||||
// Default dependencies
|
||||
let dependencies: StripeWebhookDependencies = {
|
||||
stripe,
|
||||
prisma,
|
||||
Sentry,
|
||||
getWebhookSecret: () => process.env.STRIPE_WEBHOOK_SECRET,
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setStripeWebhookDependencies(deps: Partial<StripeWebhookDependencies>) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetStripeWebhookDependencies() {
|
||||
dependencies = {
|
||||
stripe,
|
||||
prisma,
|
||||
Sentry,
|
||||
getWebhookSecret: () => process.env.STRIPE_WEBHOOK_SECRET,
|
||||
}
|
||||
}
|
||||
|
||||
export async function stripeWebhookHandler(req: Request, res: Response) {
|
||||
const sig = req.headers["stripe-signature"]
|
||||
|
||||
try {
|
||||
const event = stripe.webhooks.constructEvent(req.body, sig!, process.env.STRIPE_WEBHOOK_SECRET!)
|
||||
const webhookSecret = dependencies.getWebhookSecret()
|
||||
if (!webhookSecret) {
|
||||
throw new Error("STRIPE_WEBHOOK_SECRET is not configured")
|
||||
}
|
||||
|
||||
const event = dependencies.stripe.webhooks.constructEvent(req.body, sig!, webhookSecret)
|
||||
|
||||
console.log(`Processing Stripe webhook: ${event.type}`)
|
||||
|
||||
|
|
@ -58,12 +93,12 @@ export async function stripeWebhookHandler(req: Request, res: Response) {
|
|||
res.json({ received: true })
|
||||
} catch (err: any) {
|
||||
console.error("Webhook Error:", err.message)
|
||||
Sentry.captureException(err)
|
||||
dependencies.Sentry.captureException(err)
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckoutCompleted(session: any) {
|
||||
export async function handleCheckoutCompleted(session: any) {
|
||||
// Extract userId from the session metadata
|
||||
const userId = session.metadata?.userId
|
||||
if (!userId) {
|
||||
|
|
@ -77,35 +112,35 @@ async function handleCheckoutCompleted(session: any) {
|
|||
if (session.mode === "subscription" && session.subscription) {
|
||||
// Update the subscription with userId metadata if missing
|
||||
try {
|
||||
await stripe.subscriptions.update(session.subscription, {
|
||||
await dependencies.stripe.subscriptions.update(session.subscription, {
|
||||
metadata: { userId },
|
||||
})
|
||||
|
||||
// Also update customer metadata
|
||||
if (session.customer) {
|
||||
await stripe.customers.update(session.customer, {
|
||||
await dependencies.stripe.customers.update(session.customer, {
|
||||
metadata: { userId },
|
||||
})
|
||||
}
|
||||
|
||||
// Now fetch and process the subscription
|
||||
const subscription = await stripe.subscriptions.retrieve(session.subscription)
|
||||
const subscription = await dependencies.stripe.subscriptions.retrieve(session.subscription)
|
||||
await handleSubscriptionUpdate(subscription)
|
||||
} catch (error) {
|
||||
console.error("Error processing checkout session:", error)
|
||||
Sentry.captureException(error)
|
||||
dependencies.Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdate(subscription: any) {
|
||||
export async function handleSubscriptionUpdate(subscription: any) {
|
||||
// First check subscription metadata
|
||||
let userId = subscription.metadata?.userId
|
||||
|
||||
// If not in subscription metadata, check customer metadata
|
||||
if (!userId && subscription.customer) {
|
||||
try {
|
||||
const customer = await stripe.customers.retrieve(subscription.customer as string)
|
||||
const customer = await dependencies.stripe.customers.retrieve(subscription.customer as string)
|
||||
if ("metadata" in customer) {
|
||||
userId = customer.metadata?.userId
|
||||
}
|
||||
|
|
@ -127,7 +162,7 @@ async function handleSubscriptionUpdate(subscription: any) {
|
|||
|
||||
try {
|
||||
const priceId = subscription.items.data[0].price.id
|
||||
const price = await stripe.prices.retrieve(priceId)
|
||||
const price = await dependencies.stripe.prices.retrieve(priceId)
|
||||
|
||||
console.log(`Updating subscription for user ${userId}`)
|
||||
|
||||
|
|
@ -155,7 +190,8 @@ async function handleSubscriptionUpdate(subscription: any) {
|
|||
updateData.cancellation_request_date = null
|
||||
}
|
||||
}
|
||||
await prisma.subscriptions.upsert({
|
||||
|
||||
await dependencies.prisma.subscriptions.upsert({
|
||||
where: { user_id: userId },
|
||||
create: {
|
||||
user_id: userId,
|
||||
|
|
@ -178,23 +214,23 @@ async function handleSubscriptionUpdate(subscription: any) {
|
|||
console.log(`Successfully updated subscription for user ${userId}`)
|
||||
} catch (error) {
|
||||
console.error("Error updating subscription:", error)
|
||||
Sentry.captureException(error)
|
||||
dependencies.Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubscriptionCancellation(subscription: any) {
|
||||
export async function handleSubscriptionCancellation(subscription: any) {
|
||||
const userId = subscription.metadata.userId
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
await prisma.subscriptions.update({
|
||||
await dependencies.prisma.subscriptions.update({
|
||||
where: { user_id: userId },
|
||||
data: {
|
||||
subscription_status: "canceled",
|
||||
plan_type: "free",
|
||||
optimizations_limit: 100,
|
||||
stripe_subscription_id: null,
|
||||
cancel_at_period_end: false, // The subscription is fully canceled, not pending cancellation
|
||||
cancel_at_period_end: false,
|
||||
cancellation_request_date: null,
|
||||
},
|
||||
})
|
||||
|
|
@ -202,19 +238,19 @@ async function handleSubscriptionCancellation(subscription: any) {
|
|||
console.log(`Cancelled subscription for user ${userId}`)
|
||||
} catch (error) {
|
||||
console.error("Error cancelling subscription:", error)
|
||||
Sentry.captureException(error)
|
||||
dependencies.Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFailedPayment(invoice: any) {
|
||||
export async function handleFailedPayment(invoice: any) {
|
||||
if (!invoice.subscription) return
|
||||
|
||||
try {
|
||||
const subscription = await stripe.subscriptions.retrieve(invoice.subscription)
|
||||
const subscription = await dependencies.stripe.subscriptions.retrieve(invoice.subscription)
|
||||
const userId = subscription.metadata.userId
|
||||
if (!userId) return
|
||||
|
||||
await prisma.subscriptions.update({
|
||||
await dependencies.prisma.subscriptions.update({
|
||||
where: { user_id: userId },
|
||||
data: {
|
||||
subscription_status: "past_due",
|
||||
|
|
@ -224,6 +260,6 @@ async function handleFailedPayment(invoice: any) {
|
|||
console.log(`Updated subscription status to past_due for user ${userId}`)
|
||||
} catch (error) {
|
||||
console.error("Error handling failed payment:", error)
|
||||
Sentry.captureException(error)
|
||||
dependencies.Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,42 @@ import {
|
|||
} from "@codeflash-ai/common"
|
||||
import * as Sentry from "@sentry/node"
|
||||
|
||||
// Dependencies interface for easier testing
|
||||
export interface SubscriptionDependencies {
|
||||
prisma: {
|
||||
subscriptions: {
|
||||
findUnique: (params: any) => Promise<any>
|
||||
}
|
||||
}
|
||||
createCheckoutSession: typeof createCheckoutSession
|
||||
cancelStripeSubscription: typeof cancelStripeSubscription
|
||||
Sentry: {
|
||||
captureException: (error: any) => void
|
||||
}
|
||||
}
|
||||
|
||||
// Default dependencies
|
||||
let dependencies: SubscriptionDependencies = {
|
||||
prisma,
|
||||
createCheckoutSession,
|
||||
cancelStripeSubscription,
|
||||
Sentry,
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setSubscriptionDependencies(deps: Partial<SubscriptionDependencies>) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetSubscriptionDependencies() {
|
||||
dependencies = {
|
||||
prisma,
|
||||
createCheckoutSession,
|
||||
cancelStripeSubscription,
|
||||
Sentry,
|
||||
}
|
||||
}
|
||||
|
||||
// Get a user's subscription details
|
||||
export async function getSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
const userId = req.query.userId as string
|
||||
|
|
@ -16,7 +52,7 @@ export async function getSubscription(req: Request, res: Response, next: NextFun
|
|||
|
||||
try {
|
||||
// Get subscription with usage data
|
||||
const subscription = await prisma.subscriptions.findUnique({
|
||||
const subscription = await dependencies.prisma.subscriptions.findUnique({
|
||||
where: { user_id: userId },
|
||||
})
|
||||
|
||||
|
|
@ -33,7 +69,7 @@ export async function getSubscription(req: Request, res: Response, next: NextFun
|
|||
})
|
||||
} catch (error) {
|
||||
console.error("Error getting subscription:", error)
|
||||
Sentry.captureException(error)
|
||||
dependencies.Sentry.captureException(error)
|
||||
next(error) // Pass errors to the error handler
|
||||
}
|
||||
}
|
||||
|
|
@ -48,7 +84,7 @@ export async function createCheckout(req: Request, res: Response, next: NextFunc
|
|||
|
||||
try {
|
||||
// Use the common function with options object
|
||||
const checkoutUrl = await createCheckoutSession(userId, priceId, {
|
||||
const checkoutUrl = await dependencies.createCheckoutSession(userId, priceId, {
|
||||
successUrl: successUrl || `${process.env.WEBAPP_URL}/app/billing?success=true`,
|
||||
cancelUrl: cancelUrl || `${process.env.WEBAPP_URL}/app/billing?canceled=true`,
|
||||
period: period || (priceId === process.env.STRIPE_PRO_PRICE_YEARLY_ID ? "yearly" : "monthly"),
|
||||
|
|
@ -57,7 +93,7 @@ export async function createCheckout(req: Request, res: Response, next: NextFunc
|
|||
return res.json({ url: checkoutUrl })
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error)
|
||||
Sentry.captureException(error)
|
||||
dependencies.Sentry.captureException(error)
|
||||
next(error) // Pass errors to the error handler
|
||||
}
|
||||
}
|
||||
|
|
@ -72,11 +108,11 @@ export async function cancelSubscription(req: Request, res: Response, next: Next
|
|||
|
||||
try {
|
||||
// Use the common function for cancellation
|
||||
await cancelStripeSubscription(userId)
|
||||
await dependencies.cancelStripeSubscription(userId)
|
||||
return res.json({ status: "canceled" })
|
||||
} catch (error) {
|
||||
console.error("Error canceling subscription:", error)
|
||||
Sentry.captureException(error)
|
||||
dependencies.Sentry.captureException(error)
|
||||
next(error) // Pass errors to the error handler
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@ import {
|
|||
createNewBranchFromDiffContents,
|
||||
} from "../github/create-pr-from-diffcontents.js"
|
||||
import { posthog } from "../analytics.js"
|
||||
import suggester from "@codeflash-ai/code-suggester"
|
||||
import { type FileDiffContent } from "@codeflash-ai/code-suggester/build/src/types.js"
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
import { sendSlackMessage } from "../github/slack_util.js"
|
||||
import { Request, Response } from "express"
|
||||
import { Response } from "express"
|
||||
import { AnyOctokit, AuthorizedUserReq, PullRequestDB } from "../types.js"
|
||||
import {
|
||||
requestApproval,
|
||||
|
|
@ -23,10 +22,74 @@ import {
|
|||
} from "../github/optimization_approval.js"
|
||||
import { registerRepositoryAndMember } from "./utils/github-repo-setup.js"
|
||||
|
||||
interface CustomRequest extends Request {
|
||||
userId?: string
|
||||
// Dependencies interface for easier testing
|
||||
export interface SuggestPrChangesDependencies {
|
||||
prisma: PrismaClient
|
||||
userNickname: typeof userNickname
|
||||
getInstallationOctokitByOwner: typeof getInstallationOctokitByOwner
|
||||
isUserCollaborator: typeof isUserCollaborator
|
||||
requiresApproval: typeof requiresApproval
|
||||
requestApproval: typeof requestApproval
|
||||
posthog: typeof posthog
|
||||
githubApp: typeof githubApp
|
||||
isDiffContentsWellFormed: typeof isDiffContentsWellFormed
|
||||
fileDiffsToMap: typeof fileDiffsToMap
|
||||
determineValidHunks: typeof determineValidHunks
|
||||
buildDependentPrTitle: typeof buildDependentPrTitle
|
||||
buildPrCommentBody: typeof buildPrCommentBody
|
||||
createNewBranchFromDiffContents: typeof createNewBranchFromDiffContents
|
||||
createDependentPullRequest: typeof createDependentPullRequest
|
||||
sendSlackMessage: typeof sendSlackMessage
|
||||
updateOptimizationEvent: typeof updateOptimizationEvent
|
||||
}
|
||||
|
||||
// Default dependencies
|
||||
let dependencies: SuggestPrChangesDependencies = {
|
||||
prisma: new PrismaClient(),
|
||||
userNickname,
|
||||
getInstallationOctokitByOwner,
|
||||
isUserCollaborator,
|
||||
requiresApproval,
|
||||
requestApproval,
|
||||
posthog,
|
||||
githubApp,
|
||||
isDiffContentsWellFormed,
|
||||
fileDiffsToMap,
|
||||
determineValidHunks,
|
||||
buildDependentPrTitle,
|
||||
buildPrCommentBody,
|
||||
createNewBranchFromDiffContents,
|
||||
createDependentPullRequest,
|
||||
sendSlackMessage,
|
||||
updateOptimizationEvent,
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setSuggestPrChangesDependencies(deps: Partial<SuggestPrChangesDependencies>) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetSuggestPrChangesDependencies() {
|
||||
dependencies = {
|
||||
prisma: new PrismaClient(),
|
||||
userNickname,
|
||||
getInstallationOctokitByOwner,
|
||||
isUserCollaborator,
|
||||
requiresApproval,
|
||||
requestApproval,
|
||||
posthog,
|
||||
githubApp,
|
||||
isDiffContentsWellFormed,
|
||||
fileDiffsToMap,
|
||||
determineValidHunks,
|
||||
buildDependentPrTitle,
|
||||
buildPrCommentBody,
|
||||
createNewBranchFromDiffContents,
|
||||
createDependentPullRequest,
|
||||
sendSlackMessage,
|
||||
updateOptimizationEvent,
|
||||
}
|
||||
}
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const slackNotificationConfig = {
|
||||
"Future-House": ["aviary", "paper-qa"],
|
||||
|
|
@ -36,11 +99,12 @@ const slackNotificationConfig = {
|
|||
roboflow: ["inference"],
|
||||
gdsfactory: ["gdsfactory"],
|
||||
}
|
||||
|
||||
// Utility function to update optimization event in the database
|
||||
async function updateOptimizationEvent(traceId: string, prId?: string) {
|
||||
export async function updateOptimizationEvent(traceId: string, prId?: string) {
|
||||
if (traceId !== "") {
|
||||
try {
|
||||
await prisma.optimization_events.update({
|
||||
await dependencies.prisma.optimization_events.update({
|
||||
where: { trace_id: traceId },
|
||||
data: {
|
||||
...(prId ? { pr_id: String(prId) } : {}),
|
||||
|
|
@ -53,6 +117,7 @@ async function updateOptimizationEvent(traceId: string, prId?: string) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function suggestPrChanges(
|
||||
req: AuthorizedUserReq,
|
||||
res: Response,
|
||||
|
|
@ -69,24 +134,33 @@ export async function suggestPrChanges(
|
|||
coverage_message,
|
||||
} = req.body
|
||||
const userId = req.userId
|
||||
//traceId is optional to allow for backwards compatibility, can make this required in the future
|
||||
const traceId = req.body.traceId || ""
|
||||
console.log(`traceId: ${traceId}`)
|
||||
|
||||
if (!repo || !owner || !pullNumber || !isDiffContentsWellFormed(diffContents)) {
|
||||
if (!repo || !owner || !pullNumber || !dependencies.isDiffContentsWellFormed(diffContents)) {
|
||||
return res.status(400).send("Missing or malformed fields")
|
||||
}
|
||||
|
||||
const nickname = await userNickname(userId)
|
||||
const nickname = await dependencies.userNickname(userId)
|
||||
if (nickname == null) {
|
||||
return res.status(401).json({ error: "Unauthorized" }) // Error getting user nickname
|
||||
}
|
||||
|
||||
const installationOctokit = await getInstallationOctokitByOwner(githubApp, owner, repo)
|
||||
const installationOctokit = await dependencies.getInstallationOctokitByOwner(
|
||||
dependencies.githubApp,
|
||||
owner,
|
||||
repo,
|
||||
)
|
||||
if (installationOctokit instanceof Error) {
|
||||
return res.status(401).send(installationOctokit.message)
|
||||
}
|
||||
const isCollaborator = await isUserCollaborator(installationOctokit, owner, repo, nickname)
|
||||
|
||||
const isCollaborator = await dependencies.isUserCollaborator(
|
||||
installationOctokit,
|
||||
owner,
|
||||
repo,
|
||||
nickname,
|
||||
)
|
||||
if (!isCollaborator) {
|
||||
console.log(`${nickname} is not a collaborator on ${owner}/${repo}`)
|
||||
return res.status(401).json({ error: "Unauthorized" }) // User is not a collaborator
|
||||
|
|
@ -100,9 +174,8 @@ export async function suggestPrChanges(
|
|||
Sentry.captureException(err)
|
||||
})
|
||||
// Check if approval is required
|
||||
if (traceId && requiresApproval(owner, repo)) {
|
||||
// Check existing approval status
|
||||
const optimization = await prisma.optimization_features.findUnique({
|
||||
if (traceId && dependencies.requiresApproval(owner, repo)) {
|
||||
const optimization = await dependencies.prisma.optimization_features.findUnique({
|
||||
where: { trace_id: traceId },
|
||||
select: {
|
||||
approval_required: true,
|
||||
|
|
@ -110,7 +183,6 @@ export async function suggestPrChanges(
|
|||
},
|
||||
})
|
||||
|
||||
// Handle previously rejected requests
|
||||
if (optimization?.approval_status === "rejected") {
|
||||
return res.status(403).json({
|
||||
status: "rejected",
|
||||
|
|
@ -118,7 +190,6 @@ export async function suggestPrChanges(
|
|||
})
|
||||
}
|
||||
|
||||
// Handle approved requests
|
||||
if (optimization?.approval_status === "approved") {
|
||||
console.log(`Request ${traceId} was previously approved, continuing with PR suggestion`)
|
||||
const result = await triggerSuggestPrChanges(
|
||||
|
|
@ -137,10 +208,7 @@ export async function suggestPrChanges(
|
|||
)
|
||||
|
||||
return res.json(result)
|
||||
}
|
||||
// Handle new or pending requests
|
||||
else {
|
||||
// Store request data for later processing
|
||||
} else {
|
||||
const requestData = {
|
||||
type: "suggest-pr-changes",
|
||||
owner,
|
||||
|
|
@ -154,8 +222,7 @@ export async function suggestPrChanges(
|
|||
userId,
|
||||
}
|
||||
|
||||
// Request approval
|
||||
await requestApproval(
|
||||
await dependencies.requestApproval(
|
||||
traceId,
|
||||
owner,
|
||||
repo,
|
||||
|
|
@ -230,7 +297,7 @@ export async function suggestPrChanges(
|
|||
console.log(`Error in /cfapi/suggest-pr-changes: ${error}`)
|
||||
console.log(`Error message: ${error.message}`)
|
||||
console.log(`Error stack: ${error.stack}`)
|
||||
posthog.capture({
|
||||
dependencies.posthog.capture({
|
||||
distinctId: req.userId,
|
||||
event: `cfapi-suggest-pr-changes-failed-error`,
|
||||
properties: {
|
||||
|
|
@ -242,7 +309,6 @@ export async function suggestPrChanges(
|
|||
}
|
||||
}
|
||||
|
||||
// New function to trigger suggest PR changes - keeps all the existing logic
|
||||
export async function triggerSuggestPrChanges(
|
||||
owner: string,
|
||||
repo: string,
|
||||
|
|
@ -259,10 +325,9 @@ export async function triggerSuggestPrChanges(
|
|||
res?: Response,
|
||||
): Promise<Response | number | null> {
|
||||
try {
|
||||
// Suggest changes - all the existing logic is preserved
|
||||
const diffContentsMap: Map<string, FileDiffContent> = fileDiffsToMap(diffContents)
|
||||
const diffContentsMap: Map<string, FileDiffContent> = dependencies.fileDiffsToMap(diffContents)
|
||||
|
||||
const { validHunks, invalidHunks } = await determineValidHunks(
|
||||
const { validHunks, invalidHunks } = await dependencies.determineValidHunks(
|
||||
installationOctokit.rest as AnyOctokit,
|
||||
{ owner, repo },
|
||||
pullNumber,
|
||||
|
|
@ -270,16 +335,13 @@ export async function triggerSuggestPrChanges(
|
|||
diffContentsMap,
|
||||
)
|
||||
|
||||
// a timestamp format like 2024-01-31-12.59.48
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/:/g, ".")
|
||||
.replace(/\.\d+Z$/, "")
|
||||
|
||||
// If you change this, please also change the regex in github-app.ts in the pull_request.closed event
|
||||
const newBranchName = `codeflash/optimize-pr${pullNumber}-${timestamp}`
|
||||
|
||||
// Get the head branch of the original pull request
|
||||
const originalPrData = await installationOctokit.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
|
|
@ -287,15 +349,17 @@ export async function triggerSuggestPrChanges(
|
|||
})
|
||||
const baseBranch = originalPrData.data.head.ref
|
||||
console.log(`Attempting to access ref for: ${owner}/${repo}, branch: ${baseBranch}`)
|
||||
|
||||
const commitMessage =
|
||||
buildDependentPrTitle(
|
||||
dependencies.buildDependentPrTitle(
|
||||
prCommentFields.function_name,
|
||||
prCommentFields.speedup_pct,
|
||||
prCommentFields.speedup_x,
|
||||
pullNumber,
|
||||
baseBranch,
|
||||
) + `\n${prCommentFields.optimization_explanation}`
|
||||
const branchCreated = await createNewBranchFromDiffContents(
|
||||
|
||||
const branchCreated = await dependencies.createNewBranchFromDiffContents(
|
||||
installationOctokit,
|
||||
owner,
|
||||
repo,
|
||||
|
|
@ -304,12 +368,14 @@ export async function triggerSuggestPrChanges(
|
|||
diffContentsMap,
|
||||
commitMessage,
|
||||
)
|
||||
|
||||
if (!branchCreated) {
|
||||
throw new Error(`Failed to create branch ${newBranchName}`)
|
||||
}
|
||||
|
||||
let hasMultipleHunksInSameFile = false
|
||||
let hasMultipleFiles = validHunks.size > 1
|
||||
|
||||
for (const [filePath, hunks] of validHunks.entries()) {
|
||||
if (hunks.length > 1) {
|
||||
console.log(
|
||||
|
|
@ -319,29 +385,25 @@ export async function triggerSuggestPrChanges(
|
|||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMultipleFiles) {
|
||||
console.log(
|
||||
`Found ${validHunks.size} files with changes, using dependent PR instead of review comments`,
|
||||
)
|
||||
}
|
||||
// Modified condition: Create a dependent PR if there are invalid hunks OR multiple valid hunks
|
||||
// This addresses both the invalid hunks case and the code suggester library issues
|
||||
|
||||
if (
|
||||
invalidHunks.size > 0 ||
|
||||
validHunks.size > 1 ||
|
||||
hasMultipleFiles ||
|
||||
hasMultipleHunksInSameFile
|
||||
) {
|
||||
// we can't suggest all of the hunks for this PR, because some of them are invalid (out of scope for this PR).
|
||||
// so instead, let's make a new branch, commit the changes,
|
||||
// and make a new PR from that branch onto the PR's branch
|
||||
|
||||
console.log(
|
||||
`Creating a dependent PR because there are ${invalidHunks.size > 0 ? "invalid hunks" : "multiple valid hunks"}.`,
|
||||
)
|
||||
console.log(`Making a new dependent PR...`)
|
||||
|
||||
const newPrData = await createDependentPullRequest(
|
||||
const newPrData = await dependencies.createDependentPullRequest(
|
||||
installationOctokit as AnyOctokit,
|
||||
owner,
|
||||
repo,
|
||||
|
|
@ -354,18 +416,17 @@ export async function triggerSuggestPrChanges(
|
|||
coverage_message,
|
||||
)
|
||||
|
||||
// Respond with the new PR details
|
||||
console.log(
|
||||
`Created new dependent PR #${newPrData.data.number} from branch ${newPrData.data.head.ref}`,
|
||||
)
|
||||
|
||||
if (slackNotificationConfig[owner as keyof typeof slackNotificationConfig]?.includes(repo)) {
|
||||
await sendSlackMessage(
|
||||
await dependencies.sendSlackMessage(
|
||||
`new dependent PR created: ${newPrData.data.html_url} for ${owner}/${repo}`,
|
||||
)
|
||||
}
|
||||
|
||||
posthog.capture({
|
||||
dependencies.posthog.capture({
|
||||
distinctId: userId,
|
||||
event: `cfapi-suggest-pr-changes-success-dependent-pr-created`,
|
||||
properties: {
|
||||
|
|
@ -376,8 +437,9 @@ export async function triggerSuggestPrChanges(
|
|||
PRURL: newPrData.data.html_url,
|
||||
},
|
||||
})
|
||||
|
||||
if (traceId !== "") {
|
||||
let pull_request_db = await prisma.optimization_features.findUnique({
|
||||
let pull_request_db = await dependencies.prisma.optimization_features.findUnique({
|
||||
where: {
|
||||
trace_id: traceId,
|
||||
},
|
||||
|
|
@ -385,6 +447,7 @@ export async function triggerSuggestPrChanges(
|
|||
pull_request: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (pull_request_db) {
|
||||
if (pull_request_db.pull_request === null || pull_request_db.pull_request === undefined) {
|
||||
pull_request_db.pull_request = {}
|
||||
|
|
@ -392,7 +455,7 @@ export async function triggerSuggestPrChanges(
|
|||
;(pull_request_db as PullRequestDB).pull_request.dependent_pr_url =
|
||||
newPrData.data.html_url
|
||||
|
||||
await prisma.optimization_features.update({
|
||||
await dependencies.prisma.optimization_features.update({
|
||||
where: {
|
||||
trace_id: traceId,
|
||||
},
|
||||
|
|
@ -402,9 +465,9 @@ export async function triggerSuggestPrChanges(
|
|||
})
|
||||
}
|
||||
}
|
||||
await updateOptimizationEvent(traceId, newPrData.data.id)
|
||||
|
||||
// For backward compatibility
|
||||
await dependencies.updateOptimizationEvent(traceId, newPrData.data.id)
|
||||
|
||||
if (res) {
|
||||
res.json(newPrData.data.number)
|
||||
return res
|
||||
|
|
@ -412,10 +475,9 @@ export async function triggerSuggestPrChanges(
|
|||
|
||||
return newPrData.data.number
|
||||
} else {
|
||||
// Only use the code suggester library when there is exactly one valid hunk and no invalid hunks
|
||||
console.log(`Creating unified review for a single valid hunk.`)
|
||||
// Include all the information in a single PR comment
|
||||
const prCommentBody = buildPrCommentBody(
|
||||
|
||||
const prCommentBody = dependencies.buildPrCommentBody(
|
||||
prCommentFields,
|
||||
existingTests,
|
||||
generatedTests,
|
||||
|
|
@ -423,19 +485,18 @@ export async function triggerSuggestPrChanges(
|
|||
newBranchName,
|
||||
{ isUnifiedReview: true, includeHeader: false, isCollapsed: true },
|
||||
)
|
||||
|
||||
let reviewComments = []
|
||||
let foundInvalidHunk = false
|
||||
|
||||
// Process each valid hunk entry in the Map
|
||||
for (const [filePath, hunks] of validHunks.entries()) {
|
||||
// For each hunk in this file
|
||||
for (const hunk of hunks) {
|
||||
if (hunk.oldStart < hunk.oldEnd) {
|
||||
const newContent = hunk.newContent.join("\n")
|
||||
const isLongDiff = newContent.length > 500
|
||||
let commentBody
|
||||
|
||||
if (isLongDiff) {
|
||||
// Create a collapsible section for long diffs
|
||||
commentBody =
|
||||
prCommentBody +
|
||||
"\n\n" +
|
||||
|
|
@ -446,15 +507,15 @@ export async function triggerSuggestPrChanges(
|
|||
"\n```\n" +
|
||||
"</details>"
|
||||
} else {
|
||||
// For smaller diffs, show them directly
|
||||
commentBody = prCommentBody + "\n\n" + "```suggestion\n" + newContent + "\n```"
|
||||
}
|
||||
|
||||
reviewComments.push({
|
||||
path: filePath,
|
||||
line: hunk.oldEnd,
|
||||
start_line: hunk.oldStart,
|
||||
side: "RIGHT",
|
||||
body: commentBody, // Format as a suggestion
|
||||
body: commentBody,
|
||||
})
|
||||
} else {
|
||||
console.log(
|
||||
|
|
@ -470,7 +531,6 @@ export async function triggerSuggestPrChanges(
|
|||
}
|
||||
}
|
||||
|
||||
// Add explanation comment
|
||||
if (!foundInvalidHunk && reviewComments.length > 0) {
|
||||
const review = await installationOctokit.rest.pulls.createReview({
|
||||
owner,
|
||||
|
|
@ -480,14 +540,18 @@ export async function triggerSuggestPrChanges(
|
|||
event: "COMMENT",
|
||||
comments: reviewComments,
|
||||
})
|
||||
|
||||
console.log(`Added review comment to PR #${pullNumber}: ${review.data.html_url}`)
|
||||
|
||||
if (
|
||||
slackNotificationConfig[owner as keyof typeof slackNotificationConfig]?.includes(repo)
|
||||
) {
|
||||
await sendSlackMessage(`Suggestions made for ${review.data.html_url} in ${owner}/${repo}`)
|
||||
await dependencies.sendSlackMessage(
|
||||
`Suggestions made for ${review.data.html_url} in ${owner}/${repo}`,
|
||||
)
|
||||
}
|
||||
posthog.capture({
|
||||
|
||||
dependencies.posthog.capture({
|
||||
distinctId: userId,
|
||||
event: `cfapi-suggest-pr-changes-success-suggestions-made`,
|
||||
properties: {
|
||||
|
|
@ -499,7 +563,7 @@ export async function triggerSuggestPrChanges(
|
|||
})
|
||||
|
||||
if (traceId !== "") {
|
||||
let pull_request_db = await prisma.optimization_features.findUnique({
|
||||
let pull_request_db = await dependencies.prisma.optimization_features.findUnique({
|
||||
where: {
|
||||
trace_id: traceId,
|
||||
},
|
||||
|
|
@ -507,8 +571,8 @@ export async function triggerSuggestPrChanges(
|
|||
pull_request: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (pull_request_db) {
|
||||
// the trace_id is not in the database then ignore it, because it should already exist by this stage.
|
||||
if (
|
||||
pull_request_db.pull_request === null ||
|
||||
pull_request_db.pull_request === undefined
|
||||
|
|
@ -519,7 +583,7 @@ export async function triggerSuggestPrChanges(
|
|||
;(pull_request_db as PullRequestDB).pull_request.review_suggestion_pr_url =
|
||||
review.data.html_url
|
||||
|
||||
await prisma.optimization_features.update({
|
||||
await dependencies.prisma.optimization_features.update({
|
||||
where: {
|
||||
trace_id: traceId,
|
||||
},
|
||||
|
|
@ -529,9 +593,9 @@ export async function triggerSuggestPrChanges(
|
|||
})
|
||||
}
|
||||
}
|
||||
await updateOptimizationEvent(traceId)
|
||||
|
||||
// For backward compatibility, handle the response object if provided
|
||||
await dependencies.updateOptimizationEvent(traceId)
|
||||
|
||||
if (res) {
|
||||
res.json(review.data.id)
|
||||
return res
|
||||
|
|
@ -539,16 +603,13 @@ export async function triggerSuggestPrChanges(
|
|||
|
||||
return review.data.id
|
||||
} else {
|
||||
// Don't create a dependent PR, just return an error response
|
||||
const reason = foundInvalidHunk
|
||||
? "invalid line ordering in hunks"
|
||||
: "no valid review comments could be created"
|
||||
|
||||
console.log(`Cannot create review due to ${reason}`)
|
||||
|
||||
// Return a meaningful error response
|
||||
if (res) {
|
||||
// For backward compatibility
|
||||
return res.status(422).json({
|
||||
error: `Cannot create review comments due to ${reason}`,
|
||||
message: "Please consider creating a dependent PR instead",
|
||||
|
|
@ -564,7 +625,6 @@ export async function triggerSuggestPrChanges(
|
|||
console.error(`Error message: ${error.message}`)
|
||||
console.error(`Error stack: ${error.stack}`)
|
||||
|
||||
// For backward compatibility, handle the response object if provided
|
||||
if (res) {
|
||||
res.status(500).send(`Error creating pull request: ${error.message}`)
|
||||
return res
|
||||
|
|
|
|||
58
js/cf-api/endpoints/tests/cli-get-user.unit.test.ts
Normal file
58
js/cf-api/endpoints/tests/cli-get-user.unit.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { beforeEach, describe, expect, it, jest } from "@jest/globals"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { dirname } from "path"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
const min_version = fs
|
||||
.readFileSync(path.join(__dirname, "../min_supported_version.txt"), "utf8")
|
||||
.trim()
|
||||
|
||||
describe("getUser (unit test)", () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
it("should respond with userId and min_version when cli_version is provided", async () => {
|
||||
const { getUser } = await import("../cli-get-user")
|
||||
|
||||
const req = {
|
||||
headers: { cli_version: "1.0.0" },
|
||||
userId: "test-user-id",
|
||||
} as any
|
||||
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
} as any
|
||||
|
||||
getUser(req, res)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200)
|
||||
expect(res.send).toHaveBeenCalledWith({
|
||||
userId: "test-user-id",
|
||||
min_version: min_version,
|
||||
})
|
||||
})
|
||||
|
||||
it("should respond with userId when cli_version is unknown", async () => {
|
||||
const { getUser } = await import("../cli-get-user")
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
userId: "test-user-id",
|
||||
} as any
|
||||
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
} as any
|
||||
|
||||
getUser(req, res)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200)
|
||||
expect(res.send).toHaveBeenCalledWith("test-user-id")
|
||||
})
|
||||
})
|
||||
151
js/cf-api/endpoints/tests/collect-email.unit.test.ts
Normal file
151
js/cf-api/endpoints/tests/collect-email.unit.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"
|
||||
import { collectEmail, setNotionClient } from "../collect-email"
|
||||
|
||||
describe("collectEmail", () => {
|
||||
let mockPagesCreate: jest.MockedFunction<any>
|
||||
let mockNotionClient: any
|
||||
let mockReq: any
|
||||
let mockRes: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock for pages.create
|
||||
mockPagesCreate = jest.fn()
|
||||
|
||||
// Create mock notion client
|
||||
mockNotionClient = {
|
||||
pages: {
|
||||
create: mockPagesCreate,
|
||||
},
|
||||
}
|
||||
|
||||
// Inject the mock
|
||||
setNotionClient(mockNotionClient)
|
||||
|
||||
// Setup request and response mocks
|
||||
mockReq = {
|
||||
body: {
|
||||
email: "test@example.com",
|
||||
userId: "user123",
|
||||
username: "testuser",
|
||||
},
|
||||
}
|
||||
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up
|
||||
setNotionClient(null)
|
||||
})
|
||||
|
||||
it("should successfully collect email and create notion page", async () => {
|
||||
mockPagesCreate.mockResolvedValue({ id: "page123" })
|
||||
|
||||
await collectEmail(mockReq, mockRes)
|
||||
|
||||
expect(mockPagesCreate).toHaveBeenCalledWith({
|
||||
parent: {
|
||||
database_id: "test-notion-db-id", // Set in .env.test
|
||||
},
|
||||
properties: {
|
||||
Email: {
|
||||
title: [
|
||||
{
|
||||
text: {
|
||||
content: "test@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Nickname: {
|
||||
rich_text: [
|
||||
{
|
||||
text: {
|
||||
content: "testuser",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
github_id: {
|
||||
rich_text: [
|
||||
{
|
||||
text: {
|
||||
content: "user123",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Email collected successfully.")
|
||||
})
|
||||
|
||||
it("should handle errors and return 500 status", async () => {
|
||||
const error = new Error("Notion API error")
|
||||
error.stack = "Error stack trace"
|
||||
mockPagesCreate.mockRejectedValue(error)
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await collectEmail(mockReq, mockRes)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("collecting waitlist emails")
|
||||
expect(consoleSpy).toHaveBeenCalledWith(`Error in /cfapi/collect-email: ${error}`)
|
||||
expect(consoleSpy).toHaveBeenCalledWith(`Error message: ${error.message}`)
|
||||
expect(consoleSpy).toHaveBeenCalledWith(`Error stack: ${error.stack}`)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.send).toHaveBeenCalledWith(`Error collecting emails: ${error.message}`)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle empty request body", async () => {
|
||||
mockReq.body = {}
|
||||
mockPagesCreate.mockResolvedValue({ id: "page123" })
|
||||
|
||||
await collectEmail(mockReq, mockRes)
|
||||
|
||||
expect(mockPagesCreate).toHaveBeenCalledWith({
|
||||
parent: {
|
||||
database_id: "test-notion-db-id", // Set in .env.test
|
||||
},
|
||||
properties: {
|
||||
Email: {
|
||||
title: [
|
||||
{
|
||||
text: {
|
||||
content: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Nickname: {
|
||||
rich_text: [
|
||||
{
|
||||
text: {
|
||||
content: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
github_id: {
|
||||
rich_text: [
|
||||
{
|
||||
text: {
|
||||
content: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
})
|
||||
})
|
||||
1150
js/cf-api/endpoints/tests/create-pr.unit.test.ts
Normal file
1150
js/cf-api/endpoints/tests/create-pr.unit.test.ts
Normal file
File diff suppressed because it is too large
Load diff
17
js/cf-api/endpoints/tests/healthcheck.unit.test.ts
Normal file
17
js/cf-api/endpoints/tests/healthcheck.unit.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { healthcheck } from "../healthcheck"
|
||||
import { jest, describe, it, expect } from "@jest/globals"
|
||||
|
||||
describe("healthcheck", () => {
|
||||
it("should respond with status 200 and 'OK'", () => {
|
||||
const req = {}
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
}
|
||||
|
||||
healthcheck(req, res)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200)
|
||||
expect(res.send).toHaveBeenCalledWith("OK")
|
||||
})
|
||||
})
|
||||
318
js/cf-api/endpoints/tests/is-github-app-installed.unit.test.ts
Normal file
318
js/cf-api/endpoints/tests/is-github-app-installed.unit.test.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach, beforeAll } from "@jest/globals"
|
||||
import { Request, Response } from "express"
|
||||
let isGitHubAppInstalled: typeof import("../is-github-app-installed").isGitHubAppInstalled
|
||||
let setIsGitHubAppInstalledDependencies: typeof import("../is-github-app-installed").setIsGitHubAppInstalledDependencies
|
||||
let resetIsGitHubAppInstalledDependencies: typeof import("../is-github-app-installed").resetIsGitHubAppInstalledDependencies
|
||||
|
||||
// Extend Request interface to include userId for testing
|
||||
interface TestRequest extends Request {
|
||||
userId: string
|
||||
}
|
||||
|
||||
describe("isGitHubAppInstalled", () => {
|
||||
beforeAll(async () => {
|
||||
process.env.KEY_VAULT_NAME = "mocked-keyvault-name"
|
||||
const mod = await import("../is-github-app-installed")
|
||||
isGitHubAppInstalled = mod.isGitHubAppInstalled
|
||||
setIsGitHubAppInstalledDependencies = mod.setIsGitHubAppInstalledDependencies
|
||||
resetIsGitHubAppInstalledDependencies = mod.resetIsGitHubAppInstalledDependencies
|
||||
})
|
||||
let mockReq: Partial<TestRequest>
|
||||
let mockRes: Partial<Response>
|
||||
let mockDependencies: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup response mock with proper typing
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis() as any,
|
||||
send: jest.fn() as any,
|
||||
json: jest.fn() as any,
|
||||
}
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies = {
|
||||
userNickname: jest.fn(),
|
||||
getInstallationOctokitByOwner: jest.fn(),
|
||||
isUserCollaborator: jest.fn(),
|
||||
githubApp: {},
|
||||
}
|
||||
|
||||
setIsGitHubAppInstalledDependencies(mockDependencies)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetIsGitHubAppInstalledDependencies()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("input validation", () => {
|
||||
it("should return 400 when owner is missing", async () => {
|
||||
mockReq = {
|
||||
query: { repo: "test-repo" },
|
||||
userId: "test-user-id",
|
||||
}
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Missing owner or repo query parameters")
|
||||
})
|
||||
|
||||
it("should return 400 when repo is missing", async () => {
|
||||
mockReq = {
|
||||
query: { owner: "test-owner" },
|
||||
userId: "test-user-id",
|
||||
}
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Missing owner or repo query parameters")
|
||||
})
|
||||
|
||||
it("should return 400 when both owner and repo are missing", async () => {
|
||||
mockReq = {
|
||||
query: {},
|
||||
userId: "test-user-id",
|
||||
}
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Missing owner or repo query parameters")
|
||||
})
|
||||
|
||||
it("should return 400 when owner is empty string", async () => {
|
||||
mockReq = {
|
||||
query: { owner: " ", repo: "test-repo" },
|
||||
userId: "test-user-id",
|
||||
}
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Invalid owner or repo query parameters")
|
||||
})
|
||||
|
||||
it("should return 400 when repo is empty string", async () => {
|
||||
mockReq = {
|
||||
query: { owner: "test-owner", repo: " " },
|
||||
userId: "test-user-id",
|
||||
}
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Invalid owner or repo query parameters")
|
||||
})
|
||||
|
||||
it("should trim whitespace from owner and repo", async () => {
|
||||
mockReq = {
|
||||
query: { owner: " test-owner ", repo: " test-repo " },
|
||||
userId: "test-user-id",
|
||||
}
|
||||
|
||||
mockDependencies.userNickname.mockResolvedValue("test-nickname")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue({})
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(true)
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.getInstallationOctokitByOwner).toHaveBeenCalledWith(
|
||||
mockDependencies.githubApp,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("authentication", () => {
|
||||
beforeEach(() => {
|
||||
mockReq = {
|
||||
query: { owner: "test-owner", repo: "test-repo" },
|
||||
userId: "test-user-id",
|
||||
}
|
||||
})
|
||||
|
||||
it("should return 401 when user nickname is null", async () => {
|
||||
mockDependencies.userNickname.mockResolvedValue(null)
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.userNickname).toHaveBeenCalledWith("test-user-id")
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Unauthorized")
|
||||
})
|
||||
|
||||
it("should return 401 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)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Installation not found")
|
||||
})
|
||||
})
|
||||
|
||||
describe("authorization", () => {
|
||||
beforeEach(() => {
|
||||
mockReq = {
|
||||
query: { owner: "test-owner", repo: "test-repo" },
|
||||
userId: "test-user-id",
|
||||
}
|
||||
|
||||
mockDependencies.userNickname.mockResolvedValue("test-nickname")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it("should return 403 when user is not a collaborator", async () => {
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(false)
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.isUserCollaborator).toHaveBeenCalledWith(
|
||||
{},
|
||||
"test-owner",
|
||||
"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",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return true when user is a collaborator", async () => {
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(true)
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.isUserCollaborator).toHaveBeenCalledWith(
|
||||
{},
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"test-nickname",
|
||||
)
|
||||
expect(mockRes.json).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
beforeEach(() => {
|
||||
mockReq = {
|
||||
query: { owner: "test-owner", repo: "test-repo" },
|
||||
userId: "test-user-id",
|
||||
}
|
||||
})
|
||||
|
||||
it("should return 404 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)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404)
|
||||
expect(mockRes.send).toHaveBeenCalledWith(
|
||||
"GitHub App is not installed on the repository test-owner/test-repo",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return 500 for other errors from isUserCollaborator", async () => {
|
||||
const error = new Error("Unexpected error")
|
||||
mockDependencies.userNickname.mockResolvedValue("test-nickname")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue({})
|
||||
mockDependencies.isUserCollaborator.mockRejectedValue(error)
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error checking GitHub App installation or collaborator status:",
|
||||
error,
|
||||
)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.send).toHaveBeenCalledWith(
|
||||
"Error checking GitHub App installation or collaborator status",
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle 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)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error checking GitHub App installation or collaborator status:",
|
||||
error,
|
||||
)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.send).toHaveBeenCalledWith(
|
||||
"Error checking GitHub App installation or collaborator status",
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle 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)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error checking GitHub App installation or collaborator status:",
|
||||
error,
|
||||
)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.send).toHaveBeenCalledWith(
|
||||
"Error checking GitHub App installation or collaborator status",
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("successful flow", () => {
|
||||
it("should return true for valid installation and authorized user", async () => {
|
||||
mockReq = {
|
||||
query: { owner: "test-owner", repo: "test-repo" },
|
||||
userId: "test-user-id",
|
||||
}
|
||||
|
||||
const mockOctokit = { rest: { apps: {} } }
|
||||
|
||||
mockDependencies.userNickname.mockResolvedValue("test-nickname")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(mockOctokit)
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(true)
|
||||
|
||||
await isGitHubAppInstalled(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.userNickname).toHaveBeenCalledWith("test-user-id")
|
||||
expect(mockDependencies.getInstallationOctokitByOwner).toHaveBeenCalledWith(
|
||||
mockDependencies.githubApp,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
)
|
||||
expect(mockDependencies.isUserCollaborator).toHaveBeenCalledWith(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"test-nickname",
|
||||
)
|
||||
expect(mockRes.json).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
353
js/cf-api/endpoints/tests/optimiaztion-success.unit.test.ts
Normal file
353
js/cf-api/endpoints/tests/optimiaztion-success.unit.test.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"
|
||||
import { Request, Response } from "express"
|
||||
import {
|
||||
optimizationSuccess,
|
||||
setOptimizationSuccessDependencies,
|
||||
resetOptimizationSuccessDependencies,
|
||||
} from "../optimiaztion-success"
|
||||
|
||||
describe("optimizationSuccess", () => {
|
||||
let mockReq: Partial<Request>
|
||||
let mockRes: Partial<Response>
|
||||
let mockDependencies: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup response mock
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis() as jest.MockedFunction<(code: number) => Response>,
|
||||
json: jest.fn() as jest.MockedFunction<(body?: any) => Response>,
|
||||
}
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies = {
|
||||
prisma: {
|
||||
optimization_events: {
|
||||
updateMany: jest.fn() as jest.MockedFunction<(params: any) => Promise<{ count: number }>>,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
setOptimizationSuccessDependencies(mockDependencies)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetOptimizationSuccessDependencies()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("input validation", () => {
|
||||
it("should return 400 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.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return 400 when trace_id is null", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: null,
|
||||
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.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return 400 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.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return 400 when is_optimization_found is not a boolean", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "test-trace-id",
|
||||
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.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return 400 when is_optimization_found is null", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "test-trace-id",
|
||||
is_optimization_found: null,
|
||||
},
|
||||
} 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.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return 400 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.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should accept valid input with is_optimization_found as true", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "test-trace-id",
|
||||
is_optimization_found: true,
|
||||
},
|
||||
} as Request
|
||||
|
||||
mockDependencies.prisma.optimization_events.updateMany.mockResolvedValue({ count: 1 })
|
||||
|
||||
await optimizationSuccess(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: "Optimization status updated." })
|
||||
})
|
||||
|
||||
it("should accept valid input with is_optimization_found as false", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "test-trace-id",
|
||||
is_optimization_found: false,
|
||||
},
|
||||
} as Request
|
||||
|
||||
mockDependencies.prisma.optimization_events.updateMany.mockResolvedValue({ count: 1 })
|
||||
|
||||
await optimizationSuccess(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: "Optimization status updated." })
|
||||
})
|
||||
|
||||
it("should accept empty string trace_id as valid", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "",
|
||||
is_optimization_found: true,
|
||||
},
|
||||
} as Request
|
||||
|
||||
mockDependencies.prisma.optimization_events.updateMany.mockResolvedValue({ count: 1 })
|
||||
|
||||
await optimizationSuccess(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: "Optimization status updated." })
|
||||
})
|
||||
})
|
||||
|
||||
describe("database operations", () => {
|
||||
beforeEach(() => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "test-trace-id",
|
||||
is_optimization_found: true,
|
||||
},
|
||||
} as Request
|
||||
})
|
||||
|
||||
it("should successfully update optimization event", async () => {
|
||||
mockDependencies.prisma.optimization_events.updateMany.mockResolvedValue({ count: 1 })
|
||||
|
||||
await optimizationSuccess(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.prisma.optimization_events.updateMany).toHaveBeenCalledWith({
|
||||
where: { trace_id: "test-trace-id" },
|
||||
data: { is_optimization_found: true },
|
||||
})
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: "Optimization status updated." })
|
||||
})
|
||||
|
||||
it("should return 404 when no optimization event is found", async () => {
|
||||
mockDependencies.prisma.optimization_events.updateMany.mockResolvedValue({ count: 0 })
|
||||
|
||||
await optimizationSuccess(mockReq as Request, mockRes as Response)
|
||||
|
||||
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." })
|
||||
})
|
||||
|
||||
it("should handle multiple records updated", async () => {
|
||||
mockDependencies.prisma.optimization_events.updateMany.mockResolvedValue({ count: 3 })
|
||||
|
||||
await optimizationSuccess(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.prisma.optimization_events.updateMany).toHaveBeenCalledWith({
|
||||
where: { trace_id: "test-trace-id" },
|
||||
data: { is_optimization_found: true },
|
||||
})
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: "Optimization status updated." })
|
||||
})
|
||||
|
||||
it("should handle 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:", error)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Internal server error." })
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle numeric trace_id", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: 12345,
|
||||
is_optimization_found: false,
|
||||
},
|
||||
} as Request
|
||||
|
||||
mockDependencies.prisma.optimization_events.updateMany.mockResolvedValue({ count: 1 })
|
||||
|
||||
await optimizationSuccess(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.prisma.optimization_events.updateMany).toHaveBeenCalledWith({
|
||||
where: { trace_id: 12345 },
|
||||
data: { is_optimization_found: false },
|
||||
})
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: "Optimization status updated." })
|
||||
})
|
||||
|
||||
it("should handle extra fields in request body", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "test-trace-id",
|
||||
is_optimization_found: true,
|
||||
extra_field: "should be ignored",
|
||||
another_field: 123,
|
||||
},
|
||||
} as Request
|
||||
|
||||
mockDependencies.prisma.optimization_events.updateMany.mockResolvedValue({ count: 1 })
|
||||
|
||||
await optimizationSuccess(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.prisma.optimization_events.updateMany).toHaveBeenCalledWith({
|
||||
where: { trace_id: "test-trace-id" },
|
||||
data: { is_optimization_found: true },
|
||||
})
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: "Optimization status updated." })
|
||||
})
|
||||
})
|
||||
|
||||
describe("type validation edge cases", () => {
|
||||
it("should reject is_optimization_found as number 0", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "test-trace-id",
|
||||
is_optimization_found: 0,
|
||||
},
|
||||
} 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.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should reject is_optimization_found as number 1", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "test-trace-id",
|
||||
is_optimization_found: 1,
|
||||
},
|
||||
} 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.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should reject is_optimization_found as array", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "test-trace-id",
|
||||
is_optimization_found: [],
|
||||
},
|
||||
} 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.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should reject is_optimization_found as object", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
trace_id: "test-trace-id",
|
||||
is_optimization_found: {},
|
||||
},
|
||||
} 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.",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
800
js/cf-api/endpoints/tests/slack-events.unit.test.ts
Normal file
800
js/cf-api/endpoints/tests/slack-events.unit.test.ts
Normal file
|
|
@ -0,0 +1,800 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"
|
||||
import { Request, Response } from "express"
|
||||
import {
|
||||
handleSlackEvents,
|
||||
verifySlackRequest,
|
||||
setHandleSlackEventsDependencies,
|
||||
resetHandleSlackEventsDependencies,
|
||||
} from "../slack-events"
|
||||
|
||||
// Helper function to create mock requests
|
||||
function createMockRequest(options: { headers?: Record<string, string>; body?: any }): Request {
|
||||
return {
|
||||
headers: options.headers || {},
|
||||
body: options.body || {},
|
||||
} as unknown as Request
|
||||
}
|
||||
|
||||
describe("handleSlackEvents", () => {
|
||||
let mockReq: Request
|
||||
let mockRes: Partial<Response>
|
||||
let mockDependencies: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup response mock
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis() as jest.MockedFunction<(code: number) => Response>,
|
||||
send: jest.fn() as jest.MockedFunction<(body?: any) => Response>,
|
||||
json: jest.fn() as jest.MockedFunction<(body?: any) => Response>,
|
||||
headersSent: false,
|
||||
}
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies = {
|
||||
crypto: {
|
||||
createHmac: jest.fn(),
|
||||
timingSafeEqual: jest.fn(),
|
||||
},
|
||||
posthog: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
processReaction: jest.fn(),
|
||||
Sentry: {
|
||||
captureException: jest.fn(),
|
||||
},
|
||||
getSlackSigningSecret: jest.fn(),
|
||||
getCurrentTime: jest.fn(),
|
||||
console: {
|
||||
log: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
setHandleSlackEventsDependencies(mockDependencies)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetHandleSlackEventsDependencies()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("verifySlackRequest", () => {
|
||||
let mockHmac: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockHmac = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
digest: jest.fn(),
|
||||
}
|
||||
;(mockDependencies.crypto.createHmac as jest.MockedFunction<any>).mockReturnValue(mockHmac)
|
||||
})
|
||||
|
||||
it("should return true when SLACK_SIGNING_SECRET is not configured", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
undefined,
|
||||
)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockDependencies.console.warn).toHaveBeenCalledWith(
|
||||
"SLACK_SIGNING_SECRET not configured. Skipping request verification.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return false when headers are missing", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when signature header is missing", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-request-timestamp": "1234567890",
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when timestamp header is missing", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "v0=test-signature",
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when request is too old (replay attack prevention)", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
;(mockDependencies.getCurrentTime as jest.MockedFunction<any>).mockReturnValue(1000000000)
|
||||
|
||||
const oldTimestamp = 1000000000 - 400 // 400 seconds old (> 300 limit)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "v0=test-signature",
|
||||
"x-slack-request-timestamp": oldTimestamp.toString(),
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should verify signature correctly for valid request", () => {
|
||||
const currentTime = 1000000000
|
||||
const timestamp = currentTime
|
||||
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
;(mockDependencies.getCurrentTime as jest.MockedFunction<any>).mockReturnValue(currentTime)
|
||||
|
||||
mockHmac.digest.mockReturnValue("valid-hash")
|
||||
;(mockDependencies.crypto.timingSafeEqual as jest.MockedFunction<any>).mockReturnValue(true)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "v0=valid-signature",
|
||||
"x-slack-request-timestamp": timestamp.toString(),
|
||||
},
|
||||
body: { test: "data" },
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockDependencies.crypto.createHmac).toHaveBeenCalledWith("sha256", "test-secret")
|
||||
expect(mockHmac.update).toHaveBeenCalledWith(`v0:${timestamp}:{"test":"data"}`)
|
||||
expect(mockHmac.digest).toHaveBeenCalledWith("hex")
|
||||
expect(mockDependencies.crypto.timingSafeEqual).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle string body correctly", () => {
|
||||
const currentTime = 1000000000
|
||||
const timestamp = currentTime
|
||||
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
;(mockDependencies.getCurrentTime as jest.MockedFunction<any>).mockReturnValue(currentTime)
|
||||
|
||||
mockHmac.digest.mockReturnValue("valid-hash")
|
||||
;(mockDependencies.crypto.timingSafeEqual as jest.MockedFunction<any>).mockReturnValue(true)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "v0=valid-signature",
|
||||
"x-slack-request-timestamp": timestamp.toString(),
|
||||
},
|
||||
body: "string-body",
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockHmac.update).toHaveBeenCalledWith(`v0:${timestamp}:string-body`)
|
||||
})
|
||||
|
||||
it("should return false when signature verification fails", () => {
|
||||
const currentTime = 1000000000
|
||||
const timestamp = currentTime
|
||||
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
;(mockDependencies.getCurrentTime as jest.MockedFunction<any>).mockReturnValue(currentTime)
|
||||
|
||||
mockHmac.digest.mockReturnValue("invalid-hash")
|
||||
;(mockDependencies.crypto.timingSafeEqual as jest.MockedFunction<any>).mockReturnValue(false)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "v0=wrong-signature",
|
||||
"x-slack-request-timestamp": timestamp.toString(),
|
||||
},
|
||||
body: { test: "data" },
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle empty string signing secret", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue("")
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockDependencies.console.warn).toHaveBeenCalledWith(
|
||||
"SLACK_SIGNING_SECRET not configured. Skipping request verification.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle null signing secret", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(null)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockDependencies.console.warn).toHaveBeenCalledWith(
|
||||
"SLACK_SIGNING_SECRET not configured. Skipping request verification.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle invalid timestamp format", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
;(mockDependencies.getCurrentTime as jest.MockedFunction<any>).mockReturnValue(1000000000)
|
||||
|
||||
// Set up the crypto mocks properly
|
||||
mockHmac.digest.mockReturnValue("test-hash")
|
||||
;(mockDependencies.crypto.timingSafeEqual as jest.MockedFunction<any>).mockReturnValue(false)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "v0=test-signature",
|
||||
"x-slack-request-timestamp": "invalid-timestamp",
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
// Invalid timestamp should cause verification to fail
|
||||
// Even though the timestamp check might pass (due to NaN behavior),
|
||||
// the signature verification should fail
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleSlackEvents", () => {
|
||||
beforeEach(() => {
|
||||
// Setup verifySlackRequest to return true by default
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
;(mockDependencies.getCurrentTime as jest.MockedFunction<any>).mockReturnValue(1000000000)
|
||||
|
||||
const mockHmac = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
digest: jest.fn().mockReturnValue("valid-hash"),
|
||||
}
|
||||
;(mockDependencies.crypto.createHmac as jest.MockedFunction<any>).mockReturnValue(mockHmac)
|
||||
;(mockDependencies.crypto.timingSafeEqual as jest.MockedFunction<any>).mockReturnValue(true)
|
||||
})
|
||||
|
||||
it("should return 403 when Slack request verification fails", async () => {
|
||||
;(mockDependencies.crypto.timingSafeEqual as jest.MockedFunction<any>).mockReturnValue(false)
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "invalid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith("Failed to verify Slack request")
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Invalid request")
|
||||
})
|
||||
|
||||
it("should handle URL verification challenge", async () => {
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {
|
||||
type: "url_verification",
|
||||
challenge: "test-challenge-string",
|
||||
},
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.console.log).toHaveBeenCalledWith(
|
||||
"Handling Slack URL verification challenge",
|
||||
)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ challenge: "test-challenge-string" })
|
||||
})
|
||||
|
||||
it("should acknowledge receipt and process reaction_added event", async () => {
|
||||
const mockEvent = {
|
||||
type: "reaction_added",
|
||||
reaction: "white_check_mark",
|
||||
user: "U123456",
|
||||
item: {
|
||||
type: "message",
|
||||
channel: "C123456",
|
||||
ts: "1234567890.123456",
|
||||
},
|
||||
}
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {
|
||||
type: "event_callback",
|
||||
event: mockEvent,
|
||||
},
|
||||
})
|
||||
;(mockDependencies.processReaction as jest.MockedFunction<any>).mockResolvedValue(true)
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("OK")
|
||||
expect(mockDependencies.console.log).toHaveBeenCalledWith(
|
||||
"Processing Slack event: reaction_added",
|
||||
)
|
||||
expect(mockDependencies.processReaction).toHaveBeenCalledWith(mockEvent)
|
||||
expect(mockDependencies.posthog.capture).toHaveBeenCalledWith({
|
||||
distinctId: "system",
|
||||
event: "slack-approval-reaction-processed",
|
||||
properties: {
|
||||
reaction: "white_check_mark",
|
||||
result: "success",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should not capture analytics when processReaction returns false", async () => {
|
||||
const mockEvent = {
|
||||
type: "reaction_added",
|
||||
reaction: "x",
|
||||
user: "U123456",
|
||||
}
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {
|
||||
type: "event_callback",
|
||||
event: mockEvent,
|
||||
},
|
||||
})
|
||||
;(mockDependencies.processReaction as jest.MockedFunction<any>).mockResolvedValue(false)
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockDependencies.processReaction).toHaveBeenCalledWith(mockEvent)
|
||||
expect(mockDependencies.posthog.capture).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: "slack-approval-reaction-processed",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore non-reaction events", async () => {
|
||||
const mockEvent = {
|
||||
type: "message",
|
||||
text: "Hello world",
|
||||
}
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {
|
||||
type: "event_callback",
|
||||
event: mockEvent,
|
||||
},
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockDependencies.console.log).toHaveBeenCalledWith("Processing Slack event: message")
|
||||
expect(mockDependencies.processReaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle requests without event property", async () => {
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {
|
||||
type: "other_type",
|
||||
},
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("OK")
|
||||
expect(mockDependencies.processReaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle errors and capture them", async () => {
|
||||
const error = new Error("Processing failed")
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {
|
||||
type: "event_callback",
|
||||
event: {
|
||||
type: "reaction_added",
|
||||
reaction: "test",
|
||||
},
|
||||
},
|
||||
})
|
||||
;(mockDependencies.processReaction as jest.MockedFunction<any>).mockRejectedValue(error)
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
`Error handling Slack event: ${error}`,
|
||||
)
|
||||
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 () => {
|
||||
const error = new Error("Processing failed")
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {
|
||||
type: "event_callback",
|
||||
event: {
|
||||
type: "reaction_added",
|
||||
reaction: "test",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock processReaction to throw an error
|
||||
;(mockDependencies.processReaction as jest.MockedFunction<any>).mockRejectedValue(error)
|
||||
|
||||
// Mock the response to simulate headers already sent after the first response
|
||||
;(mockRes.status as jest.MockedFunction<any>).mockImplementationOnce(() => {
|
||||
mockRes.headersSent = true
|
||||
return mockRes
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
`Error handling Slack event: ${error}`,
|
||||
)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
|
||||
// The status should be called once (for the initial 200 response)
|
||||
expect(mockRes.status).toHaveBeenCalledTimes(1)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
})
|
||||
|
||||
it("should handle errors during verification", async () => {
|
||||
const error = new Error("Verification failed")
|
||||
;(mockDependencies.crypto.createHmac as jest.MockedFunction<any>).mockImplementation(() => {
|
||||
throw error
|
||||
})
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
`Error handling Slack event: ${error}`,
|
||||
)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Error processing event")
|
||||
})
|
||||
|
||||
it("should handle invalid timestamp format gracefully", async () => {
|
||||
// Force verification to fail by making timingSafeEqual return false
|
||||
;(mockDependencies.crypto.timingSafeEqual as jest.MockedFunction<any>).mockReturnValue(false)
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "v0=test-signature",
|
||||
"x-slack-request-timestamp": "invalid-timestamp",
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
// Should fail verification and return 403
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Invalid request")
|
||||
})
|
||||
|
||||
it("should handle complex event data with nested objects", async () => {
|
||||
const complexEvent = {
|
||||
type: "reaction_added",
|
||||
reaction: "heavy_check_mark",
|
||||
user: "U1234567890",
|
||||
item: {
|
||||
type: "message",
|
||||
channel: "C1234567890",
|
||||
ts: "1234567890.123456",
|
||||
},
|
||||
item_user: "U0987654321",
|
||||
event_ts: "1234567890.123457",
|
||||
}
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {
|
||||
type: "event_callback",
|
||||
team_id: "T1234567890",
|
||||
api_app_id: "A1234567890",
|
||||
event: complexEvent,
|
||||
event_id: "Ev1234567890",
|
||||
event_time: 1234567890,
|
||||
},
|
||||
})
|
||||
;(mockDependencies.processReaction as jest.MockedFunction<any>).mockResolvedValue(true)
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.processReaction).toHaveBeenCalledWith(complexEvent)
|
||||
expect(mockDependencies.posthog.capture).toHaveBeenCalledWith({
|
||||
distinctId: "system",
|
||||
event: "slack-approval-reaction-processed",
|
||||
properties: {
|
||||
reaction: "heavy_check_mark",
|
||||
result: "success",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle empty request body", async () => {
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("OK")
|
||||
})
|
||||
|
||||
it("should handle undefined request body in verification", async () => {
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: undefined,
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
// Should handle undefined body in JSON.stringify
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
})
|
||||
|
||||
it("should handle malformed JSON in error conversion", async () => {
|
||||
const circularError = {}
|
||||
;(circularError as any).self = circularError // Create circular reference
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {
|
||||
type: "event_callback",
|
||||
event: {
|
||||
type: "reaction_added",
|
||||
reaction: "test",
|
||||
},
|
||||
},
|
||||
})
|
||||
;(mockDependencies.processReaction as jest.MockedFunction<any>).mockRejectedValue(
|
||||
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
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle processReaction throwing synchronously", async () => {
|
||||
const error = new Error("Sync error")
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {
|
||||
type: "event_callback",
|
||||
event: {
|
||||
type: "reaction_added",
|
||||
reaction: "test",
|
||||
},
|
||||
},
|
||||
})
|
||||
;(mockDependencies.processReaction as jest.MockedFunction<any>).mockImplementation(() => {
|
||||
throw error
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
`Error handling Slack event: ${error}`,
|
||||
)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
})
|
||||
|
||||
it("should handle errors that occur before sending response", async () => {
|
||||
const error = new Error("Early error")
|
||||
|
||||
// Mock verification to fail with an error before any response is sent
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockImplementation(
|
||||
() => {
|
||||
throw error
|
||||
},
|
||||
)
|
||||
|
||||
mockReq = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "valid",
|
||||
"x-slack-request-timestamp": "1000000000",
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
await handleSlackEvents(mockReq, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
`Error handling Slack event: ${error}`,
|
||||
)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Error processing event")
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle missing headers object", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
|
||||
const req = createMockRequest({
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle very large timestamp differences", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
;(mockDependencies.getCurrentTime as jest.MockedFunction<any>).mockReturnValue(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "v0=test-signature",
|
||||
"x-slack-request-timestamp": "0",
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle negative timestamps", () => {
|
||||
;(mockDependencies.getSlackSigningSecret as jest.MockedFunction<any>).mockReturnValue(
|
||||
"test-secret",
|
||||
)
|
||||
;(mockDependencies.getCurrentTime as jest.MockedFunction<any>).mockReturnValue(1000000000)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
"x-slack-signature": "v0=test-signature",
|
||||
"x-slack-request-timestamp": "-1000",
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const result = verifySlackRequest(req)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
735
js/cf-api/endpoints/tests/stripe-webhook.unit.test.ts
Normal file
735
js/cf-api/endpoints/tests/stripe-webhook.unit.test.ts
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"
|
||||
import { Request, Response } from "express"
|
||||
import {
|
||||
stripeWebhookHandler,
|
||||
handleCheckoutCompleted,
|
||||
handleSubscriptionUpdate,
|
||||
handleSubscriptionCancellation,
|
||||
handleFailedPayment,
|
||||
setStripeWebhookDependencies,
|
||||
resetStripeWebhookDependencies,
|
||||
} from "../stripe-webhook"
|
||||
|
||||
describe("Stripe Webhook Handler", () => {
|
||||
let mockReq: Partial<Request>
|
||||
let mockRes: Partial<Response>
|
||||
let mockDependencies: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup request mock
|
||||
mockReq = {
|
||||
headers: {
|
||||
"stripe-signature": "test-signature",
|
||||
},
|
||||
body: "test-body",
|
||||
}
|
||||
|
||||
// Setup response mock
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis() as jest.MockedFunction<(code: number) => Response>,
|
||||
send: jest.fn() as jest.MockedFunction<(body?: any) => Response>,
|
||||
json: jest.fn() as jest.MockedFunction<(body?: any) => Response>,
|
||||
}
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies = {
|
||||
stripe: {
|
||||
webhooks: {
|
||||
constructEvent: jest.fn(),
|
||||
},
|
||||
subscriptions: {
|
||||
update: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
},
|
||||
customers: {
|
||||
update: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
},
|
||||
prices: {
|
||||
retrieve: jest.fn(),
|
||||
},
|
||||
},
|
||||
prisma: {
|
||||
subscriptions: {
|
||||
upsert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
},
|
||||
Sentry: {
|
||||
captureException: jest.fn(),
|
||||
},
|
||||
getWebhookSecret: jest.fn().mockReturnValue("test-webhook-secret"),
|
||||
}
|
||||
|
||||
setStripeWebhookDependencies(mockDependencies)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetStripeWebhookDependencies()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("stripeWebhookHandler", () => {
|
||||
describe("webhook event construction", () => {
|
||||
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",
|
||||
)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalled()
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.send).toHaveBeenCalledWith(
|
||||
"Webhook Error: STRIPE_WEBHOOK_SECRET is not configured",
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should return 400 when webhook signature verification fails", async () => {
|
||||
const error = new Error("Invalid signature")
|
||||
;(
|
||||
mockDependencies.stripe.webhooks.constructEvent as jest.MockedFunction<any>
|
||||
).mockImplementation(() => {
|
||||
throw error
|
||||
})
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
await stripeWebhookHandler(mockReq as Request, mockRes as Response)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it("should process webhook event successfully", async () => {
|
||||
const mockEvent = {
|
||||
id: "evt_test123",
|
||||
type: "customer.subscription.created",
|
||||
data: {
|
||||
object: {
|
||||
id: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: "price_test123" },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "active",
|
||||
current_period_start: 1640995200,
|
||||
current_period_end: 1643673600,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
;(
|
||||
mockDependencies.stripe.webhooks.constructEvent as jest.MockedFunction<any>
|
||||
).mockReturnValue(mockEvent)
|
||||
;(mockDependencies.stripe.prices.retrieve as jest.MockedFunction<any>).mockResolvedValue({
|
||||
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 () => {
|
||||
const mockEvent = {
|
||||
id: "evt_test123",
|
||||
type: "unknown.event.type",
|
||||
data: {
|
||||
object: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleCheckoutCompleted", () => {
|
||||
it("should handle checkout session without userId metadata", async () => {
|
||||
const session = {
|
||||
id: "cs_test123",
|
||||
metadata: {},
|
||||
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 () => {
|
||||
const session = {
|
||||
id: "cs_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
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 () => {
|
||||
const session = {
|
||||
id: "cs_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
mode: "subscription",
|
||||
subscription: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
}
|
||||
|
||||
;(
|
||||
mockDependencies.stripe.subscriptions.retrieve as jest.MockedFunction<any>
|
||||
).mockResolvedValue({
|
||||
id: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: "price_test123" },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "active",
|
||||
current_period_start: 1640995200,
|
||||
current_period_end: 1643673600,
|
||||
})
|
||||
;(mockDependencies.stripe.prices.retrieve as jest.MockedFunction<any>).mockResolvedValue({
|
||||
metadata: { tier: "pro", optimizations: "1000" },
|
||||
})
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await handleCheckoutCompleted(session)
|
||||
|
||||
expect(mockDependencies.stripe.subscriptions.update).toHaveBeenCalledWith("sub_test123", {
|
||||
metadata: { userId: "user_test123" },
|
||||
})
|
||||
expect(mockDependencies.stripe.customers.update).toHaveBeenCalledWith("cus_test123", {
|
||||
metadata: { userId: "user_test123" },
|
||||
})
|
||||
expect(mockDependencies.stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_test123")
|
||||
expect(mockDependencies.prisma.subscriptions.upsert).toHaveBeenCalled()
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle errors during checkout processing", async () => {
|
||||
const session = {
|
||||
id: "cs_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
mode: "subscription",
|
||||
subscription: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
}
|
||||
|
||||
const error = new Error("Stripe API error")
|
||||
;(mockDependencies.stripe.subscriptions.update as jest.MockedFunction<any>).mockRejectedValue(
|
||||
error,
|
||||
)
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
await handleCheckoutCompleted(session)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error processing checkout session:", error)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleSubscriptionUpdate", () => {
|
||||
it("should handle subscription without userId in metadata", async () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
;(mockDependencies.stripe.customers.retrieve as jest.MockedFunction<any>).mockResolvedValue({
|
||||
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 () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
metadata: {},
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: "price_test123" },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "active",
|
||||
current_period_start: 1640995200,
|
||||
current_period_end: 1643673600,
|
||||
}
|
||||
|
||||
;(mockDependencies.stripe.customers.retrieve as jest.MockedFunction<any>).mockResolvedValue({
|
||||
metadata: { userId: "user_test123" },
|
||||
})
|
||||
;(mockDependencies.stripe.prices.retrieve as jest.MockedFunction<any>).mockResolvedValue({
|
||||
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 () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
const error = new Error("Customer not found")
|
||||
;(mockDependencies.stripe.customers.retrieve as jest.MockedFunction<any>).mockRejectedValue(
|
||||
error,
|
||||
)
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
await handleSubscriptionUpdate(subscription)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error retrieving customer:", error)
|
||||
expect(mockDependencies.prisma.subscriptions.upsert).not.toHaveBeenCalled()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should create new subscription record", async () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: "price_test123" },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "active",
|
||||
current_period_start: 1640995200,
|
||||
current_period_end: 1643673600,
|
||||
cancel_at_period_end: false,
|
||||
}
|
||||
|
||||
;(mockDependencies.stripe.prices.retrieve as jest.MockedFunction<any>).mockResolvedValue({
|
||||
metadata: { tier: "pro", optimizations: "1000" },
|
||||
})
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await handleSubscriptionUpdate(subscription)
|
||||
|
||||
expect(mockDependencies.stripe.prices.retrieve).toHaveBeenCalledWith("price_test123")
|
||||
expect(mockDependencies.prisma.subscriptions.upsert).toHaveBeenCalledWith({
|
||||
where: { user_id: "user_test123" },
|
||||
create: {
|
||||
user_id: "user_test123",
|
||||
stripe_customer_id: "cus_test123",
|
||||
stripe_subscription_id: "sub_test123",
|
||||
plan_type: "pro",
|
||||
optimizations_limit: 1000,
|
||||
optimizations_used: 0,
|
||||
subscription_status: "active",
|
||||
current_period_start: new Date(1640995200 * 1000),
|
||||
current_period_end: new Date(1643673600 * 1000),
|
||||
created_at: expect.any(Date),
|
||||
updated_at: expect.any(Date),
|
||||
cancel_at_period_end: false,
|
||||
cancellation_request_date: null,
|
||||
},
|
||||
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 () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: "price_test123" },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "active",
|
||||
current_period_start: 1640995200,
|
||||
current_period_end: 1643673600,
|
||||
cancel_at_period_end: true,
|
||||
}
|
||||
|
||||
;(mockDependencies.stripe.prices.retrieve as jest.MockedFunction<any>).mockResolvedValue({
|
||||
metadata: { tier: "pro", optimizations: "1000" },
|
||||
})
|
||||
|
||||
await handleSubscriptionUpdate(subscription)
|
||||
|
||||
const upsertCall = (mockDependencies.prisma.subscriptions.upsert as jest.MockedFunction<any>)
|
||||
.mock.calls[0][0]
|
||||
expect(upsertCall.update.cancel_at_period_end).toBe(true)
|
||||
expect(upsertCall.update.cancellation_request_date).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it("should handle cancel_at_period_end = false (reactivation)", async () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: "price_test123" },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "active",
|
||||
current_period_start: 1640995200,
|
||||
current_period_end: 1643673600,
|
||||
cancel_at_period_end: false,
|
||||
}
|
||||
|
||||
;(mockDependencies.stripe.prices.retrieve as jest.MockedFunction<any>).mockResolvedValue({
|
||||
metadata: { tier: "pro", optimizations: "1000" },
|
||||
})
|
||||
|
||||
await handleSubscriptionUpdate(subscription)
|
||||
|
||||
const upsertCall = (mockDependencies.prisma.subscriptions.upsert as jest.MockedFunction<any>)
|
||||
.mock.calls[0][0]
|
||||
expect(upsertCall.update.cancel_at_period_end).toBe(false)
|
||||
expect(upsertCall.update.cancellation_request_date).toBe(null)
|
||||
})
|
||||
|
||||
it("should use default values when price metadata is missing", async () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: "price_test123" },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "active",
|
||||
current_period_start: 1640995200,
|
||||
current_period_end: 1643673600,
|
||||
}
|
||||
|
||||
;(mockDependencies.stripe.prices.retrieve as jest.MockedFunction<any>).mockResolvedValue({
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
await handleSubscriptionUpdate(subscription)
|
||||
|
||||
const upsertCall = (mockDependencies.prisma.subscriptions.upsert as jest.MockedFunction<any>)
|
||||
.mock.calls[0][0]
|
||||
expect(upsertCall.create.plan_type).toBe("pro")
|
||||
expect(upsertCall.create.optimizations_limit).toBe(500)
|
||||
})
|
||||
|
||||
it("should handle database errors", async () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
customer: "cus_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: "price_test123" },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "active",
|
||||
current_period_start: 1640995200,
|
||||
current_period_end: 1643673600,
|
||||
}
|
||||
|
||||
const error = new Error("Database error")
|
||||
;(mockDependencies.stripe.prices.retrieve as jest.MockedFunction<any>).mockResolvedValue({
|
||||
metadata: { tier: "pro", optimizations: "1000" },
|
||||
})
|
||||
;(mockDependencies.prisma.subscriptions.upsert as jest.MockedFunction<any>).mockRejectedValue(
|
||||
error,
|
||||
)
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
await handleSubscriptionUpdate(subscription)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error updating subscription:", error)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleSubscriptionCancellation", () => {
|
||||
it("should handle subscription without userId", async () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
await handleSubscriptionCancellation(subscription)
|
||||
|
||||
expect(mockDependencies.prisma.subscriptions.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should cancel subscription successfully", async () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
}
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await handleSubscriptionCancellation(subscription)
|
||||
|
||||
expect(mockDependencies.prisma.subscriptions.update).toHaveBeenCalledWith({
|
||||
where: { user_id: "user_test123" },
|
||||
data: {
|
||||
subscription_status: "canceled",
|
||||
plan_type: "free",
|
||||
optimizations_limit: 100,
|
||||
stripe_subscription_id: null,
|
||||
cancel_at_period_end: false,
|
||||
cancellation_request_date: null,
|
||||
},
|
||||
})
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Cancelled subscription for user user_test123")
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle database errors during cancellation", async () => {
|
||||
const subscription = {
|
||||
id: "sub_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
}
|
||||
|
||||
const error = new Error("Database error")
|
||||
;(mockDependencies.prisma.subscriptions.update as jest.MockedFunction<any>).mockRejectedValue(
|
||||
error,
|
||||
)
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
await handleSubscriptionCancellation(subscription)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error cancelling subscription:", error)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleFailedPayment", () => {
|
||||
it("should handle invoice without subscription", async () => {
|
||||
const invoice = {
|
||||
id: "in_test123",
|
||||
}
|
||||
|
||||
await handleFailedPayment(invoice)
|
||||
|
||||
expect(mockDependencies.stripe.subscriptions.retrieve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle subscription without userId", async () => {
|
||||
const invoice = {
|
||||
id: "in_test123",
|
||||
subscription: "sub_test123",
|
||||
}
|
||||
|
||||
;(
|
||||
mockDependencies.stripe.subscriptions.retrieve as jest.MockedFunction<any>
|
||||
).mockResolvedValue({
|
||||
id: "sub_test123",
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
await handleFailedPayment(invoice)
|
||||
|
||||
expect(mockDependencies.stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_test123")
|
||||
expect(mockDependencies.prisma.subscriptions.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should update subscription to past_due", async () => {
|
||||
const invoice = {
|
||||
id: "in_test123",
|
||||
subscription: "sub_test123",
|
||||
}
|
||||
|
||||
;(
|
||||
mockDependencies.stripe.subscriptions.retrieve as jest.MockedFunction<any>
|
||||
).mockResolvedValue({
|
||||
id: "sub_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
})
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await handleFailedPayment(invoice)
|
||||
|
||||
expect(mockDependencies.stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_test123")
|
||||
expect(mockDependencies.prisma.subscriptions.update).toHaveBeenCalledWith({
|
||||
where: { user_id: "user_test123" },
|
||||
data: {
|
||||
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 () => {
|
||||
const invoice = {
|
||||
id: "in_test123",
|
||||
subscription: "sub_test123",
|
||||
}
|
||||
|
||||
const error = new Error("Database error")
|
||||
;(
|
||||
mockDependencies.stripe.subscriptions.retrieve as jest.MockedFunction<any>
|
||||
).mockResolvedValue({
|
||||
id: "sub_test123",
|
||||
metadata: { userId: "user_test123" },
|
||||
})
|
||||
;(mockDependencies.prisma.subscriptions.update as jest.MockedFunction<any>).mockRejectedValue(
|
||||
error,
|
||||
)
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
await handleFailedPayment(invoice)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error handling failed payment:", error)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle Stripe API errors when retrieving subscription", async () => {
|
||||
const invoice = {
|
||||
id: "in_test123",
|
||||
subscription: "sub_test123",
|
||||
}
|
||||
|
||||
const error = new Error("Subscription not found")
|
||||
;(
|
||||
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:", error)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
expect(mockDependencies.prisma.subscriptions.update).not.toHaveBeenCalled()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
617
js/cf-api/endpoints/tests/subscription-management.unit.test.ts
Normal file
617
js/cf-api/endpoints/tests/subscription-management.unit.test.ts
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"
|
||||
import { Request, Response, NextFunction } from "express"
|
||||
import {
|
||||
getSubscription,
|
||||
createCheckout,
|
||||
cancelSubscription,
|
||||
setSubscriptionDependencies,
|
||||
resetSubscriptionDependencies,
|
||||
} from "../subscription-management"
|
||||
|
||||
describe("Subscription Functions", () => {
|
||||
let mockReq: Partial<Request>
|
||||
let mockRes: Partial<Response>
|
||||
let mockNext: NextFunction
|
||||
let mockDependencies: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup response mock
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis() as jest.MockedFunction<(code: number) => Response>,
|
||||
json: jest.fn() as jest.MockedFunction<(body?: any) => Response>,
|
||||
}
|
||||
|
||||
// Setup next function mock
|
||||
mockNext = jest.fn() as NextFunction
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies = {
|
||||
prisma: {
|
||||
subscriptions: {
|
||||
findUnique: jest.fn() as jest.MockedFunction<(params: any) => Promise<any>>,
|
||||
},
|
||||
},
|
||||
createCheckoutSession: jest.fn() as jest.MockedFunction<
|
||||
(userId: string, priceId: string, options: any) => Promise<string>
|
||||
>,
|
||||
cancelStripeSubscription: jest.fn() as jest.MockedFunction<(userId: string) => Promise<void>>,
|
||||
Sentry: {
|
||||
captureException: jest.fn() as jest.MockedFunction<(error: any) => void>,
|
||||
},
|
||||
}
|
||||
|
||||
setSubscriptionDependencies(mockDependencies)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetSubscriptionDependencies()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("getSubscription", () => {
|
||||
describe("input validation", () => {
|
||||
it("should return 400 when userId is missing", async () => {
|
||||
mockReq = {
|
||||
query: {},
|
||||
}
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "User ID is required" })
|
||||
})
|
||||
|
||||
it("should return 400 when userId is empty string", async () => {
|
||||
mockReq = {
|
||||
query: { userId: "" },
|
||||
}
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "User ID is required" })
|
||||
})
|
||||
|
||||
it("should return 400 when userId is null", async () => {
|
||||
mockReq = {
|
||||
query: { userId: null as any },
|
||||
}
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "User ID is required" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("successful retrieval", () => {
|
||||
it("should return subscription details when found", async () => {
|
||||
const mockSubscription = {
|
||||
plan_type: "pro",
|
||||
subscription_status: "active",
|
||||
optimizations_used: 5,
|
||||
optimizations_limit: 100,
|
||||
current_period_end: "2024-12-31T23:59:59Z",
|
||||
}
|
||||
|
||||
mockReq = {
|
||||
query: { userId: "user-123" },
|
||||
}
|
||||
|
||||
mockDependencies.prisma.subscriptions.findUnique.mockResolvedValue(mockSubscription)
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockDependencies.prisma.subscriptions.findUnique).toHaveBeenCalledWith({
|
||||
where: { user_id: "user-123" },
|
||||
})
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
plan: "pro",
|
||||
status: "active",
|
||||
usageCount: 5,
|
||||
usageLimit: 100,
|
||||
renewalDate: "2024-12-31T23:59:59Z",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return 404 when subscription not found", async () => {
|
||||
mockReq = {
|
||||
query: { userId: "user-123" },
|
||||
}
|
||||
|
||||
mockDependencies.prisma.subscriptions.findUnique.mockResolvedValue(null)
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Subscription not found" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle database errors", async () => {
|
||||
const error = new Error("Database error")
|
||||
mockReq = {
|
||||
query: { userId: "user-123" },
|
||||
}
|
||||
|
||||
mockDependencies.prisma.subscriptions.findUnique.mockRejectedValue(error)
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error getting subscription:", error)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
expect(mockNext).toHaveBeenCalledWith(error)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createCheckout", () => {
|
||||
describe("input validation", () => {
|
||||
it("should return 400 when userId is missing", async () => {
|
||||
mockReq = {
|
||||
body: { priceId: "price-123" },
|
||||
}
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "User ID and price ID are required" })
|
||||
})
|
||||
|
||||
it("should return 400 when priceId is missing", async () => {
|
||||
mockReq = {
|
||||
body: { userId: "user-123" },
|
||||
}
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "User ID and price ID are required" })
|
||||
})
|
||||
|
||||
it("should return 400 when both userId and priceId are missing", async () => {
|
||||
mockReq = {
|
||||
body: {},
|
||||
}
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "User ID and price ID are required" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("successful checkout creation", () => {
|
||||
beforeEach(() => {
|
||||
// Mock environment variables
|
||||
process.env.WEBAPP_URL = "https://test.example.com"
|
||||
process.env.STRIPE_PRO_PRICE_YEARLY_ID = "price-yearly-123"
|
||||
})
|
||||
|
||||
it("should create checkout session with default options", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
userId: "user-123",
|
||||
priceId: "price-monthly-123",
|
||||
},
|
||||
}
|
||||
|
||||
mockDependencies.createCheckoutSession.mockResolvedValue(
|
||||
"https://checkout.stripe.com/session-123",
|
||||
)
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockDependencies.createCheckoutSession).toHaveBeenCalledWith(
|
||||
"user-123",
|
||||
"price-monthly-123",
|
||||
{
|
||||
successUrl: "https://test.example.com/app/billing?success=true",
|
||||
cancelUrl: "https://test.example.com/app/billing?canceled=true",
|
||||
period: "monthly",
|
||||
},
|
||||
)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
url: "https://checkout.stripe.com/session-123",
|
||||
})
|
||||
})
|
||||
|
||||
it("should use yearly period for yearly price ID", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
userId: "user-123",
|
||||
priceId: "price-yearly-123",
|
||||
},
|
||||
}
|
||||
|
||||
mockDependencies.createCheckoutSession.mockResolvedValue(
|
||||
"https://checkout.stripe.com/session-123",
|
||||
)
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockDependencies.createCheckoutSession).toHaveBeenCalledWith(
|
||||
"user-123",
|
||||
"price-yearly-123",
|
||||
{
|
||||
successUrl: "https://test.example.com/app/billing?success=true",
|
||||
cancelUrl: "https://test.example.com/app/billing?canceled=true",
|
||||
period: "yearly",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("should use custom URLs when provided", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
userId: "user-123",
|
||||
priceId: "price-monthly-123",
|
||||
successUrl: "https://custom.com/success",
|
||||
cancelUrl: "https://custom.com/cancel",
|
||||
},
|
||||
}
|
||||
|
||||
mockDependencies.createCheckoutSession.mockResolvedValue(
|
||||
"https://checkout.stripe.com/session-123",
|
||||
)
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockDependencies.createCheckoutSession).toHaveBeenCalledWith(
|
||||
"user-123",
|
||||
"price-monthly-123",
|
||||
{
|
||||
successUrl: "https://custom.com/success",
|
||||
cancelUrl: "https://custom.com/cancel",
|
||||
period: "monthly",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("should use custom period when provided", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
userId: "user-123",
|
||||
priceId: "price-monthly-123",
|
||||
period: "custom",
|
||||
},
|
||||
}
|
||||
|
||||
mockDependencies.createCheckoutSession.mockResolvedValue(
|
||||
"https://checkout.stripe.com/session-123",
|
||||
)
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockDependencies.createCheckoutSession).toHaveBeenCalledWith(
|
||||
"user-123",
|
||||
"price-monthly-123",
|
||||
{
|
||||
successUrl: "https://test.example.com/app/billing?success=true",
|
||||
cancelUrl: "https://test.example.com/app/billing?canceled=true",
|
||||
period: "custom",
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle checkout session creation errors", async () => {
|
||||
const error = new Error("Stripe error")
|
||||
mockReq = {
|
||||
body: {
|
||||
userId: "user-123",
|
||||
priceId: "price-123",
|
||||
},
|
||||
}
|
||||
|
||||
mockDependencies.createCheckoutSession.mockRejectedValue(error)
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error creating checkout session:", error)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
expect(mockNext).toHaveBeenCalledWith(error)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancelSubscription", () => {
|
||||
describe("input validation", () => {
|
||||
it("should return 400 when userId is missing", async () => {
|
||||
mockReq = {
|
||||
body: {},
|
||||
}
|
||||
|
||||
await cancelSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "User ID is required" })
|
||||
})
|
||||
|
||||
it("should return 400 when userId is empty string", async () => {
|
||||
mockReq = {
|
||||
body: { userId: "" },
|
||||
}
|
||||
|
||||
await cancelSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "User ID is required" })
|
||||
})
|
||||
|
||||
it("should return 400 when userId is null", async () => {
|
||||
mockReq = {
|
||||
body: { userId: null },
|
||||
}
|
||||
|
||||
await cancelSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "User ID is required" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("successful cancellation", () => {
|
||||
it("should cancel subscription successfully", async () => {
|
||||
mockReq = {
|
||||
body: { userId: "user-123" },
|
||||
}
|
||||
|
||||
mockDependencies.cancelStripeSubscription.mockResolvedValue(undefined)
|
||||
|
||||
await cancelSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockDependencies.cancelStripeSubscription).toHaveBeenCalledWith("user-123")
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ status: "canceled" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle cancellation errors", async () => {
|
||||
const error = new Error("Cancellation failed")
|
||||
mockReq = {
|
||||
body: { userId: "user-123" },
|
||||
}
|
||||
|
||||
mockDependencies.cancelStripeSubscription.mockRejectedValue(error)
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
await cancelSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error canceling subscription:", error)
|
||||
expect(mockDependencies.Sentry.captureException).toHaveBeenCalledWith(error)
|
||||
expect(mockNext).toHaveBeenCalledWith(error)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle array userId in query", async () => {
|
||||
mockReq = {
|
||||
query: { userId: ["user-123", "user-456"] as any },
|
||||
}
|
||||
|
||||
// Mock the function to return null since arrays won't match any real user_id
|
||||
mockDependencies.prisma.subscriptions.findUnique.mockResolvedValue(null)
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
// The function will receive the array as-is and pass it to the database query
|
||||
expect(mockDependencies.prisma.subscriptions.findUnique).toHaveBeenCalledWith({
|
||||
where: { user_id: ["user-123", "user-456"] },
|
||||
})
|
||||
|
||||
// Since no subscription will be found with an array userId, it should return 404
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Subscription not found" })
|
||||
})
|
||||
|
||||
it("should handle numeric userId in query", async () => {
|
||||
mockReq = {
|
||||
query: { userId: 12345 as any },
|
||||
}
|
||||
|
||||
// Numeric user IDs likely won't match string user_ids in database
|
||||
mockDependencies.prisma.subscriptions.findUnique.mockResolvedValue(null)
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
// Numeric values are passed as-is, not converted to string by the type assertion
|
||||
expect(mockDependencies.prisma.subscriptions.findUnique).toHaveBeenCalledWith({
|
||||
where: { user_id: 12345 },
|
||||
})
|
||||
|
||||
// Should return 404 since numeric ID likely won't match any records
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Subscription not found" })
|
||||
})
|
||||
|
||||
it("should handle missing environment variables in createCheckout", async () => {
|
||||
const originalWebAppUrl = process.env.WEBAPP_URL
|
||||
const originalYearlyPriceId = process.env.STRIPE_PRO_PRICE_YEARLY_ID
|
||||
|
||||
delete process.env.WEBAPP_URL
|
||||
delete process.env.STRIPE_PRO_PRICE_YEARLY_ID
|
||||
|
||||
mockReq = {
|
||||
body: {
|
||||
userId: "user-123",
|
||||
priceId: "price-monthly-123",
|
||||
},
|
||||
}
|
||||
|
||||
mockDependencies.createCheckoutSession.mockResolvedValue(
|
||||
"https://checkout.stripe.com/session-123",
|
||||
)
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockDependencies.createCheckoutSession).toHaveBeenCalledWith(
|
||||
"user-123",
|
||||
"price-monthly-123",
|
||||
{
|
||||
successUrl: "undefined/app/billing?success=true",
|
||||
cancelUrl: "undefined/app/billing?canceled=true",
|
||||
period: "monthly",
|
||||
},
|
||||
)
|
||||
|
||||
// Restore environment variables
|
||||
if (originalWebAppUrl) process.env.WEBAPP_URL = originalWebAppUrl
|
||||
if (originalYearlyPriceId) process.env.STRIPE_PRO_PRICE_YEARLY_ID = originalYearlyPriceId
|
||||
})
|
||||
|
||||
it("should handle boolean userId in query", async () => {
|
||||
mockReq = {
|
||||
query: { userId: true as any },
|
||||
}
|
||||
|
||||
// Boolean values likely won't match any real user_id
|
||||
mockDependencies.prisma.subscriptions.findUnique.mockResolvedValue(null)
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
// Boolean values are passed as-is
|
||||
expect(mockDependencies.prisma.subscriptions.findUnique).toHaveBeenCalledWith({
|
||||
where: { user_id: true },
|
||||
})
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Subscription not found" })
|
||||
})
|
||||
|
||||
it("should handle object userId in query", async () => {
|
||||
const objectUserId = { id: "user-123" }
|
||||
mockReq = {
|
||||
query: { userId: objectUserId as any },
|
||||
}
|
||||
|
||||
// Object values likely won't match any real user_id
|
||||
mockDependencies.prisma.subscriptions.findUnique.mockResolvedValue(null)
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
// Object values are passed as-is
|
||||
expect(mockDependencies.prisma.subscriptions.findUnique).toHaveBeenCalledWith({
|
||||
where: { user_id: objectUserId },
|
||||
})
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Subscription not found" })
|
||||
})
|
||||
|
||||
it("should handle empty object in request body for createCheckout", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
userId: "",
|
||||
priceId: "",
|
||||
},
|
||||
}
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "User ID and price ID are required" })
|
||||
})
|
||||
|
||||
it("should handle subscription with missing fields", async () => {
|
||||
const mockIncompleteSubscription = {
|
||||
plan_type: "pro",
|
||||
// Missing some fields
|
||||
}
|
||||
|
||||
mockReq = {
|
||||
query: { userId: "user-123" },
|
||||
}
|
||||
|
||||
mockDependencies.prisma.subscriptions.findUnique.mockResolvedValue(mockIncompleteSubscription)
|
||||
|
||||
await getSubscription(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
plan: "pro",
|
||||
status: undefined,
|
||||
usageCount: undefined,
|
||||
usageLimit: undefined,
|
||||
renewalDate: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createCheckout environment variable handling", () => {
|
||||
afterEach(() => {
|
||||
// Ensure environment variables are restored
|
||||
process.env.WEBAPP_URL = "https://test.example.com"
|
||||
process.env.STRIPE_PRO_PRICE_YEARLY_ID = "price-yearly-123"
|
||||
})
|
||||
|
||||
it("should handle undefined WEBAPP_URL gracefully", async () => {
|
||||
delete process.env.WEBAPP_URL
|
||||
process.env.STRIPE_PRO_PRICE_YEARLY_ID = "price-yearly-123"
|
||||
|
||||
mockReq = {
|
||||
body: {
|
||||
userId: "user-123",
|
||||
priceId: "price-monthly-123",
|
||||
},
|
||||
}
|
||||
|
||||
mockDependencies.createCheckoutSession.mockResolvedValue(
|
||||
"https://checkout.stripe.com/session-123",
|
||||
)
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockDependencies.createCheckoutSession).toHaveBeenCalledWith(
|
||||
"user-123",
|
||||
"price-monthly-123",
|
||||
{
|
||||
successUrl: "undefined/app/billing?success=true",
|
||||
cancelUrl: "undefined/app/billing?canceled=true",
|
||||
period: "monthly",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle undefined STRIPE_PRO_PRICE_YEARLY_ID gracefully", async () => {
|
||||
process.env.WEBAPP_URL = "https://test.example.com"
|
||||
delete process.env.STRIPE_PRO_PRICE_YEARLY_ID
|
||||
|
||||
mockReq = {
|
||||
body: {
|
||||
userId: "user-123",
|
||||
priceId: "some-unknown-price-id",
|
||||
},
|
||||
}
|
||||
|
||||
mockDependencies.createCheckoutSession.mockResolvedValue(
|
||||
"https://checkout.stripe.com/session-123",
|
||||
)
|
||||
|
||||
await createCheckout(mockReq as Request, mockRes as Response, mockNext)
|
||||
|
||||
expect(mockDependencies.createCheckoutSession).toHaveBeenCalledWith(
|
||||
"user-123",
|
||||
"some-unknown-price-id",
|
||||
{
|
||||
successUrl: "https://test.example.com/app/billing?success=true",
|
||||
cancelUrl: "https://test.example.com/app/billing?canceled=true",
|
||||
period: "monthly", // Should default to monthly since yearly price ID is undefined
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
687
js/cf-api/endpoints/tests/suggest-pr-changes.unit.test.ts
Normal file
687
js/cf-api/endpoints/tests/suggest-pr-changes.unit.test.ts
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach, beforeAll } from "@jest/globals"
|
||||
import { Request, Response } from "express"
|
||||
let suggestPrChanges: typeof import("../suggest-pr-changes").suggestPrChanges
|
||||
let triggerSuggestPrChanges: typeof import("../suggest-pr-changes").triggerSuggestPrChanges
|
||||
let setSuggestPrChangesDependencies: typeof import("../suggest-pr-changes").setSuggestPrChangesDependencies
|
||||
let resetSuggestPrChangesDependencies: typeof import("../suggest-pr-changes").resetSuggestPrChangesDependencies
|
||||
// Mock types for testing
|
||||
interface AuthorizedUserReq extends Request {
|
||||
userId: string
|
||||
}
|
||||
|
||||
// Note: Global mocks are set up in jest.setup.ts
|
||||
|
||||
describe("Suggest PR Changes", () => {
|
||||
beforeAll(async () => {
|
||||
process.env.KEY_VAULT_NAME = "mocked-keyvault-name"
|
||||
const mod = await import("../suggest-pr-changes")
|
||||
suggestPrChanges = mod.suggestPrChanges
|
||||
triggerSuggestPrChanges = mod.triggerSuggestPrChanges
|
||||
setSuggestPrChangesDependencies = mod.setSuggestPrChangesDependencies
|
||||
resetSuggestPrChangesDependencies = mod.resetSuggestPrChangesDependencies
|
||||
})
|
||||
let mockReq: Partial<AuthorizedUserReq>
|
||||
let mockRes: Partial<Response>
|
||||
let mockDependencies: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup response mock with proper typing
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis() as any,
|
||||
send: jest.fn() as any,
|
||||
json: jest.fn() as any,
|
||||
}
|
||||
|
||||
// Setup comprehensive mock dependencies
|
||||
mockDependencies = {
|
||||
prisma: {
|
||||
optimization_features: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
optimization_events: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
},
|
||||
userNickname: jest.fn(),
|
||||
getInstallationOctokitByOwner: jest.fn(),
|
||||
isUserCollaborator: jest.fn(),
|
||||
requiresApproval: jest.fn(),
|
||||
requestApproval: jest.fn(),
|
||||
posthog: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
githubApp: {},
|
||||
isDiffContentsWellFormed: jest.fn(),
|
||||
fileDiffsToMap: jest.fn(),
|
||||
determineValidHunks: jest.fn(),
|
||||
buildDependentPrTitle: jest.fn(),
|
||||
buildPrCommentBody: jest.fn(),
|
||||
createNewBranchFromDiffContents: jest.fn(),
|
||||
createDependentPullRequest: jest.fn(),
|
||||
sendSlackMessage: jest.fn(),
|
||||
updateOptimizationEvent: jest.fn(),
|
||||
}
|
||||
|
||||
setSuggestPrChangesDependencies(mockDependencies)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetSuggestPrChangesDependencies()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("suggestPrChanges", () => {
|
||||
describe("input validation", () => {
|
||||
it("should return 400 for missing required fields", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
owner: "test-owner",
|
||||
// missing repo, pullNumber, diffContents
|
||||
},
|
||||
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")
|
||||
})
|
||||
|
||||
it("should return 400 for malformed diff contents", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
pullNumber: 1,
|
||||
diffContents: "invalid-diff",
|
||||
},
|
||||
userId: "test-user-id",
|
||||
} 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")
|
||||
})
|
||||
})
|
||||
|
||||
describe("authentication and authorization", () => {
|
||||
beforeEach(() => {
|
||||
mockReq = {
|
||||
body: {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
pullNumber: 1,
|
||||
diffContents: [{ file: "test.js", content: "test" }],
|
||||
prCommentFields: { function_name: "testFunc" },
|
||||
},
|
||||
userId: "test-user-id",
|
||||
} as AuthorizedUserReq
|
||||
mockDependencies.isDiffContentsWellFormed.mockReturnValue(true)
|
||||
})
|
||||
|
||||
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" })
|
||||
})
|
||||
|
||||
it("should return 401 when installation octokit fails", async () => {
|
||||
mockDependencies.userNickname.mockResolvedValue("test-user")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(
|
||||
new Error("Installation error"),
|
||||
)
|
||||
|
||||
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Installation error")
|
||||
})
|
||||
|
||||
it("should return 401 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(
|
||||
"test-user is not a collaborator on test-owner/test-repo",
|
||||
)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Unauthorized" })
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should return 401 for roboflow repositories", async () => {
|
||||
mockReq.body.owner = "roboflow"
|
||||
mockDependencies.userNickname.mockResolvedValue("test-user")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue({})
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(true)
|
||||
mockDependencies.requiresApproval.mockReturnValue(false)
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Rejecting request for roboflow repository")
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401)
|
||||
expect(mockRes.send).toHaveBeenCalledWith("Unauthorized for roboflow repositories")
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("approval workflow", () => {
|
||||
beforeEach(() => {
|
||||
mockReq = {
|
||||
body: {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
pullNumber: 1,
|
||||
diffContents: [{ file: "test.js", content: "test" }],
|
||||
prCommentFields: { function_name: "testFunc" },
|
||||
traceId: "test-trace-id",
|
||||
},
|
||||
userId: "test-user-id",
|
||||
} as AuthorizedUserReq
|
||||
mockDependencies.isDiffContentsWellFormed.mockReturnValue(true)
|
||||
mockDependencies.userNickname.mockResolvedValue("test-user")
|
||||
|
||||
// Create a properly typed mock installation octokit
|
||||
const mockInstallationOctokit = {
|
||||
rest: {
|
||||
pulls: {
|
||||
get: jest.fn() as any,
|
||||
},
|
||||
},
|
||||
}
|
||||
// Fix: Use any to bypass TypeScript issues
|
||||
mockInstallationOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { head: { ref: "feature-branch" } },
|
||||
})
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(mockInstallationOctokit)
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it("should return 403 for rejected requests", async () => {
|
||||
mockDependencies.requiresApproval.mockReturnValue(true)
|
||||
mockDependencies.prisma.optimization_features.findUnique.mockResolvedValue({
|
||||
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.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should proceed with approved requests", async () => {
|
||||
mockDependencies.requiresApproval.mockReturnValue(true)
|
||||
mockDependencies.prisma.optimization_features.findUnique.mockResolvedValue({
|
||||
approval_status: "approved",
|
||||
})
|
||||
|
||||
// Mock triggerSuggestPrChanges dependencies
|
||||
mockDependencies.fileDiffsToMap.mockReturnValue(new Map())
|
||||
mockDependencies.determineValidHunks.mockResolvedValue({
|
||||
validHunks: new Map(),
|
||||
invalidHunks: new Map(),
|
||||
})
|
||||
mockDependencies.buildDependentPrTitle.mockReturnValue("Test PR Title")
|
||||
mockDependencies.createNewBranchFromDiffContents.mockResolvedValue(true)
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Request test-trace-id was previously approved, continuing with PR suggestion",
|
||||
)
|
||||
expect(mockRes.json).toHaveBeenCalled()
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should request approval for new requests", async () => {
|
||||
mockDependencies.requiresApproval.mockReturnValue(true)
|
||||
mockDependencies.prisma.optimization_features.findUnique.mockResolvedValue({
|
||||
approval_status: "pending",
|
||||
})
|
||||
|
||||
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.requestApproval).toHaveBeenCalledWith(
|
||||
"test-trace-id",
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"testFunc",
|
||||
"test-user-id",
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(202)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
status: "pending_approval",
|
||||
message:
|
||||
"This PR suggestion requires approval. You will be notified when it is processed.",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle errors and track them", async () => {
|
||||
mockReq = {
|
||||
body: {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
pullNumber: 1,
|
||||
diffContents: [{ file: "test.js", content: "test" }],
|
||||
},
|
||||
userId: "test-user-id",
|
||||
} as AuthorizedUserReq
|
||||
|
||||
const error = new Error("Test error")
|
||||
error.stack = "Test stack trace"
|
||||
mockDependencies.isDiffContentsWellFormed.mockReturnValue(true)
|
||||
mockDependencies.userNickname.mockRejectedValue(error)
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await suggestPrChanges(mockReq as AuthorizedUserReq, mockRes as Response)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(`Error in /cfapi/suggest-pr-changes: ${error}`)
|
||||
expect(consoleSpy).toHaveBeenCalledWith(`Error message: ${error.message}`)
|
||||
expect(consoleSpy).toHaveBeenCalledWith(`Error stack: ${error.stack}`)
|
||||
expect(mockDependencies.posthog.capture).toHaveBeenCalledWith({
|
||||
distinctId: "test-user-id",
|
||||
event: "cfapi-suggest-pr-changes-failed-error",
|
||||
properties: {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
})
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.send).toHaveBeenCalledWith(`Error creating pull request: ${error.message}`)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("triggerSuggestPrChanges", () => {
|
||||
let mockInstallationOctokit: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockInstallationOctokit = {
|
||||
rest: {
|
||||
pulls: {
|
||||
get: jest.fn() as any,
|
||||
createReview: jest.fn() as any,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Setup default mocks
|
||||
mockDependencies.fileDiffsToMap.mockReturnValue(new Map())
|
||||
mockDependencies.determineValidHunks.mockResolvedValue({
|
||||
validHunks: new Map(),
|
||||
invalidHunks: new Map(),
|
||||
})
|
||||
mockDependencies.buildDependentPrTitle.mockReturnValue("Test PR Title")
|
||||
mockDependencies.createNewBranchFromDiffContents.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
describe("dependent PR creation", () => {
|
||||
it("should create dependent PR when there are invalid hunks", async () => {
|
||||
const validHunks = new Map([
|
||||
["file1.js", [{ oldStart: 1, oldEnd: 5, newContent: ["new code"] }]],
|
||||
])
|
||||
const invalidHunks = new Map([
|
||||
["file2.js", [{ oldStart: 10, oldEnd: 15, newContent: ["invalid code"] }]],
|
||||
])
|
||||
|
||||
mockDependencies.determineValidHunks.mockResolvedValue({
|
||||
validHunks,
|
||||
invalidHunks,
|
||||
})
|
||||
// Fix: Use any to bypass TypeScript issues
|
||||
mockInstallationOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { head: { ref: "feature-branch" } },
|
||||
})
|
||||
mockDependencies.createDependentPullRequest.mockResolvedValue({
|
||||
data: {
|
||||
id: 123,
|
||||
number: 456,
|
||||
html_url: "https://github.com/test/test/pull/456",
|
||||
head: { ref: "codeflash/optimize-pr1-2024-01-01" },
|
||||
},
|
||||
})
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").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,
|
||||
"trace123",
|
||||
)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Creating a dependent PR because there are invalid hunks.",
|
||||
)
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Making a new dependent PR...")
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Created new dependent PR #456 from branch codeflash/optimize-pr1-2024-01-01",
|
||||
)
|
||||
expect(mockDependencies.createDependentPullRequest).toHaveBeenCalled()
|
||||
expect(mockDependencies.posthog.capture).toHaveBeenCalledWith({
|
||||
distinctId: "user123",
|
||||
event: "cfapi-suggest-pr-changes-success-dependent-pr-created",
|
||||
properties: {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
newPrNumber: 456,
|
||||
newPrBranch: "codeflash/optimize-pr1-2024-01-01",
|
||||
PRURL: "https://github.com/test/test/pull/456",
|
||||
},
|
||||
})
|
||||
expect(result).toBe(456)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should create dependent PR when there are multiple files", async () => {
|
||||
const validHunks = new Map([
|
||||
["file1.js", [{ oldStart: 1, oldEnd: 5, newContent: ["new code"] }]],
|
||||
["file2.js", [{ oldStart: 10, oldEnd: 15, newContent: ["more code"] }]],
|
||||
])
|
||||
const invalidHunks = new Map()
|
||||
|
||||
mockDependencies.determineValidHunks.mockResolvedValue({
|
||||
validHunks,
|
||||
invalidHunks,
|
||||
})
|
||||
mockInstallationOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { head: { ref: "feature-branch" } },
|
||||
})
|
||||
mockDependencies.createDependentPullRequest.mockResolvedValue({
|
||||
data: {
|
||||
id: 123,
|
||||
number: 456,
|
||||
html_url: "https://github.com/test/test/pull/456",
|
||||
head: { ref: "codeflash/optimize-pr1-2024-01-01" },
|
||||
},
|
||||
})
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").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,
|
||||
"trace123",
|
||||
)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Found 2 files with changes, using dependent PR instead of review comments",
|
||||
)
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Creating a dependent PR because there are multiple valid hunks.",
|
||||
)
|
||||
expect(result).toBe(456)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should send Slack notification for configured repositories", async () => {
|
||||
const validHunks = new Map([
|
||||
["file1.js", [{ oldStart: 1, oldEnd: 5, newContent: ["new code"] }]],
|
||||
])
|
||||
const invalidHunks = new Map([
|
||||
["file2.js", [{ oldStart: 10, oldEnd: 15, newContent: ["invalid code"] }]],
|
||||
])
|
||||
|
||||
mockDependencies.determineValidHunks.mockResolvedValue({
|
||||
validHunks,
|
||||
invalidHunks,
|
||||
})
|
||||
mockInstallationOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { head: { ref: "feature-branch" } },
|
||||
})
|
||||
mockDependencies.createDependentPullRequest.mockResolvedValue({
|
||||
data: {
|
||||
id: 123,
|
||||
number: 456,
|
||||
html_url: "https://github.com/test/test/pull/456",
|
||||
head: { ref: "codeflash/optimize-pr1-2024-01-01" },
|
||||
},
|
||||
})
|
||||
|
||||
await triggerSuggestPrChanges(
|
||||
"Future-House", // This is in slackNotificationConfig
|
||||
"aviary", // This repo is configured for notifications
|
||||
1,
|
||||
[],
|
||||
{
|
||||
function_name: "testFunc",
|
||||
speedup_pct: 50,
|
||||
speedup_x: 2,
|
||||
optimization_explanation: "test",
|
||||
},
|
||||
"existing tests",
|
||||
"generated tests",
|
||||
"coverage message",
|
||||
"user123",
|
||||
"testuser",
|
||||
mockInstallationOctokit,
|
||||
"trace123",
|
||||
)
|
||||
|
||||
expect(mockDependencies.sendSlackMessage).toHaveBeenCalledWith(
|
||||
"new dependent PR created: https://github.com/test/test/pull/456 for Future-House/aviary",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("review comment creation", () => {
|
||||
it("should create review comments for single valid hunk", async () => {
|
||||
const validHunks = new Map([
|
||||
[
|
||||
"file1.js",
|
||||
[{ oldStart: 1, oldEnd: 5, newContent: ["new code line 1", "new code line 2"] }],
|
||||
],
|
||||
])
|
||||
const invalidHunks = new Map()
|
||||
|
||||
mockDependencies.determineValidHunks.mockResolvedValue({
|
||||
validHunks,
|
||||
invalidHunks,
|
||||
})
|
||||
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.buildPrCommentBody.mockReturnValue("Test PR comment body")
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").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,
|
||||
"trace123",
|
||||
)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Creating unified review for a single valid hunk.")
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Added review comment to PR #1: https://github.com/test/test/pull/1#pullrequestreview-789",
|
||||
)
|
||||
expect(mockInstallationOctokit.rest.pulls.createReview).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
pull_number: 1,
|
||||
commit_id: "abc123",
|
||||
event: "COMMENT",
|
||||
comments: [
|
||||
{
|
||||
path: "file1.js",
|
||||
line: 5,
|
||||
start_line: 1,
|
||||
side: "RIGHT",
|
||||
body: "Test PR comment body\n\n```suggestion\nnew code line 1\nnew code line 2\n```",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(result).toBe(789)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle review creation failure gracefully", async () => {
|
||||
const validHunks = new Map([
|
||||
[
|
||||
"file1.js",
|
||||
[{ oldStart: 5, oldEnd: 1, newContent: ["new code"] }], // Invalid range (start > end)
|
||||
],
|
||||
])
|
||||
const invalidHunks = new Map()
|
||||
|
||||
mockDependencies.determineValidHunks.mockResolvedValue({
|
||||
validHunks,
|
||||
invalidHunks,
|
||||
})
|
||||
mockInstallationOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { head: { ref: "feature-branch", sha: "abc123" } },
|
||||
})
|
||||
|
||||
const consoleSpy = jest.spyOn(console, "log").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,
|
||||
"trace123",
|
||||
)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Found invalid review range for file1.js: start_line (5) must be less than line (1) with content new code",
|
||||
)
|
||||
expect(result).toBe(null)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle branch creation failure", async () => {
|
||||
mockDependencies.createNewBranchFromDiffContents.mockResolvedValue(false)
|
||||
mockInstallationOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { head: { ref: "feature-branch" } },
|
||||
})
|
||||
|
||||
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,
|
||||
"trace123",
|
||||
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:"),
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,634 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach, beforeAll } from "@jest/globals"
|
||||
import { Request, Response } from "express"
|
||||
let verifyExistingOptimizations: typeof import("../verify-existing-optimizations").verifyExistingOptimizations
|
||||
let setVerifyExistingOptimizationsDependencies: typeof import("../verify-existing-optimizations").setVerifyExistingOptimizationsDependencies
|
||||
let resetVerifyExistingOptimizationsDependencies: typeof import("../verify-existing-optimizations").resetVerifyExistingOptimizationsDependencies
|
||||
|
||||
// Extend Request interface to include userId for testing
|
||||
interface TestRequest extends Request {
|
||||
userId: string
|
||||
}
|
||||
|
||||
describe("verifyExistingOptimizations", () => {
|
||||
beforeAll(async () => {
|
||||
process.env.KEY_VAULT_NAME = "mocked-keyvault-name"
|
||||
const mod = await import("../verify-existing-optimizations")
|
||||
verifyExistingOptimizations = mod.verifyExistingOptimizations
|
||||
setVerifyExistingOptimizationsDependencies = mod.setVerifyExistingOptimizationsDependencies
|
||||
resetVerifyExistingOptimizationsDependencies = mod.resetVerifyExistingOptimizationsDependencies
|
||||
})
|
||||
let mockReq: Partial<TestRequest>
|
||||
let mockRes: Partial<Response>
|
||||
let mockDependencies: any
|
||||
let mockOctokit: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup request mock
|
||||
mockReq = {
|
||||
body: {
|
||||
repo_owner: "test-owner",
|
||||
repo_name: "test-repo",
|
||||
pr_number: 123,
|
||||
},
|
||||
userId: "test-user-id", // Now properly typed
|
||||
}
|
||||
|
||||
// Setup response mock
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis() as any,
|
||||
json: jest.fn() as any,
|
||||
send: jest.fn() as any,
|
||||
}
|
||||
|
||||
// Setup mock octokit
|
||||
mockOctokit = {
|
||||
rest: {
|
||||
pulls: {
|
||||
get: jest.fn(),
|
||||
listReviews: jest.fn(),
|
||||
listCommentsForReview: jest.fn(),
|
||||
listReviewComments: jest.fn(),
|
||||
},
|
||||
issues: {
|
||||
listComments: jest.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies = {
|
||||
getInstallationOctokitByOwner: jest.fn(),
|
||||
githubApp: {},
|
||||
parseAndCreateOptimizationsDict: jest.fn(),
|
||||
posthog: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
userNickname: jest.fn(),
|
||||
isUserCollaborator: jest.fn(),
|
||||
}
|
||||
|
||||
setVerifyExistingOptimizationsDependencies(mockDependencies)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetVerifyExistingOptimizationsDependencies()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("input validation", () => {
|
||||
it("should return 400 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)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Missing or malformed fields" })
|
||||
})
|
||||
|
||||
it("should return 400 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)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Missing or malformed fields" })
|
||||
})
|
||||
|
||||
it("should return 401 when user nickname is null", async () => {
|
||||
mockDependencies.userNickname.mockResolvedValue(null)
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Unauthorized" })
|
||||
})
|
||||
|
||||
it("should return 500 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)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Installation error" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("collaborator verification", () => {
|
||||
beforeEach(() => {
|
||||
mockDependencies.userNickname.mockResolvedValue("test-user")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(mockOctokit)
|
||||
})
|
||||
|
||||
it("should return 401 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",
|
||||
})
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-user is not a collaborator on test-owner/test-repo",
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should return 500 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)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: "Failed to verify collaborator status" })
|
||||
// Fix: console.error concatenates the error as a string
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(`Error checking collaborator status: ${error}`)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("PR retrieval", () => {
|
||||
beforeEach(() => {
|
||||
mockDependencies.userNickname.mockResolvedValue("test-user")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(mockOctokit)
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it("should return 404 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",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle 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)
|
||||
|
||||
// Fix: console.error concatenates the error as a string
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`Error in /cfapi/verify-existing-optimizations: ${error}`,
|
||||
)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should successfully retrieve PR data", async () => {
|
||||
mockOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { body: "test body" },
|
||||
})
|
||||
mockOctokit.rest.issues.listComments.mockResolvedValue({ data: [] })
|
||||
mockOctokit.rest.pulls.listReviews.mockResolvedValue({ data: [] })
|
||||
mockOctokit.rest.pulls.listReviewComments.mockResolvedValue({ data: [] })
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockOctokit.rest.pulls.get).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
pull_number: 123,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("comments and reviews retrieval", () => {
|
||||
beforeEach(() => {
|
||||
mockDependencies.userNickname.mockResolvedValue("test-user")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(mockOctokit)
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(true)
|
||||
mockOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { body: "test body" },
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle PR comments retrieval failure", 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:", error)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
error: "Failed to retrieve PR comments for test-owner/test-repo",
|
||||
})
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle PR reviews retrieval failure", 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:", error)
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
error: "Failed to retrieve PR reviews for test-owner/test-repo",
|
||||
})
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should successfully process reviews and comments", async () => {
|
||||
mockOctokit.rest.issues.listComments.mockResolvedValue({
|
||||
data: [{ body: "Regular comment" }],
|
||||
})
|
||||
mockOctokit.rest.pulls.listReviews.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 1, body: "Review body 1" },
|
||||
{ id: 2, body: "Review body 2" },
|
||||
],
|
||||
})
|
||||
mockOctokit.rest.pulls.listCommentsForReview.mockResolvedValue({
|
||||
data: [{ body: "Review comment" }],
|
||||
})
|
||||
mockOctokit.rest.pulls.listReviewComments.mockResolvedValue({
|
||||
data: [{ body: "Additional review comment" }],
|
||||
})
|
||||
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({
|
||||
function1: new Set(["optimization1"]),
|
||||
})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
// Verify that all comments types are collected
|
||||
expect(mockDependencies.parseAndCreateOptimizationsDict).toHaveBeenCalledWith(
|
||||
"test body",
|
||||
expect.arrayContaining([
|
||||
{ body: "Regular comment" },
|
||||
{ body: "Review body 1" },
|
||||
{ body: "Review body 2" },
|
||||
{ body: "Review comment" },
|
||||
{ body: "Additional review comment" },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it("should filter out comments with undefined body", async () => {
|
||||
mockOctokit.rest.issues.listComments.mockResolvedValue({
|
||||
data: [{ body: "Valid comment" }, { body: undefined }, { body: "Another valid comment" }],
|
||||
})
|
||||
mockOctokit.rest.pulls.listReviews.mockResolvedValue({ data: [] })
|
||||
mockOctokit.rest.pulls.listReviewComments.mockResolvedValue({ data: [] })
|
||||
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({
|
||||
function1: new Set(["optimization1"]),
|
||||
})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.parseAndCreateOptimizationsDict).toHaveBeenCalledWith("test body", [
|
||||
{ body: "Valid comment" },
|
||||
{ body: "Another valid comment" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("optimizations processing", () => {
|
||||
beforeEach(() => {
|
||||
mockDependencies.userNickname.mockResolvedValue("test-user")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(mockOctokit)
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(true)
|
||||
mockOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { body: "test body" },
|
||||
})
|
||||
mockOctokit.rest.issues.listComments.mockResolvedValue({
|
||||
data: [{ body: "test comment" }],
|
||||
})
|
||||
mockOctokit.rest.pulls.listReviews.mockResolvedValue({ data: [] })
|
||||
mockOctokit.rest.pulls.listReviewComments.mockResolvedValue({ data: [] })
|
||||
})
|
||||
|
||||
it("should return 404 when no optimizations are found", async () => {
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
error: "No optimizations found for this PR",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle null PR body", async () => {
|
||||
mockOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { body: null },
|
||||
})
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.parseAndCreateOptimizationsDict).toHaveBeenCalledWith(
|
||||
"", // null body should become empty string
|
||||
[{ body: "test comment" }],
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle undefined PR body", async () => {
|
||||
mockOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { body: undefined },
|
||||
})
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.parseAndCreateOptimizationsDict).toHaveBeenCalledWith(
|
||||
"", // undefined body should become empty string
|
||||
[{ body: "test comment" }],
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert Set to array format and return optimizations", async () => {
|
||||
const mockOptimizationsDict = {
|
||||
function1: new Set(["optimization1", "optimization2"]),
|
||||
function2: new Set(["optimization3"]),
|
||||
}
|
||||
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue(mockOptimizationsDict)
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
const expectedResponse = {
|
||||
function1: ["optimization1", "optimization2"],
|
||||
function2: ["optimization3"],
|
||||
}
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.json).toHaveBeenCalledWith(expectedResponse)
|
||||
})
|
||||
|
||||
it("should track analytics event", async () => {
|
||||
const mockOptimizationsDict = {
|
||||
function1: new Set(["optimization1"]),
|
||||
}
|
||||
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue(mockOptimizationsDict)
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.posthog.capture).toHaveBeenCalledWith({
|
||||
distinctId: "test-user-id",
|
||||
event: "cfapi-github-pr-optimization",
|
||||
properties: {
|
||||
repo_owner: "test-owner",
|
||||
repo_name: "test-repo",
|
||||
pr_number: 123,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle empty sets in optimizations dict", async () => {
|
||||
const mockOptimizationsDict = {
|
||||
function1: new Set([]),
|
||||
function2: new Set(["optimization1"]),
|
||||
}
|
||||
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue(mockOptimizationsDict)
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
const expectedResponse = {
|
||||
function1: [],
|
||||
function2: ["optimization1"],
|
||||
}
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.json).toHaveBeenCalledWith(expectedResponse)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
beforeEach(() => {
|
||||
mockDependencies.userNickname.mockResolvedValue("test-user")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(mockOctokit)
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it("should handle 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)
|
||||
|
||||
// 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",
|
||||
})
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle 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",
|
||||
})
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
beforeEach(() => {
|
||||
mockDependencies.userNickname.mockResolvedValue("test-user")
|
||||
mockDependencies.getInstallationOctokitByOwner.mockResolvedValue(mockOctokit)
|
||||
mockDependencies.isUserCollaborator.mockResolvedValue(true)
|
||||
mockOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { body: "test body" },
|
||||
})
|
||||
mockOctokit.rest.issues.listComments.mockResolvedValue({ data: [] })
|
||||
mockOctokit.rest.pulls.listReviews.mockResolvedValue({ data: [] })
|
||||
mockOctokit.rest.pulls.listReviewComments.mockResolvedValue({ data: [] })
|
||||
})
|
||||
|
||||
it("should handle review comment retrieval failures gracefully", async () => {
|
||||
mockOctokit.rest.pulls.listReviews.mockResolvedValue({
|
||||
data: [{ id: 1, body: "Review body" }],
|
||||
})
|
||||
mockOctokit.rest.pulls.listCommentsForReview.mockRejectedValue(new Error("Review API error"))
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({
|
||||
function1: new Set(["optimization1"]),
|
||||
})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error getting review comments for review 1:",
|
||||
expect.any(Error),
|
||||
)
|
||||
// Should still continue and succeed
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle all review comments retrieval failure gracefully", async () => {
|
||||
mockOctokit.rest.pulls.listReviewComments.mockRejectedValue(
|
||||
new Error("All review comments API error"),
|
||||
)
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({
|
||||
function1: new Set(["optimization1"]),
|
||||
})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error getting all review comments:",
|
||||
expect.any(Error),
|
||||
)
|
||||
// Should still continue and succeed
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle valid non-zero PR number", async () => {
|
||||
mockReq.body.pr_number = 999
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockOctokit.rest.pulls.get).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
pull_number: 999,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle very large optimizations dict", async () => {
|
||||
const largeOptimizationsDict: { [key: string]: Set<string> } = {}
|
||||
|
||||
// Create 100 functions with 50 optimizations each
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const functionName = `function${i}`
|
||||
const optimizations = new Set<string>()
|
||||
for (let j = 0; j < 50; j++) {
|
||||
optimizations.add(`optimization${i}_${j}`)
|
||||
}
|
||||
largeOptimizationsDict[functionName] = optimizations
|
||||
}
|
||||
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue(largeOptimizationsDict)
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
|
||||
// Verify the response structure is correct
|
||||
const jsonCall = (mockRes.json as jest.MockedFunction<any>).mock.calls[0][0]
|
||||
expect(Object.keys(jsonCall)).toHaveLength(100)
|
||||
expect(jsonCall.function0).toHaveLength(50)
|
||||
expect(jsonCall.function99).toHaveLength(50)
|
||||
})
|
||||
|
||||
it("should handle reviews with no body", async () => {
|
||||
mockOctokit.rest.pulls.listReviews.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 1, body: "Review with body" },
|
||||
{ id: 2, body: null },
|
||||
{ id: 3, body: "" },
|
||||
{ id: 4 }, // no body property
|
||||
],
|
||||
})
|
||||
mockOctokit.rest.pulls.listCommentsForReview.mockResolvedValue({ data: [] })
|
||||
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({
|
||||
function1: new Set(["optimization1"]),
|
||||
})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
// Should only include reviews with truthy body values
|
||||
expect(mockDependencies.parseAndCreateOptimizationsDict).toHaveBeenCalledWith(
|
||||
"test body",
|
||||
expect.arrayContaining([{ body: "Review with body" }]),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle complex review structure", async () => {
|
||||
mockOctokit.rest.pulls.listReviews.mockResolvedValue({
|
||||
data: [{ id: 1, body: "Main review body" }],
|
||||
})
|
||||
mockOctokit.rest.pulls.listCommentsForReview.mockResolvedValue({
|
||||
data: [
|
||||
{ body: "Review comment 1" },
|
||||
{ body: "Review comment 2" },
|
||||
{ body: null }, // Should be filtered out
|
||||
],
|
||||
})
|
||||
mockOctokit.rest.pulls.listReviewComments.mockResolvedValue({
|
||||
data: [{ body: "General review comment 1" }, { body: "General review comment 2" }],
|
||||
})
|
||||
|
||||
mockDependencies.parseAndCreateOptimizationsDict.mockReturnValue({
|
||||
function1: new Set(["optimization1"]),
|
||||
})
|
||||
|
||||
await verifyExistingOptimizations(mockReq as Request, mockRes as Response)
|
||||
|
||||
expect(mockDependencies.parseAndCreateOptimizationsDict).toHaveBeenCalledWith(
|
||||
"test body",
|
||||
expect.arrayContaining([
|
||||
{ body: "Main review body" },
|
||||
{ body: "Review comment 1" },
|
||||
{ body: "Review comment 2" },
|
||||
{ body: "General review comment 1" },
|
||||
{ body: "General review comment 2" },
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -5,6 +5,44 @@ import { parseAndCreateOptimizationsDict } from "../github/pr-changes-utils.js"
|
|||
import { posthog } from "../analytics.js"
|
||||
import { userNickname } from "../auth0-mgmt.js"
|
||||
|
||||
// Dependencies interface for easier testing
|
||||
export interface VerifyExistingOptimizationsDependencies {
|
||||
getInstallationOctokitByOwner: typeof getInstallationOctokitByOwner
|
||||
githubApp: typeof githubApp
|
||||
parseAndCreateOptimizationsDict: typeof parseAndCreateOptimizationsDict
|
||||
posthog: typeof posthog
|
||||
userNickname: typeof userNickname
|
||||
isUserCollaborator: typeof isUserCollaborator
|
||||
}
|
||||
|
||||
// Default dependencies
|
||||
let dependencies: VerifyExistingOptimizationsDependencies = {
|
||||
getInstallationOctokitByOwner,
|
||||
githubApp,
|
||||
parseAndCreateOptimizationsDict,
|
||||
posthog,
|
||||
userNickname,
|
||||
isUserCollaborator,
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setVerifyExistingOptimizationsDependencies(
|
||||
deps: Partial<VerifyExistingOptimizationsDependencies>,
|
||||
) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetVerifyExistingOptimizationsDependencies() {
|
||||
dependencies = {
|
||||
getInstallationOctokitByOwner,
|
||||
githubApp,
|
||||
parseAndCreateOptimizationsDict,
|
||||
posthog,
|
||||
userNickname,
|
||||
isUserCollaborator,
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyExistingOptimizations(req: Request, res: Response) {
|
||||
try {
|
||||
const { repo_owner, repo_name, pr_number } = req.body
|
||||
|
|
@ -14,19 +52,30 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
|
|||
return res.status(400).json({ error: "Missing or malformed fields" })
|
||||
}
|
||||
|
||||
const nickname: string | null = await userNickname(userId)
|
||||
const nickname: string | null = await dependencies.userNickname(userId)
|
||||
if (nickname == null) {
|
||||
return res.status(401).json({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const octokit = await getInstallationOctokitByOwner(githubApp, repo_owner, repo_name)
|
||||
const octokit = await dependencies.getInstallationOctokitByOwner(
|
||||
dependencies.githubApp,
|
||||
repo_owner,
|
||||
repo_name,
|
||||
)
|
||||
if (octokit instanceof Error) {
|
||||
return res.status(500).json({ error: octokit.message })
|
||||
}
|
||||
|
||||
console.log(`Got installation Octokit for ${repo_owner}/${repo_name}`)
|
||||
|
||||
// Check collaborator status with error handling
|
||||
try {
|
||||
const isCollaborator = await isUserCollaborator(octokit, repo_owner, repo_name, nickname)
|
||||
const isCollaborator = await dependencies.isUserCollaborator(
|
||||
octokit,
|
||||
repo_owner,
|
||||
repo_name,
|
||||
nickname,
|
||||
)
|
||||
if (!isCollaborator) {
|
||||
console.log(`${nickname} is not a collaborator on ${repo_owner}/${repo_name}`)
|
||||
return res.status(401).json({ error: "Unauthorized - User is not a collaborator" })
|
||||
|
|
@ -82,7 +131,7 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
|
|||
error: `Failed to retrieve PR reviews for ${repo_owner}/${repo_name}`,
|
||||
})
|
||||
}
|
||||
//console.log("pr reviews:", pr_reviews)
|
||||
|
||||
const reviewBodies: { body: string }[] = []
|
||||
for (const review of pr_reviews.data) {
|
||||
// Add the main review body if it exists
|
||||
|
|
@ -98,7 +147,6 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
|
|||
pull_number: pr_number,
|
||||
review_id: review.id,
|
||||
})
|
||||
// console.log(`Review comments for review ${review.id}:`, reviewComments)
|
||||
// Add each review comment body
|
||||
for (const comment of reviewComments.data) {
|
||||
if (comment.body) {
|
||||
|
|
@ -118,7 +166,6 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
|
|||
repo: repo_name,
|
||||
pull_number: pr_number,
|
||||
})
|
||||
// console.log("All review comments:", allReviewComments)
|
||||
for (const comment of allReviewComments.data) {
|
||||
if (comment.body) {
|
||||
reviewBodies.push({ body: comment.body })
|
||||
|
|
@ -134,10 +181,10 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
|
|||
(comment: { body?: string }) => comment.body !== undefined,
|
||||
) as { body: string }[]
|
||||
const allComments = [...validComments, ...reviewBodies]
|
||||
const optimizations_dict = parseAndCreateOptimizationsDict(prBody, allComments)
|
||||
const optimizations_dict = dependencies.parseAndCreateOptimizationsDict(prBody, allComments)
|
||||
|
||||
if (Object.keys(optimizations_dict).length === 0) {
|
||||
return res.status(200).json({ error: "No optimizations found for this PR" })
|
||||
return res.status(404).json({ error: "No optimizations found for this PR" })
|
||||
}
|
||||
|
||||
const response_dict: { [key: string]: string[] } = {}
|
||||
|
|
@ -145,7 +192,7 @@ export async function verifyExistingOptimizations(req: Request, res: Response) {
|
|||
response_dict[key] = Array.from(optimizations_dict[key])
|
||||
}
|
||||
|
||||
posthog.capture({
|
||||
dependencies.posthog.capture({
|
||||
distinctId: userId,
|
||||
event: `cfapi-github-pr-optimization`,
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -12,13 +12,9 @@ import type { RestEndpointMethodTypes } from "@octokit/rest"
|
|||
import { buildResultHeader, buildResultDetails, buildResultTestReport } from "./pr-changes-utils.js"
|
||||
import { AnyOctokit, PullRequestCreationResponse } from "../types.js"
|
||||
|
||||
type PullsCreateResponse = RestEndpointMethodTypes["pulls"]["create"]["response"]
|
||||
type GitCreateTreeParamsTree =
|
||||
RestEndpointMethodTypes["git"]["createTree"]["parameters"]["tree"][number]
|
||||
type Response<T> = {
|
||||
status: number
|
||||
data: T
|
||||
}
|
||||
|
||||
interface BenchmarkDetail {
|
||||
benchmark_name: string
|
||||
test_function: string
|
||||
|
|
@ -40,6 +36,53 @@ export interface PrCommentFields {
|
|||
benchmark_details?: BenchmarkDetail[]
|
||||
}
|
||||
|
||||
// Dependencies interface for easier testing
|
||||
export interface CreatePrFromDiffContentsDependencies {
|
||||
addLabelToPullRequest: typeof addLabelToPullRequest
|
||||
buildBenchmarkInfo: typeof buildBenchmarkInfo
|
||||
buildDependentPrTitle: typeof buildDependentPrTitle
|
||||
buildPrTitle: typeof buildPrTitle
|
||||
buildResultFooter: typeof buildResultFooter
|
||||
buildResultHeader: typeof buildResultHeader
|
||||
buildResultDetails: typeof buildResultDetails
|
||||
buildResultTestReport: typeof buildResultTestReport
|
||||
originalPRComment: typeof originalPRComment
|
||||
}
|
||||
|
||||
// Default dependencies
|
||||
let dependencies: CreatePrFromDiffContentsDependencies = {
|
||||
addLabelToPullRequest,
|
||||
buildBenchmarkInfo,
|
||||
buildDependentPrTitle,
|
||||
buildPrTitle,
|
||||
buildResultFooter,
|
||||
buildResultHeader,
|
||||
buildResultDetails,
|
||||
buildResultTestReport,
|
||||
originalPRComment,
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setCreatePrFromDiffContentsDependencies(
|
||||
deps: Partial<CreatePrFromDiffContentsDependencies>,
|
||||
) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetCreatePrFromDiffContentsDependencies() {
|
||||
dependencies = {
|
||||
addLabelToPullRequest,
|
||||
buildBenchmarkInfo,
|
||||
buildDependentPrTitle,
|
||||
buildPrTitle,
|
||||
buildResultFooter,
|
||||
buildResultHeader,
|
||||
buildResultDetails,
|
||||
buildResultTestReport,
|
||||
originalPRComment,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNewBranchFromDiffContents(
|
||||
installationOctokit: Octokit,
|
||||
owner: string,
|
||||
|
|
@ -49,80 +92,85 @@ export async function createNewBranchFromDiffContents(
|
|||
diffContentsMap: Map<string, FileDiffContent>,
|
||||
commitMessage: string,
|
||||
): Promise<boolean> {
|
||||
// Create a new branch off of the specified base branch
|
||||
await installationOctokit.rest.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/heads/${newBranchName}`,
|
||||
sha: (
|
||||
await installationOctokit.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${baseBranch}`,
|
||||
})
|
||||
).data.object.sha,
|
||||
})
|
||||
|
||||
const treeItems: GitCreateTreeParamsTree[] = []
|
||||
for (const [filePath, fileDiffContent] of diffContentsMap) {
|
||||
// Create blobs for the changed files with newContent
|
||||
const blobData = await installationOctokit.rest.git.createBlob({
|
||||
try {
|
||||
// Create a new branch off of the specified base branch
|
||||
await installationOctokit.rest.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
content: fileDiffContent.newContent,
|
||||
encoding: "utf-8",
|
||||
ref: `refs/heads/${newBranchName}`,
|
||||
sha: (
|
||||
await installationOctokit.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${baseBranch}`,
|
||||
})
|
||||
).data.object.sha,
|
||||
})
|
||||
|
||||
treeItems.push({
|
||||
path: filePath,
|
||||
mode: "100644", // blob
|
||||
type: "blob",
|
||||
sha: blobData.data.sha,
|
||||
const treeItems: GitCreateTreeParamsTree[] = []
|
||||
for (const [filePath, fileDiffContent] of diffContentsMap) {
|
||||
// Create blobs for the changed files with newContent
|
||||
const blobData = await installationOctokit.rest.git.createBlob({
|
||||
owner,
|
||||
repo,
|
||||
content: fileDiffContent.newContent,
|
||||
encoding: "utf-8",
|
||||
})
|
||||
|
||||
treeItems.push({
|
||||
path: filePath,
|
||||
mode: "100644", // blob
|
||||
type: "blob",
|
||||
sha: blobData.data.sha,
|
||||
})
|
||||
}
|
||||
|
||||
// Get the current commit SHA of the new branch
|
||||
const refData = await installationOctokit.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${newBranchName}`,
|
||||
})
|
||||
const currentCommitSha = refData.data.object.sha
|
||||
|
||||
// Get the tree SHA of the current commit
|
||||
const commitData = await installationOctokit.rest.git.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: currentCommitSha,
|
||||
})
|
||||
const currentTreeSha = commitData.data.tree.sha
|
||||
|
||||
// Create a new tree with the changes
|
||||
const newTreeData = await installationOctokit.rest.git.createTree({
|
||||
owner,
|
||||
repo,
|
||||
base_tree: currentTreeSha,
|
||||
tree: treeItems,
|
||||
})
|
||||
|
||||
// Create a new commit with the new tree
|
||||
const newCommitData = await installationOctokit.rest.git.createCommit({
|
||||
owner,
|
||||
repo,
|
||||
message: commitMessage,
|
||||
tree: newTreeData.data.sha,
|
||||
parents: [currentCommitSha],
|
||||
})
|
||||
|
||||
// Update the new branch reference to point to the new commit
|
||||
const result = await installationOctokit.rest.git.updateRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${newBranchName}`,
|
||||
sha: newCommitData.data.sha,
|
||||
})
|
||||
|
||||
return result.status === 200
|
||||
} catch (error) {
|
||||
console.error("Error creating branch from diff contents:", error)
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the current commit SHA of the new branch
|
||||
const refData = await installationOctokit.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${newBranchName}`,
|
||||
})
|
||||
const currentCommitSha = refData.data.object.sha
|
||||
|
||||
// Get the tree SHA of the current commit
|
||||
const commitData = await installationOctokit.rest.git.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: currentCommitSha,
|
||||
})
|
||||
const currentTreeSha = commitData.data.tree.sha
|
||||
|
||||
// Create a new tree with the changes
|
||||
const newTreeData = await installationOctokit.rest.git.createTree({
|
||||
owner,
|
||||
repo,
|
||||
base_tree: currentTreeSha,
|
||||
tree: treeItems,
|
||||
})
|
||||
|
||||
// Create a new commit with the new tree
|
||||
const newCommitData = await installationOctokit.rest.git.createCommit({
|
||||
owner,
|
||||
repo,
|
||||
message: commitMessage,
|
||||
tree: newTreeData.data.sha,
|
||||
parents: [currentCommitSha],
|
||||
})
|
||||
|
||||
// Update the new branch reference to point to the new commit
|
||||
const result = await installationOctokit.rest.git.updateRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${newBranchName}`,
|
||||
sha: newCommitData.data.sha,
|
||||
})
|
||||
|
||||
return result.status === 200
|
||||
}
|
||||
|
||||
export async function createNewPullRequest(
|
||||
|
|
@ -133,7 +181,6 @@ export async function createNewPullRequest(
|
|||
body: string,
|
||||
newBranchName: string,
|
||||
baseBranch: string,
|
||||
// ): Promise<Octokit.Response<Octokit.PullsCreateResponse>> {
|
||||
): Promise<PullRequestCreationResponse> {
|
||||
return await installationOctokit.rest.pulls.create({
|
||||
owner,
|
||||
|
|
@ -160,6 +207,53 @@ export async function assignReviewer(
|
|||
})
|
||||
}
|
||||
|
||||
export async function createStandalonePullRequest(
|
||||
installationOctokit: AnyOctokit,
|
||||
owner: string,
|
||||
repo: string,
|
||||
newBranchName: string,
|
||||
baseBranch: string,
|
||||
prCommentFields: PrCommentFields,
|
||||
existingTests: string,
|
||||
generatedTests: string,
|
||||
coverage_message: string,
|
||||
): Promise<PullRequestCreationResponse> {
|
||||
// Open a new standalone Codeflash PR (that doesn't reference an original PR, likely just to main)
|
||||
const prCommentHeader = dependencies.buildResultHeader(prCommentFields)
|
||||
// Build benchmark info if available
|
||||
const benchmarkInfo =
|
||||
prCommentFields.benchmark_details && prCommentFields.benchmark_details.length > 0
|
||||
? dependencies.buildBenchmarkInfo(prCommentFields)
|
||||
: ""
|
||||
// Open a new PR from the new branch onto the original PR's head branch
|
||||
const prCommentBody = dependencies.buildResultDetails(prCommentFields)
|
||||
const prCommentTestReport = dependencies.buildResultTestReport(
|
||||
prCommentFields,
|
||||
existingTests,
|
||||
generatedTests,
|
||||
coverage_message,
|
||||
)
|
||||
const prCommentFooter = dependencies.buildResultFooter(newBranchName)
|
||||
const title: string = dependencies.buildPrTitle(
|
||||
prCommentFields.function_name,
|
||||
prCommentFields.speedup_pct,
|
||||
prCommentFields.speedup_x,
|
||||
)
|
||||
const body: string = benchmarkInfo
|
||||
? `${prCommentHeader}\n${benchmarkInfo}\n${prCommentBody}\n${prCommentTestReport}\n${prCommentFooter}`
|
||||
: `${prCommentHeader}\n${prCommentBody}\n${prCommentTestReport}\n${prCommentFooter}`
|
||||
|
||||
return await createNewPullRequest(
|
||||
installationOctokit,
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
newBranchName,
|
||||
baseBranch,
|
||||
)
|
||||
}
|
||||
|
||||
export async function createDependentPullRequest(
|
||||
installationOctokit: AnyOctokit,
|
||||
owner: string,
|
||||
|
|
@ -172,7 +266,7 @@ export async function createDependentPullRequest(
|
|||
generatedTests: string,
|
||||
coverage_message: string,
|
||||
): Promise<PullRequestCreationResponse> {
|
||||
let { title, body } = createDependentPRTitleAndBody(
|
||||
const { title, body } = createDependentPRTitleAndBody(
|
||||
prCommentFields,
|
||||
existingTests,
|
||||
generatedTests,
|
||||
|
|
@ -181,6 +275,7 @@ export async function createDependentPullRequest(
|
|||
newBranchName,
|
||||
baseBranch,
|
||||
)
|
||||
|
||||
const newPrData = await createNewPullRequest(
|
||||
installationOctokit,
|
||||
owner,
|
||||
|
|
@ -190,11 +285,16 @@ export async function createDependentPullRequest(
|
|||
newBranchName,
|
||||
baseBranch,
|
||||
)
|
||||
await addLabelToPullRequest(installationOctokit, owner, repo, newPrData.data.number)
|
||||
|
||||
await dependencies.addLabelToPullRequest(installationOctokit, owner, repo, newPrData.data.number)
|
||||
|
||||
// Make a comment on the original PR with a link to the new PR
|
||||
const commentBody: string = originalPRComment(prCommentFields, newPrData.data.number, baseBranch)
|
||||
const result = await installationOctokit.rest.issues.createComment({
|
||||
const commentBody: string = dependencies.originalPRComment(
|
||||
prCommentFields,
|
||||
newPrData.data.number,
|
||||
baseBranch,
|
||||
)
|
||||
await installationOctokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: origPrNumber,
|
||||
|
|
@ -213,22 +313,22 @@ function createDependentPRTitleAndBody(
|
|||
newBranchName: string,
|
||||
baseBranch: string,
|
||||
): { title: string; body: string } {
|
||||
const prCommentHeader = buildResultHeader(prCommentFields)
|
||||
const prCommentHeader = dependencies.buildResultHeader(prCommentFields)
|
||||
// Build benchmark info if available
|
||||
const benchmarkInfo =
|
||||
prCommentFields.benchmark_details && prCommentFields.benchmark_details.length > 0
|
||||
? buildBenchmarkInfo(prCommentFields)
|
||||
? dependencies.buildBenchmarkInfo(prCommentFields)
|
||||
: ""
|
||||
// Open a new PR from the new branch onto the original PR's head branch
|
||||
const prCommentBody = buildResultDetails(prCommentFields)
|
||||
const prCommentTestReport = buildResultTestReport(
|
||||
const prCommentBody = dependencies.buildResultDetails(prCommentFields)
|
||||
const prCommentTestReport = dependencies.buildResultTestReport(
|
||||
prCommentFields,
|
||||
existingTests,
|
||||
generatedTests,
|
||||
coverage_message,
|
||||
)
|
||||
const prCommentFooter = buildResultFooter(newBranchName)
|
||||
const title = buildDependentPrTitle(
|
||||
const prCommentFooter = dependencies.buildResultFooter(newBranchName)
|
||||
const title = dependencies.buildDependentPrTitle(
|
||||
prCommentFields.function_name,
|
||||
prCommentFields.speedup_pct,
|
||||
prCommentFields.speedup_x,
|
||||
|
|
@ -245,5 +345,5 @@ If you approve this dependent PR, these changes will be merged into the original
|
|||
? `${introSection}${prCommentHeader}\n${benchmarkInfo}\n${prCommentBody}\n${prCommentTestReport}\n${prCommentFooter}`
|
||||
: `${introSection}${prCommentHeader}\n${prCommentBody}\n${prCommentTestReport}\n${prCommentFooter}`
|
||||
|
||||
return { title: title, body: body }
|
||||
return { title, body }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,406 +18,452 @@ import {
|
|||
} from "@codeflash-ai/common"
|
||||
import { getUserRole } from "./github-utils.js"
|
||||
|
||||
const APP_ID: string = process.env.GH_APP_ID ?? "" // GitHub App ID
|
||||
const APP_USER_ID: number = parseInt(process.env.GH_APP_USER_ID ?? "0") // GitHub App User ID
|
||||
|
||||
const PRIVATE_KEY: string =
|
||||
process.env.NODE_ENV === "production"
|
||||
? await getGithubAppPrivateKey()
|
||||
: fs.readFileSync("github/Codeflash AI Dev GitHub App Private Key.pem", "utf8")
|
||||
const WEBHOOK_SECRET: string =
|
||||
process.env.NODE_ENV === "production"
|
||||
? await getGithubAppWebhookSecret()
|
||||
: (process.env.GH_APP_WEBHOOK_SECRET ?? "default-secret")
|
||||
// Create a function to get config without initializing the app
|
||||
async function getGithubAppConfig() {
|
||||
const APP_ID: string = process.env.GH_APP_ID ?? ""
|
||||
|
||||
export const githubApp = new App({
|
||||
appId: APP_ID,
|
||||
privateKey: PRIVATE_KEY,
|
||||
webhooks: {
|
||||
secret: WEBHOOK_SECRET,
|
||||
},
|
||||
oauth: {
|
||||
// OAuth details are currently unused by the app
|
||||
clientId: "", // process.env.GH_APP_CLIENT_ID ?? "",
|
||||
clientSecret: "", // process.env.GH_APP_CLIENT_SECRET ?? "",
|
||||
},
|
||||
})
|
||||
console.log(`Github App Initialized`)
|
||||
const PRIVATE_KEY: string =
|
||||
process.env.NODE_ENV === "production"
|
||||
? await getGithubAppPrivateKey()
|
||||
: process.env.NODE_ENV === "test"
|
||||
? "test-private-key"
|
||||
: fs.readFileSync("github/Codeflash AI Dev GitHub App Private Key.pem", "utf8")
|
||||
|
||||
const { data } = await githubApp.octokit.request("/app")
|
||||
// Read more about custom logging: https://github.com/octokit/core.js#logging
|
||||
githubApp.octokit.log.debug(`Authenticated as '${data.name}'`)
|
||||
const WEBHOOK_SECRET: string =
|
||||
process.env.NODE_ENV === "production"
|
||||
? await getGithubAppWebhookSecret()
|
||||
: (process.env.GH_APP_WEBHOOK_SECRET ?? "default-secret")
|
||||
|
||||
githubApp.webhooks.onAny(async ({ id, name, payload }) => {
|
||||
console.log(`Github App: Received webhook event ${name} (${id})`)
|
||||
console.log(`Payload: ${JSON.stringify(payload)}`)
|
||||
posthog.capture({
|
||||
distinctId: `github|${payload.sender?.id}`,
|
||||
event: `cfapi-github-webhook-received`,
|
||||
properties: {
|
||||
event: name,
|
||||
id,
|
||||
return { APP_ID, PRIVATE_KEY, WEBHOOK_SECRET }
|
||||
}
|
||||
|
||||
// Initialize the app conditionally
|
||||
const initializeApp = async () => {
|
||||
const { APP_ID, PRIVATE_KEY, WEBHOOK_SECRET } = await getGithubAppConfig()
|
||||
|
||||
return new App({
|
||||
appId: APP_ID,
|
||||
privateKey: PRIVATE_KEY,
|
||||
webhooks: {
|
||||
secret: WEBHOOK_SECRET,
|
||||
},
|
||||
oauth: {
|
||||
// OAuth details are currently unused by the app
|
||||
clientId: "", // process.env.GH_APP_CLIENT_ID ?? "",
|
||||
clientSecret: "", // process.env.GH_APP_CLIENT_SECRET ?? "",
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`Github App Authenticated as '${data.name}'`)
|
||||
|
||||
githubApp.webhooks.on("installation", async ({ octokit, payload }) => {
|
||||
console.log(`Received a new installation event: ${JSON.stringify(payload)}`)
|
||||
// Create an installation access token
|
||||
const installationAccessToken = await octokit.rest.apps.createInstallationAccessToken({
|
||||
installation_id: payload.installation.id,
|
||||
})
|
||||
console.log(`Installation access token: ${installationAccessToken.data.token}`)
|
||||
})
|
||||
|
||||
githubApp.webhooks.on("pull_request.opened", async ({ octokit, payload }) => {
|
||||
console.log(`Received a pull request opened event: ${JSON.stringify(payload)}`)
|
||||
})
|
||||
githubApp.webhooks.on("pull_request.edited", async ({ octokit, payload }) => {
|
||||
console.log(`Received a pull request edited event: ${JSON.stringify(payload)}`)
|
||||
})
|
||||
|
||||
githubApp.webhooks.on("pull_request.closed", async ({ octokit, payload }) => {
|
||||
if (payload.pull_request) {
|
||||
const prId = String(payload.pull_request.id)
|
||||
try {
|
||||
await prisma.optimization_events.updateMany({
|
||||
where: { pr_id: prId },
|
||||
data: {
|
||||
event_type: payload.pull_request.merged ? "pr_merged" : "pr_closed",
|
||||
// Export the actual App instance, initialized based on environment
|
||||
export const githubApp = await (async () => {
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
// In test environment, return a minimal mock that won't fail
|
||||
return {
|
||||
octokit: {
|
||||
request: async () => ({ data: { name: "Test App" } }),
|
||||
log: {
|
||||
debug: () => {},
|
||||
},
|
||||
})
|
||||
console.log(
|
||||
`Updated optimization_event for PR ID ${prId} to ${payload.pull_request.merged ? "pr_merged" : "pr_closed"}`,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(`Failed to update optimization_event for PR ID ${prId}:`, err)
|
||||
}
|
||||
console.log(
|
||||
`Received a pull request closed event. PR #${payload.pull_request.number} ` +
|
||||
`by ${payload.pull_request.user.login} was closed.`,
|
||||
)
|
||||
},
|
||||
webhooks: {
|
||||
on: () => {},
|
||||
onAny: () => {},
|
||||
onError: () => {},
|
||||
},
|
||||
getInstallationOctokit: async () => ({}),
|
||||
} as any as App
|
||||
}
|
||||
|
||||
// Check if the PR was merged and is a PR created by Codeflash
|
||||
const is_user_code_flash = payload.pull_request.user.id === APP_USER_ID
|
||||
if (payload.pull_request.merged && is_user_code_flash) {
|
||||
// Extract the original PR number from the branch name
|
||||
const dependentBranchNamePattern = /codeflash.optimize-pr(\d+)-\d{4}-\d{2}-\d{2}T.+$/
|
||||
const standaloneBranchNamePattern = /codeflash.optimize-(.+)-\d{4}-\d{2}-\d{2}T.+$/
|
||||
const dependentPrMatch = dependentBranchNamePattern.exec(payload.pull_request.head.ref)
|
||||
const standalonePrMatch = standaloneBranchNamePattern.exec(payload.pull_request.head.ref)
|
||||
if (dependentPrMatch != null) {
|
||||
const originalPrNumber = parseInt(dependentPrMatch[1])
|
||||
let username = "You"
|
||||
if (payload.pull_request.merged_by != null) {
|
||||
// should not be null, but check anyway
|
||||
username = `@${payload.pull_request.merged_by.login}`
|
||||
}
|
||||
// Comment on the original PR
|
||||
await octokit.rest.issues.createComment({
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
issue_number: originalPrNumber,
|
||||
body: `This PR is now faster! 🚀 ${username} accepted my optimizations from:
|
||||
- #${payload.pull_request.number}`,
|
||||
})
|
||||
// In other environments, initialize normally
|
||||
const app = await initializeApp()
|
||||
|
||||
posthog.capture({
|
||||
distinctId: `github|${payload.sender.id}`, // this is the user who merged the PR
|
||||
event: `cfapi-github-dependent-pr-merged`,
|
||||
properties: {
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
originalPrNumber,
|
||||
dependentPrNumber: payload.pull_request.number,
|
||||
mergedBy: payload.pull_request.merged_by?.login,
|
||||
console.log(`Github App Initialized`)
|
||||
|
||||
const { data } = await app.octokit.request("/app")
|
||||
// Read more about custom logging: https://github.com/octokit/core.js#logging
|
||||
app.octokit.log.debug(`Authenticated as '${data.name}'`)
|
||||
|
||||
app.webhooks.onAny(async ({ id, name, payload }) => {
|
||||
console.log(`Github App: Received webhook event ${name} (${id})`)
|
||||
console.log(`Payload: ${JSON.stringify(payload)}`)
|
||||
posthog.capture({
|
||||
distinctId: `github|${payload.sender?.id}`,
|
||||
event: `cfapi-github-webhook-received`,
|
||||
properties: {
|
||||
event: name,
|
||||
id,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
console.log(`Github App Authenticated as '${data.name}'`)
|
||||
|
||||
app.webhooks.on("installation", async ({ octokit, payload }) => {
|
||||
console.log(`Received a new installation event: ${JSON.stringify(payload)}`)
|
||||
// Create an installation access token
|
||||
const installationAccessToken = await octokit.rest.apps.createInstallationAccessToken({
|
||||
installation_id: payload.installation.id,
|
||||
})
|
||||
console.log(`Installation access token: ${installationAccessToken.data.token}`)
|
||||
})
|
||||
|
||||
app.webhooks.on("pull_request.opened", async ({ octokit, payload }) => {
|
||||
console.log(`Received a pull request opened event: ${JSON.stringify(payload)}`)
|
||||
})
|
||||
|
||||
app.webhooks.on("pull_request.edited", async ({ octokit, payload }) => {
|
||||
console.log(`Received a pull request edited event: ${JSON.stringify(payload)}`)
|
||||
})
|
||||
|
||||
app.webhooks.on("pull_request.closed", async ({ octokit, payload }) => {
|
||||
if (payload.pull_request) {
|
||||
const prId = String(payload.pull_request.id)
|
||||
try {
|
||||
await prisma.optimization_events.updateMany({
|
||||
where: { pr_id: prId },
|
||||
data: {
|
||||
event_type: payload.pull_request.merged ? "pr_merged" : "pr_closed",
|
||||
},
|
||||
})
|
||||
console.log(
|
||||
`Commented on original PR #${originalPrNumber} and logged the event to Posthog.`,
|
||||
`Updated optimization_event for PR ID ${prId} to ${payload.pull_request.merged ? "pr_merged" : "pr_closed"}`,
|
||||
)
|
||||
} else if (standalonePrMatch != null) {
|
||||
posthog.capture({
|
||||
distinctId: `github|${payload.sender.id}`,
|
||||
event: `cfapi-github-standalone-pr-merged`,
|
||||
properties: {
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
functionName: standalonePrMatch[1],
|
||||
prNumber: payload.pull_request.number,
|
||||
mergedBy: payload.pull_request.merged_by?.login,
|
||||
},
|
||||
})
|
||||
console.log(`Logged standalone PR #${payload.pull_request.number} merge event to Posthog.`)
|
||||
} catch (err) {
|
||||
console.error(`Failed to update optimization_event for PR ID ${prId}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open optimization PRs targeting the branch of the closed PR
|
||||
// Ensure we only close PRs that are targeting the branch of the PR that was just closed
|
||||
const closedPrBranch = payload.pull_request.head.ref
|
||||
// Logic to close any open optimization PRs targeting this branch
|
||||
console.log(`Closing optimization PRs targeting branch ${closedPrBranch}...`)
|
||||
if (payload.installation === undefined) {
|
||||
console.error(
|
||||
`Error! Installation ID is missing from payload. Cannot close PRs for this installation!`,
|
||||
console.log(
|
||||
`Received a pull request closed event. PR #${payload.pull_request.number} ` +
|
||||
`by ${payload.pull_request.user.login} was closed.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const installationOctokit = await githubApp.getInstallationOctokit(payload.installation.id)
|
||||
const openPrs = await installationOctokit.rest.pulls.list({
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
state: "open",
|
||||
base: closedPrBranch,
|
||||
})
|
||||
|
||||
for (const pr of openPrs.data) {
|
||||
// Check if the PR is opened by the Codeflash GitHub App and targets the same base branch as the closed PR
|
||||
if (
|
||||
pr.user?.type === "Bot" &&
|
||||
pr.user?.id === APP_USER_ID &&
|
||||
pr.base.ref === closedPrBranch
|
||||
) {
|
||||
await installationOctokit.rest.pulls.update({
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
pull_number: pr.number,
|
||||
state: "closed",
|
||||
})
|
||||
console.log(
|
||||
`Closed optimization PR #${pr.number} targeting branch '${closedPrBranch}' ` +
|
||||
`because original PR #${payload.pull_request.number} by ${payload.pull_request.user.login} was closed.`,
|
||||
)
|
||||
console.log(`Posting pull request comment...`)
|
||||
// Check if the PR was merged and is a PR created by Codeflash
|
||||
const is_user_code_flash = payload.pull_request.user.id === APP_USER_ID
|
||||
if (payload.pull_request.merged && is_user_code_flash) {
|
||||
// Extract the original PR number from the branch name
|
||||
const dependentBranchNamePattern = /codeflash.optimize-pr(\d+)-\d{4}-\d{2}-\d{2}T.+$/
|
||||
const standaloneBranchNamePattern = /codeflash.optimize-(.+)-\d{4}-\d{2}-\d{2}T.+$/
|
||||
const dependentPrMatch = dependentBranchNamePattern.exec(payload.pull_request.head.ref)
|
||||
const standalonePrMatch = standaloneBranchNamePattern.exec(payload.pull_request.head.ref)
|
||||
if (dependentPrMatch != null) {
|
||||
const originalPrNumber = parseInt(dependentPrMatch[1])
|
||||
let username = "You"
|
||||
if (payload.pull_request.merged_by != null) {
|
||||
// should not be null, but check anyway
|
||||
username = `@${payload.pull_request.merged_by.login}`
|
||||
}
|
||||
// Comment on the original PR
|
||||
await octokit.rest.issues.createComment({
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
issue_number: pr.number,
|
||||
body:
|
||||
`>This PR has been automatically closed because the original PR #${payload.pull_request.number} ` +
|
||||
`by ${payload.pull_request.user.login} was closed.`,
|
||||
issue_number: originalPrNumber,
|
||||
body: `This PR is now faster! 🚀 ${username} accepted my optimizations from:
|
||||
- #${payload.pull_request.number}`,
|
||||
})
|
||||
|
||||
// Proceed to delete the branch
|
||||
if (is_user_code_flash) {
|
||||
await deleteBranchIfExists(installationOctokit, payload, `heads/${pr.head.ref}`)
|
||||
}
|
||||
posthog.capture({
|
||||
distinctId: `github|${payload.sender.id}`, // this is the user who merged the PR
|
||||
event: `cfapi-github-dependent-pr-merged`,
|
||||
properties: {
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
originalPrNumber,
|
||||
dependentPrNumber: payload.pull_request.number,
|
||||
mergedBy: payload.pull_request.merged_by?.login,
|
||||
},
|
||||
})
|
||||
console.log(
|
||||
`Commented on original PR #${originalPrNumber} and logged the event to Posthog.`,
|
||||
)
|
||||
} else if (standalonePrMatch != null) {
|
||||
posthog.capture({
|
||||
distinctId: `github|${payload.sender.id}`,
|
||||
event: `cfapi-github-standalone-pr-merged`,
|
||||
properties: {
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
functionName: standalonePrMatch[1],
|
||||
prNumber: payload.pull_request.number,
|
||||
mergedBy: payload.pull_request.merged_by?.login,
|
||||
},
|
||||
})
|
||||
console.log(
|
||||
`Logged standalone PR #${payload.pull_request.number} merge event to Posthog.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If there was no open PR's, still delete the branch in case of inline comment
|
||||
if (is_user_code_flash) {
|
||||
await deleteBranchIfExists(installationOctokit, payload, closedPrBranch)
|
||||
// Close any open optimization PRs targeting the branch of the closed PR
|
||||
// Ensure we only close PRs that are targeting the branch of the PR that was just closed
|
||||
const closedPrBranch = payload.pull_request.head.ref
|
||||
// Logic to close any open optimization PRs targeting this branch
|
||||
console.log(`Closing optimization PRs targeting branch ${closedPrBranch}...`)
|
||||
if (payload.installation === undefined) {
|
||||
console.error(
|
||||
`Error! Installation ID is missing from payload. Cannot close PRs for this installation!`,
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const installationOctokit = await app.getInstallationOctokit(payload.installation.id)
|
||||
const openPrs = await installationOctokit.rest.pulls.list({
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
state: "open",
|
||||
base: closedPrBranch,
|
||||
})
|
||||
|
||||
for (const pr of openPrs.data) {
|
||||
// Check if the PR is opened by the Codeflash GitHub App and targets the same base branch as the closed PR
|
||||
if (
|
||||
pr.user?.type === "Bot" &&
|
||||
pr.user?.id === APP_USER_ID &&
|
||||
pr.base.ref === closedPrBranch
|
||||
) {
|
||||
await installationOctokit.rest.pulls.update({
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
pull_number: pr.number,
|
||||
state: "closed",
|
||||
})
|
||||
console.log(
|
||||
`Closed optimization PR #${pr.number} targeting branch '${closedPrBranch}' ` +
|
||||
`because original PR #${payload.pull_request.number} by ${payload.pull_request.user.login} was closed.`,
|
||||
)
|
||||
console.log(`Posting pull request comment...`)
|
||||
await octokit.rest.issues.createComment({
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
issue_number: pr.number,
|
||||
body:
|
||||
`>This PR has been automatically closed because the original PR #${payload.pull_request.number} ` +
|
||||
`by ${payload.pull_request.user.login} was closed.`,
|
||||
})
|
||||
|
||||
// Proceed to delete the branch
|
||||
if (is_user_code_flash) {
|
||||
await deleteBranchIfExists(installationOctokit, payload, `heads/${pr.head.ref}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there was no open PR's, still delete the branch in case of inline comment
|
||||
if (is_user_code_flash) {
|
||||
await deleteBranchIfExists(installationOctokit, payload, closedPrBranch)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to close optimization PRs targeting branch ${closedPrBranch}: ${error}`,
|
||||
)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to close optimization PRs targeting branch ${closedPrBranch}: ${error}`)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
githubApp.webhooks.on("installation.created", async ({ octokit, payload }) => {
|
||||
console.log(`Received a installation.created event: ${JSON.stringify(payload)}`)
|
||||
})
|
||||
|
||||
githubApp.webhooks.on("installation_repositories.added", async ({ octokit, payload }) => {
|
||||
console.log(`Received a installation_repositories.added event: ${JSON.stringify(payload)}`)
|
||||
})
|
||||
|
||||
githubApp.webhooks.on("marketplace_purchase", async ({ id, name, payload }) => {
|
||||
console.log(`Received a marketplace purchase event: ${name} (${id})`)
|
||||
console.log(`Payload: ${JSON.stringify(payload)}`)
|
||||
posthog.capture({
|
||||
distinctId: `github|${payload.sender.id}`,
|
||||
event: `cfapi-github-marketplace-purchase`,
|
||||
properties: {
|
||||
event: name,
|
||||
id,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
githubApp.webhooks.on("pull_request.synchronize", async ({ octokit, payload }) => {
|
||||
if (payload.pull_request) {
|
||||
console.log(
|
||||
`Received a pull request synchronize event. PR #${payload.pull_request.number} ` +
|
||||
`by ${payload.pull_request?.user?.login} was updated with new commits.`,
|
||||
)
|
||||
// Retrieve the list of commits for the pull request
|
||||
const commits = await octokit.rest.pulls.listCommits({
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
pull_number: payload.pull_request.number,
|
||||
app.webhooks.on("installation.created", async ({ octokit, payload }) => {
|
||||
console.log(`Received a installation.created event: ${JSON.stringify(payload)}`)
|
||||
})
|
||||
|
||||
app.webhooks.on("installation_repositories.added", async ({ octokit, payload }) => {
|
||||
console.log(`Received a installation_repositories.added event: ${JSON.stringify(payload)}`)
|
||||
})
|
||||
|
||||
app.webhooks.on("marketplace_purchase", async ({ id, name, payload }) => {
|
||||
console.log(`Received a marketplace purchase event: ${name} (${id})`)
|
||||
console.log(`Payload: ${JSON.stringify(payload)}`)
|
||||
posthog.capture({
|
||||
distinctId: `github|${payload.sender.id}`,
|
||||
event: `cfapi-github-marketplace-purchase`,
|
||||
properties: {
|
||||
event: name,
|
||||
id,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Check the latest commit for the co-authored-by line
|
||||
const latestCommit = commits.data[commits.data.length - 1]
|
||||
if (
|
||||
latestCommit.commit.message.includes(
|
||||
"Co-authored-by: codeflash-ai[bot] <148906541+codeflash-ai[bot]@users.noreply.github.com>",
|
||||
app.webhooks.on("pull_request.synchronize", async ({ octokit, payload }) => {
|
||||
if (payload.pull_request) {
|
||||
console.log(
|
||||
`Received a pull request synchronize event. PR #${payload.pull_request.number} ` +
|
||||
`by ${payload.pull_request?.user?.login} was updated with new commits.`,
|
||||
)
|
||||
) {
|
||||
// Log the event to Posthog
|
||||
posthog.capture({
|
||||
distinctId: `github|${payload.sender.id}`,
|
||||
event: `cfapi-github-commit-coauthored-by-codeflash`,
|
||||
properties: {
|
||||
prNumber: payload.pull_request.number,
|
||||
commitId: latestCommit.sha,
|
||||
repository: payload.repository.full_name,
|
||||
author: latestCommit.commit.author?.name,
|
||||
},
|
||||
})
|
||||
console.log(`Logged co-authored commit to Posthog: ${latestCommit.sha}`)
|
||||
|
||||
// should not be null, but check anyway
|
||||
const authorname = latestCommit.commit.author?.name ?? "You"
|
||||
// Comment on the PR
|
||||
await octokit.rest.issues.createComment({
|
||||
// Retrieve the list of commits for the pull request
|
||||
const commits = await octokit.rest.pulls.listCommits({
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
issue_number: payload.pull_request.number,
|
||||
body: `This PR is now faster! 🚀 ${authorname} accepted my code suggestion above.`,
|
||||
pull_number: payload.pull_request.number,
|
||||
})
|
||||
console.log(
|
||||
`Commented on PR #${payload.pull_request.number} about the accepted review comment.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Optional: Handle errors
|
||||
githubApp.webhooks.onError(error => {
|
||||
console.error(`Error occurred in Github App's onError handler: ${error}`)
|
||||
if (error instanceof Error) {
|
||||
// Check if it's an AggregateError, common for signature issues
|
||||
if (error.name === "AggregateError" && Array.isArray((error as any).errors)) {
|
||||
console.error("AggregateError details (possible secret mismatch or multiple issues):")
|
||||
;(error as any).errors.forEach((subError: Error, i: number) => {
|
||||
console.error(` Sub-error ${i + 1}: ${subError.message}`)
|
||||
})
|
||||
} else if (error.message.includes("content length")) {
|
||||
console.error(
|
||||
"Content length mismatch detected by Octokit. Payload may be truncated or header incorrect.",
|
||||
)
|
||||
const eventRequest = (error as any).event?.request
|
||||
if (eventRequest && eventRequest.headers) {
|
||||
// Check the latest commit for the co-authored-by line
|
||||
const latestCommit = commits.data[commits.data.length - 1]
|
||||
if (
|
||||
latestCommit.commit.message.includes(
|
||||
"Co-authored-by: codeflash-ai[bot] <148906541+codeflash-ai[bot]@users.noreply.github.com>",
|
||||
)
|
||||
) {
|
||||
// Log the event to Posthog
|
||||
posthog.capture({
|
||||
distinctId: `github|${payload.sender.id}`,
|
||||
event: `cfapi-github-commit-coauthored-by-codeflash`,
|
||||
properties: {
|
||||
prNumber: payload.pull_request.number,
|
||||
commitId: latestCommit.sha,
|
||||
repository: payload.repository.full_name,
|
||||
author: latestCommit.commit.author?.name,
|
||||
},
|
||||
})
|
||||
console.log(`Logged co-authored commit to Posthog: ${latestCommit.sha}`)
|
||||
|
||||
// should not be null, but check anyway
|
||||
const authorname = latestCommit.commit.author?.name ?? "You"
|
||||
// Comment on the PR
|
||||
await octokit.rest.issues.createComment({
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
issue_number: payload.pull_request.number,
|
||||
body: `This PR is now faster! 🚀 ${authorname} accepted my code suggestion above.`,
|
||||
})
|
||||
console.log(
|
||||
`Commented on PR #${payload.pull_request.number} about the accepted review comment.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Optional: Handle errors
|
||||
app.webhooks.onError(error => {
|
||||
console.error(`Error occurred in Github App's onError handler: ${error}`)
|
||||
if (error instanceof Error) {
|
||||
// Check if it's an AggregateError, common for signature issues
|
||||
if (error.name === "AggregateError" && Array.isArray((error as any).errors)) {
|
||||
console.error("AggregateError details (possible secret mismatch or multiple issues):")
|
||||
;(error as any).errors.forEach((subError: Error, i: number) => {
|
||||
console.error(` Sub-error ${i + 1}: ${subError.message}`)
|
||||
})
|
||||
} else if (error.message.includes("content length")) {
|
||||
console.error(
|
||||
"Request headers from error.event:",
|
||||
JSON.stringify(eventRequest.headers, null, 2),
|
||||
"Content length mismatch detected by Octokit. Payload may be truncated or header incorrect.",
|
||||
)
|
||||
const eventRequest = (error as any).event?.request
|
||||
if (eventRequest && eventRequest.headers) {
|
||||
console.error(
|
||||
"Request headers from error.event:",
|
||||
JSON.stringify(eventRequest.headers, null, 2),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Log the full error structure for better debugging
|
||||
console.error(
|
||||
"Full error object (onError):",
|
||||
JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
|
||||
)
|
||||
} else {
|
||||
console.error("Full error (onError, non-Error instance):", error)
|
||||
}
|
||||
// Log the full error structure for better debugging
|
||||
console.error(
|
||||
"Full error object (onError):",
|
||||
JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
|
||||
)
|
||||
} else {
|
||||
console.error("Full error (onError, non-Error instance):", error)
|
||||
}
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
|
||||
githubApp.webhooks.on("installation_repositories", async ({ payload }) => {
|
||||
console.log(`Received a installation_repositories event: ${JSON.stringify(payload)}`)
|
||||
const { repositories_added, installation, sender } = payload
|
||||
// Check if required fields are missing
|
||||
if (!repositories_added || !installation?.id) {
|
||||
console.log(
|
||||
`Missing repositories_added or installation.id. Full payload: ${JSON.stringify(payload, null, 2)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const account = installation.account
|
||||
let accountLogin: string | undefined
|
||||
app.webhooks.on("installation_repositories", async ({ payload }) => {
|
||||
console.log(`Received a installation_repositories event: ${JSON.stringify(payload)}`)
|
||||
const { repositories_added, installation, sender } = payload
|
||||
// Check if required fields are missing
|
||||
if (!repositories_added || !installation?.id) {
|
||||
console.log(
|
||||
`Missing repositories_added or installation.id. Full payload: ${JSON.stringify(payload, null, 2)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const account = installation.account
|
||||
let accountLogin: string | undefined
|
||||
|
||||
// Check if the account is a user or an organization
|
||||
if ("login" in account) {
|
||||
// It's a user account, use `login`
|
||||
accountLogin = account.login
|
||||
} else if ("slug" in account) {
|
||||
// It's an organization account, use `slug`
|
||||
accountLogin = account.slug
|
||||
}
|
||||
// Check if the account is a user or an organization
|
||||
if ("login" in account) {
|
||||
// It's a user account, use `login`
|
||||
accountLogin = account.login
|
||||
} else if ("slug" in account) {
|
||||
// It's an organization account, use `slug`
|
||||
accountLogin = account.slug
|
||||
}
|
||||
|
||||
let accountType: string | undefined
|
||||
if ("type" in account) {
|
||||
accountType = account.type
|
||||
} else {
|
||||
accountType = "Organization" // fallback assumption
|
||||
}
|
||||
let accountType: string | undefined
|
||||
if ("type" in account) {
|
||||
accountType = account.type
|
||||
} else {
|
||||
accountType = "Organization" // fallback assumption
|
||||
}
|
||||
|
||||
if (!accountLogin) {
|
||||
console.error("Error: Account login or slug not found")
|
||||
return
|
||||
}
|
||||
if (!accountLogin) {
|
||||
console.error("Error: Account login or slug not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the installation exists, if not, create it
|
||||
const installationExists = await getAppInstallationByInstalltionId(installation.id)
|
||||
// Check if the installation exists, if not, create it
|
||||
const installationExists = await getAppInstallationByInstalltionId(installation.id)
|
||||
|
||||
if (!installationExists) {
|
||||
// Create the installation if it doesn't exist
|
||||
await createAppInstallation({
|
||||
installation_id: installation.id,
|
||||
account_id: installation.account.id,
|
||||
account_login: accountLogin,
|
||||
account_type: accountType,
|
||||
})
|
||||
console.log(`Installation created for ID: ${installation.id}`)
|
||||
}
|
||||
|
||||
// Process each repository in the list of added repositories
|
||||
for (const repo of repositories_added) {
|
||||
try {
|
||||
// Upsert logic for repository creation or update
|
||||
const savedRepo = await upsertRepository({
|
||||
github_repo_id: String(repo.id),
|
||||
if (!installationExists) {
|
||||
// Create the installation if it doesn't exist
|
||||
await createAppInstallation({
|
||||
installation_id: installation.id,
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
is_private: repo.private,
|
||||
account_id: installation.account.id,
|
||||
account_login: accountLogin,
|
||||
account_type: accountType,
|
||||
})
|
||||
// Get the GitHub user ID of the sender
|
||||
const githubUserId = sender?.id
|
||||
|
||||
if (githubUserId) {
|
||||
console.log(`GitHub User ID: ${githubUserId} triggered the event`)
|
||||
// Fetch the user's role using the helper
|
||||
// Use octokit from getInstallationOctokit for this installation
|
||||
const installationOctokit = await githubApp.getInstallationOctokit(installation.id)
|
||||
const userRole = await getUserRole({
|
||||
octokit: installationOctokit,
|
||||
owner: accountLogin,
|
||||
repo: repo.name,
|
||||
username: sender.login,
|
||||
isOrg: accountType === "Organization",
|
||||
})
|
||||
console.log(`Fetched user role: ${userRole}`)
|
||||
const user = await createOrUpdateUser(
|
||||
`github|${githubUserId}`,
|
||||
sender.login,
|
||||
sender.email ?? null,
|
||||
sender.name ?? null,
|
||||
)
|
||||
await createRepositoryMember({
|
||||
repository_id: savedRepo.id,
|
||||
user_id: user.user_id,
|
||||
role: userRole,
|
||||
})
|
||||
console.log(`Repository upserted: ${repo.full_name}`)
|
||||
} else {
|
||||
console.error("GitHub User ID not found in sender.")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to add/reactivate repository ${repo.full_name}:`, error)
|
||||
Sentry.captureException(error)
|
||||
console.log(`Installation created for ID: ${installation.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Process each repository in the list of added repositories
|
||||
for (const repo of repositories_added) {
|
||||
try {
|
||||
// Upsert logic for repository creation or update
|
||||
const savedRepo = await upsertRepository({
|
||||
github_repo_id: String(repo.id),
|
||||
installation_id: installation.id,
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
is_private: repo.private,
|
||||
})
|
||||
// Get the GitHub user ID of the sender
|
||||
const githubUserId = sender?.id
|
||||
|
||||
if (githubUserId) {
|
||||
console.log(`GitHub User ID: ${githubUserId} triggered the event`)
|
||||
// Fetch the user's role using the helper
|
||||
// Use octokit from getInstallationOctokit for this installation
|
||||
const installationOctokit = await app.getInstallationOctokit(installation.id)
|
||||
const userRole = await getUserRole({
|
||||
octokit: installationOctokit,
|
||||
owner: accountLogin,
|
||||
repo: repo.name,
|
||||
username: sender.login,
|
||||
isOrg: accountType === "Organization",
|
||||
})
|
||||
console.log(`Fetched user role: ${userRole}`)
|
||||
const user = await createOrUpdateUser(
|
||||
`github|${githubUserId}`,
|
||||
sender.login,
|
||||
sender.email ?? null,
|
||||
sender.name ?? null,
|
||||
)
|
||||
await createRepositoryMember({
|
||||
repository_id: savedRepo.id,
|
||||
user_id: user.user_id,
|
||||
role: userRole,
|
||||
})
|
||||
console.log(`Repository upserted: ${repo.full_name}`)
|
||||
} else {
|
||||
console.error("GitHub User ID not found in sender.")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to add/reactivate repository ${repo.full_name}:`, error)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
})()
|
||||
|
||||
export const ghAppPathPrefix: string = "/cfapi/github"
|
||||
|
||||
|
|
@ -426,7 +472,7 @@ export const ghAppMiddleware = createNodeMiddleware(githubApp.webhooks, {
|
|||
path: "/cfapi/github/webhooks",
|
||||
})
|
||||
|
||||
const deleteBranchIfExists = async (installationOctokit, payload, branchName) => {
|
||||
const deleteBranchIfExists = async (installationOctokit: any, payload: any, branchName: string) => {
|
||||
try {
|
||||
console.log(`Deleting the branch associated with the closed PR...`)
|
||||
// Check if the branch exists by querying the reference
|
||||
|
|
@ -446,7 +492,7 @@ const deleteBranchIfExists = async (installationOctokit, payload, branchName) =>
|
|||
ref: `heads/${branchName}`,
|
||||
})
|
||||
console.log(`Branch '${branchName}' has been deleted.`)
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// If the branch doesn't exist or other errors occur, catch the error
|
||||
if (error.status === 404) {
|
||||
console.log("Branch does not exist!")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,33 @@
|
|||
import { type App, type Octokit } from "octokit"
|
||||
import * as Sentry from "@sentry/node"
|
||||
|
||||
// Dependencies interface for easier testing
|
||||
export interface GithubUtilsDependencies {
|
||||
console: {
|
||||
error: typeof console.error
|
||||
}
|
||||
}
|
||||
|
||||
// Default dependencies
|
||||
let dependencies: GithubUtilsDependencies = {
|
||||
console: {
|
||||
error: console.error,
|
||||
},
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setGithubUtilsDependencies(deps: Partial<GithubUtilsDependencies>) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetGithubUtilsDependencies() {
|
||||
dependencies = {
|
||||
console: {
|
||||
error: console.error,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if the user is a collaborator on the repository
|
||||
export async function isUserCollaborator(
|
||||
installationOctokit: Octokit,
|
||||
|
|
@ -38,7 +65,7 @@ export async function getInstallationOctokitByOwner(
|
|||
const installationId = repoInstallation.data.id
|
||||
return await githubApp.getInstallationOctokit(installationId)
|
||||
} catch (error: any) {
|
||||
console.error(`Error getting installation Octokit for ${owner}/${repo}:`, error)
|
||||
dependencies.console.error(`Error getting installation Octokit for ${owner}/${repo}:`, error)
|
||||
if (error.status === 404) {
|
||||
return Error(`GitHub App is not installed on the repository ${owner}/${repo}`)
|
||||
} else {
|
||||
|
|
@ -48,7 +75,7 @@ export async function getInstallationOctokitByOwner(
|
|||
}
|
||||
|
||||
// Ensures that a label exists on a repository, creating it if it doesn't
|
||||
async function ensureLabelExists(
|
||||
export async function ensureLabelExists(
|
||||
installationOctokit: Octokit,
|
||||
owner: string,
|
||||
repo: string,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,60 @@
|
|||
import { WebClient, ChatPostMessageArguments } from "@slack/web-api"
|
||||
|
||||
const options = {}
|
||||
const web = new WebClient(process.env.SLACK_TOKEN, options)
|
||||
|
||||
const SLACK_TOKEN = process.env.SLACK_TOKEN
|
||||
const SLACK_CHANNEL_ID = process.env.SLACK_CHANNEL_ID
|
||||
|
||||
if (!SLACK_TOKEN) {
|
||||
throw new Error("Missing SLACK_TOKEN")
|
||||
// Dependencies interface for easier testing
|
||||
export interface SendSlackMessageDependencies {
|
||||
WebClient: typeof WebClient
|
||||
getSlackToken: () => string | undefined
|
||||
getSlackChannelId: () => string | undefined
|
||||
console: typeof console
|
||||
}
|
||||
|
||||
if (!SLACK_CHANNEL_ID) {
|
||||
throw new Error("Missing SLACK_CHANNEL_ID")
|
||||
// Default dependencies
|
||||
let dependencies: SendSlackMessageDependencies = {
|
||||
WebClient,
|
||||
getSlackToken: () => process.env.SLACK_TOKEN,
|
||||
getSlackChannelId: () => process.env.SLACK_CHANNEL_ID,
|
||||
console,
|
||||
}
|
||||
|
||||
// For testing - allow dependency injection
|
||||
export function setSendSlackMessageDependencies(deps: Partial<SendSlackMessageDependencies>) {
|
||||
dependencies = { ...dependencies, ...deps }
|
||||
}
|
||||
|
||||
export function resetSendSlackMessageDependencies() {
|
||||
dependencies = {
|
||||
WebClient,
|
||||
getSlackToken: () => process.env.SLACK_TOKEN,
|
||||
getSlackChannelId: () => process.env.SLACK_CHANNEL_ID,
|
||||
console,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize web client
|
||||
let web: WebClient | null = null
|
||||
|
||||
export function initializeWebClient() {
|
||||
const SLACK_TOKEN = dependencies.getSlackToken()
|
||||
const SLACK_CHANNEL_ID = dependencies.getSlackChannelId()
|
||||
|
||||
if (!SLACK_TOKEN) {
|
||||
throw new Error("Missing SLACK_TOKEN")
|
||||
}
|
||||
|
||||
if (!SLACK_CHANNEL_ID) {
|
||||
throw new Error("Missing SLACK_CHANNEL_ID")
|
||||
}
|
||||
|
||||
if (!web) {
|
||||
web = new dependencies.WebClient(SLACK_TOKEN, {})
|
||||
}
|
||||
|
||||
return web
|
||||
}
|
||||
|
||||
// For testing - allow resetting the web client
|
||||
export function resetWebClient() {
|
||||
web = null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -22,11 +65,17 @@ if (!SLACK_CHANNEL_ID) {
|
|||
* @param {boolean} returnData - Whether to return the full Slack API response
|
||||
* @returns {Promise<boolean|object>} - True or API response
|
||||
*/
|
||||
export const sendSlackMessage = async (message, channel = null, returnData = false) => {
|
||||
export const sendSlackMessage = async (
|
||||
message: any,
|
||||
channel: string | null = null,
|
||||
returnData: boolean = false,
|
||||
): Promise<boolean | object> => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const channelId = channel || SLACK_CHANNEL_ID
|
||||
|
||||
try {
|
||||
const webClient = initializeWebClient()
|
||||
const SLACK_CHANNEL_ID = dependencies.getSlackChannelId()
|
||||
const channelId = channel || SLACK_CHANNEL_ID
|
||||
|
||||
// Configure the message payload depending on the input type
|
||||
let payload: ChatPostMessageArguments
|
||||
|
||||
|
|
@ -43,14 +92,14 @@ export const sendSlackMessage = async (message, channel = null, returnData = fal
|
|||
blocks: message.blocks,
|
||||
}
|
||||
} else {
|
||||
console.warn("Object passed to sendSlackMessage without blocks property")
|
||||
dependencies.console.warn("Object passed to sendSlackMessage without blocks property")
|
||||
payload = {
|
||||
channel: channelId,
|
||||
text: JSON.stringify(message),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error("Invalid message type", typeof message)
|
||||
dependencies.console.error("Invalid message type", typeof message)
|
||||
payload = {
|
||||
channel: channelId,
|
||||
text: "Invalid message",
|
||||
|
|
@ -59,10 +108,10 @@ export const sendSlackMessage = async (message, channel = null, returnData = fal
|
|||
|
||||
// console.log("Sending payload to Slack:", JSON.stringify(payload, null, 2));
|
||||
|
||||
const resp = await web.chat.postMessage(payload)
|
||||
const resp = await webClient.chat.postMessage(payload)
|
||||
return resolve(returnData ? resp : true)
|
||||
} catch (error) {
|
||||
console.error("Error sending Slack message:", error)
|
||||
dependencies.console.error("Error sending Slack message:", error)
|
||||
return resolve(returnData ? { error } : true)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
572
js/cf-api/github/tests/create-pr-from-diffcontents.unit.test.ts
Normal file
572
js/cf-api/github/tests/create-pr-from-diffcontents.unit.test.ts
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"
|
||||
import {
|
||||
createNewBranchFromDiffContents,
|
||||
createNewPullRequest,
|
||||
assignReviewer,
|
||||
createStandalonePullRequest,
|
||||
createDependentPullRequest,
|
||||
setCreatePrFromDiffContentsDependencies,
|
||||
resetCreatePrFromDiffContentsDependencies,
|
||||
type PrCommentFields,
|
||||
} from "../create-pr-from-diffcontents"
|
||||
import { type FileDiffContent } from "@codeflash-ai/code-suggester/build/src/types.js"
|
||||
|
||||
describe("Create PR from Diff Contents", () => {
|
||||
let mockOctokit: any
|
||||
let mockDependencies: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup mock octokit
|
||||
mockOctokit = {
|
||||
rest: {
|
||||
git: {
|
||||
createRef: jest.fn(),
|
||||
getRef: jest.fn(),
|
||||
createBlob: jest.fn(),
|
||||
getCommit: jest.fn(),
|
||||
createTree: jest.fn(),
|
||||
createCommit: jest.fn(),
|
||||
updateRef: jest.fn(),
|
||||
},
|
||||
pulls: {
|
||||
create: jest.fn(),
|
||||
requestReviewers: jest.fn(),
|
||||
},
|
||||
issues: {
|
||||
createComment: jest.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies = {
|
||||
addLabelToPullRequest: jest.fn(),
|
||||
buildBenchmarkInfo: jest.fn(),
|
||||
buildDependentPrTitle: jest.fn(),
|
||||
buildPrTitle: jest.fn(),
|
||||
buildResultFooter: jest.fn(),
|
||||
buildResultHeader: jest.fn(),
|
||||
buildResultDetails: jest.fn(),
|
||||
buildResultTestReport: jest.fn(),
|
||||
originalPRComment: jest.fn(),
|
||||
}
|
||||
|
||||
setCreatePrFromDiffContentsDependencies(mockDependencies)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetCreatePrFromDiffContentsDependencies()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("createNewBranchFromDiffContents", () => {
|
||||
const diffContentsMap = new Map<string, FileDiffContent>([
|
||||
[
|
||||
"file1.js",
|
||||
{
|
||||
oldContent: "console.log('old code')",
|
||||
newContent: "console.log('optimized code')",
|
||||
},
|
||||
],
|
||||
[
|
||||
"file2.js",
|
||||
{
|
||||
oldContent: "function old() { return false; }",
|
||||
newContent: "function optimized() { return true; }",
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
// Helper function to setup successful API responses
|
||||
const setupSuccessfulMocks = () => {
|
||||
mockOctokit.rest.git.getRef
|
||||
.mockResolvedValueOnce({ data: { object: { sha: "base-sha" } } })
|
||||
.mockResolvedValueOnce({ data: { object: { sha: "branch-sha" } } })
|
||||
mockOctokit.rest.git.createRef.mockResolvedValue({})
|
||||
mockOctokit.rest.git.createBlob
|
||||
.mockResolvedValueOnce({ data: { sha: "blob1-sha" } })
|
||||
.mockResolvedValueOnce({ data: { sha: "blob2-sha" } })
|
||||
mockOctokit.rest.git.getCommit.mockResolvedValue({
|
||||
data: { tree: { sha: "tree-sha" } },
|
||||
})
|
||||
mockOctokit.rest.git.createTree.mockResolvedValue({
|
||||
data: { sha: "new-tree-sha" },
|
||||
})
|
||||
mockOctokit.rest.git.createCommit.mockResolvedValue({
|
||||
data: { sha: "new-commit-sha" },
|
||||
})
|
||||
mockOctokit.rest.git.updateRef.mockResolvedValue({
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
|
||||
it("should successfully create a new branch with diff contents", async () => {
|
||||
setupSuccessfulMocks()
|
||||
|
||||
const result = await createNewBranchFromDiffContents(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"feature-branch",
|
||||
"main",
|
||||
diffContentsMap,
|
||||
"Optimize performance",
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
|
||||
// Verify branch creation
|
||||
expect(mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
ref: "refs/heads/feature-branch",
|
||||
sha: "base-sha",
|
||||
})
|
||||
|
||||
// Verify blob creation for each file
|
||||
expect(mockOctokit.rest.git.createBlob).toHaveBeenCalledTimes(2)
|
||||
expect(mockOctokit.rest.git.createBlob).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
content: "console.log('optimized code')",
|
||||
encoding: "utf-8",
|
||||
})
|
||||
expect(mockOctokit.rest.git.createBlob).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
content: "function optimized() { return true; }",
|
||||
encoding: "utf-8",
|
||||
})
|
||||
|
||||
// Verify tree creation
|
||||
expect(mockOctokit.rest.git.createTree).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
base_tree: "tree-sha",
|
||||
tree: [
|
||||
{
|
||||
path: "file1.js",
|
||||
mode: "100644",
|
||||
type: "blob",
|
||||
sha: "blob1-sha",
|
||||
},
|
||||
{
|
||||
path: "file2.js",
|
||||
mode: "100644",
|
||||
type: "blob",
|
||||
sha: "blob2-sha",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Verify commit creation
|
||||
expect(mockOctokit.rest.git.createCommit).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
message: "Optimize performance",
|
||||
tree: "new-tree-sha",
|
||||
parents: ["branch-sha"],
|
||||
})
|
||||
|
||||
// Verify branch update
|
||||
expect(mockOctokit.rest.git.updateRef).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
ref: "heads/feature-branch",
|
||||
sha: "new-commit-sha",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle empty diff contents map", async () => {
|
||||
setupSuccessfulMocks()
|
||||
|
||||
const emptyMap = new Map<string, FileDiffContent>()
|
||||
|
||||
const result = await createNewBranchFromDiffContents(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"feature-branch",
|
||||
"main",
|
||||
emptyMap,
|
||||
"Empty commit",
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockOctokit.rest.git.createBlob).not.toHaveBeenCalled()
|
||||
expect(mockOctokit.rest.git.createTree).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
base_tree: "tree-sha",
|
||||
tree: [],
|
||||
})
|
||||
})
|
||||
|
||||
it("should return false when GitHub API fails", async () => {
|
||||
mockOctokit.rest.git.createRef.mockRejectedValue(new Error("API Error"))
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
const result = await createNewBranchFromDiffContents(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"feature-branch",
|
||||
"main",
|
||||
diffContentsMap,
|
||||
"Optimize performance",
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error creating branch from diff contents:",
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should return false when updateRef returns non-200 status", async () => {
|
||||
// Setup most mocks to succeed
|
||||
mockOctokit.rest.git.getRef
|
||||
.mockResolvedValueOnce({ data: { object: { sha: "base-sha" } } })
|
||||
.mockResolvedValueOnce({ data: { object: { sha: "branch-sha" } } })
|
||||
mockOctokit.rest.git.createRef.mockResolvedValue({})
|
||||
mockOctokit.rest.git.createBlob
|
||||
.mockResolvedValueOnce({ data: { sha: "blob1-sha" } })
|
||||
.mockResolvedValueOnce({ data: { sha: "blob2-sha" } })
|
||||
mockOctokit.rest.git.getCommit.mockResolvedValue({
|
||||
data: { tree: { sha: "tree-sha" } },
|
||||
})
|
||||
mockOctokit.rest.git.createTree.mockResolvedValue({
|
||||
data: { sha: "new-tree-sha" },
|
||||
})
|
||||
mockOctokit.rest.git.createCommit.mockResolvedValue({
|
||||
data: { sha: "new-commit-sha" },
|
||||
})
|
||||
|
||||
// Only updateRef fails with non-200 status
|
||||
mockOctokit.rest.git.updateRef.mockResolvedValue({
|
||||
status: 400,
|
||||
})
|
||||
|
||||
const result = await createNewBranchFromDiffContents(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"feature-branch",
|
||||
"main",
|
||||
diffContentsMap,
|
||||
"Optimize performance",
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createNewPullRequest", () => {
|
||||
it("should create a new pull request successfully", async () => {
|
||||
const mockPrResponse = {
|
||||
data: {
|
||||
id: 123,
|
||||
number: 456,
|
||||
html_url: "https://github.com/test-owner/test-repo/pull/456",
|
||||
title: "Test PR",
|
||||
},
|
||||
}
|
||||
|
||||
mockOctokit.rest.pulls.create.mockResolvedValue(mockPrResponse)
|
||||
|
||||
const result = await createNewPullRequest(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"Test PR Title",
|
||||
"Test PR Body",
|
||||
"feature-branch",
|
||||
"main",
|
||||
)
|
||||
|
||||
expect(result).toEqual(mockPrResponse)
|
||||
expect(mockOctokit.rest.pulls.create).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
title: "Test PR Title",
|
||||
head: "feature-branch",
|
||||
base: "main",
|
||||
body: "Test PR Body",
|
||||
})
|
||||
})
|
||||
|
||||
it("should propagate GitHub API errors", async () => {
|
||||
const error = new Error("PR creation failed")
|
||||
mockOctokit.rest.pulls.create.mockRejectedValue(error)
|
||||
|
||||
await expect(
|
||||
createNewPullRequest(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"Test PR Title",
|
||||
"Test PR Body",
|
||||
"feature-branch",
|
||||
"main",
|
||||
),
|
||||
).rejects.toThrow("PR creation failed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("assignReviewer", () => {
|
||||
it("should assign a reviewer to a pull request", async () => {
|
||||
await assignReviewer(mockOctokit, "test-owner", "test-repo", 456, "reviewer-username")
|
||||
|
||||
expect(mockOctokit.rest.pulls.requestReviewers).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
pull_number: 456,
|
||||
reviewers: ["reviewer-username"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle GitHub API errors when assigning reviewer", async () => {
|
||||
const error = new Error("Review request failed")
|
||||
mockOctokit.rest.pulls.requestReviewers.mockRejectedValue(error)
|
||||
|
||||
await expect(
|
||||
assignReviewer(mockOctokit, "test-owner", "test-repo", 456, "reviewer-username"),
|
||||
).rejects.toThrow("Review request failed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createStandalonePullRequest", () => {
|
||||
let mockPrCommentFields: PrCommentFields
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrCommentFields = {
|
||||
best_runtime: "10ms",
|
||||
original_runtime: "20ms",
|
||||
function_name: "testFunction",
|
||||
file_path: "test.js",
|
||||
speedup_x: "2.0",
|
||||
speedup_pct: "50",
|
||||
loop_count: "1000",
|
||||
optimization_explanation: "Optimized loop",
|
||||
report_table: {},
|
||||
benchmark_details: [],
|
||||
}
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies.buildResultHeader.mockReturnValue("## Header")
|
||||
mockDependencies.buildResultDetails.mockReturnValue("Details section")
|
||||
mockDependencies.buildResultTestReport.mockReturnValue("Test report")
|
||||
mockDependencies.buildResultFooter.mockReturnValue("Footer")
|
||||
mockDependencies.buildPrTitle.mockReturnValue("Optimize testFunction")
|
||||
mockOctokit.rest.pulls.create.mockResolvedValue({
|
||||
data: { id: 123, number: 456 },
|
||||
})
|
||||
})
|
||||
|
||||
it("should create standalone PR without benchmark info", async () => {
|
||||
const result = await createStandalonePullRequest(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"feature-branch",
|
||||
"main",
|
||||
mockPrCommentFields,
|
||||
"existing tests",
|
||||
"generated tests",
|
||||
"coverage message",
|
||||
)
|
||||
|
||||
expect(mockDependencies.buildResultHeader).toHaveBeenCalledWith(mockPrCommentFields)
|
||||
expect(mockDependencies.buildResultDetails).toHaveBeenCalledWith(mockPrCommentFields)
|
||||
expect(mockDependencies.buildResultTestReport).toHaveBeenCalledWith(
|
||||
mockPrCommentFields,
|
||||
"existing tests",
|
||||
"generated tests",
|
||||
"coverage message",
|
||||
)
|
||||
expect(mockDependencies.buildResultFooter).toHaveBeenCalledWith("feature-branch")
|
||||
expect(mockDependencies.buildPrTitle).toHaveBeenCalledWith("testFunction", "50", "2.0")
|
||||
|
||||
expect(mockOctokit.rest.pulls.create).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
title: "Optimize testFunction",
|
||||
head: "feature-branch",
|
||||
base: "main",
|
||||
body: "## Header\nDetails section\nTest report\nFooter",
|
||||
})
|
||||
|
||||
expect(result.data.id).toBe(123)
|
||||
})
|
||||
|
||||
it("should create standalone PR with benchmark info", async () => {
|
||||
mockPrCommentFields.benchmark_details = [
|
||||
{
|
||||
benchmark_name: "test_benchmark",
|
||||
test_function: "testFunction",
|
||||
original_timing: 20,
|
||||
expected_new_timing: 10,
|
||||
speedup_percent: 50,
|
||||
},
|
||||
]
|
||||
mockDependencies.buildBenchmarkInfo.mockReturnValue("Benchmark info")
|
||||
|
||||
await createStandalonePullRequest(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"feature-branch",
|
||||
"main",
|
||||
mockPrCommentFields,
|
||||
"existing tests",
|
||||
"generated tests",
|
||||
"coverage message",
|
||||
)
|
||||
|
||||
expect(mockDependencies.buildBenchmarkInfo).toHaveBeenCalledWith(mockPrCommentFields)
|
||||
expect(mockOctokit.rest.pulls.create).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
title: "Optimize testFunction",
|
||||
head: "feature-branch",
|
||||
base: "main",
|
||||
body: "## Header\nBenchmark info\nDetails section\nTest report\nFooter",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createDependentPullRequest", () => {
|
||||
let mockPrCommentFields: PrCommentFields
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrCommentFields = {
|
||||
best_runtime: "10ms",
|
||||
original_runtime: "20ms",
|
||||
function_name: "testFunction",
|
||||
file_path: "test.js",
|
||||
speedup_x: "2.0",
|
||||
speedup_pct: "50",
|
||||
loop_count: "1000",
|
||||
optimization_explanation: "Optimized loop",
|
||||
report_table: {},
|
||||
benchmark_details: [],
|
||||
}
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies.buildResultHeader.mockReturnValue("## Header")
|
||||
mockDependencies.buildResultDetails.mockReturnValue("Details section")
|
||||
mockDependencies.buildResultTestReport.mockReturnValue("Test report")
|
||||
mockDependencies.buildResultFooter.mockReturnValue("Footer")
|
||||
mockDependencies.buildDependentPrTitle.mockReturnValue("Dependent PR: Optimize testFunction")
|
||||
mockDependencies.originalPRComment.mockReturnValue("Original PR comment")
|
||||
mockOctokit.rest.pulls.create.mockResolvedValue({
|
||||
data: { id: 123, number: 456 },
|
||||
})
|
||||
})
|
||||
|
||||
it("should create dependent PR and comment on original PR", async () => {
|
||||
const result = await createDependentPullRequest(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
789,
|
||||
"feature-branch",
|
||||
"pr-789-branch",
|
||||
mockPrCommentFields,
|
||||
"existing tests",
|
||||
"generated tests",
|
||||
"coverage message",
|
||||
)
|
||||
|
||||
expect(mockDependencies.buildDependentPrTitle).toHaveBeenCalledWith(
|
||||
"testFunction",
|
||||
"50",
|
||||
"2.0",
|
||||
789,
|
||||
"pr-789-branch",
|
||||
)
|
||||
|
||||
// Check PR creation
|
||||
expect(mockOctokit.rest.pulls.create).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
title: "Dependent PR: Optimize testFunction",
|
||||
head: "feature-branch",
|
||||
base: "pr-789-branch",
|
||||
body: expect.stringContaining(
|
||||
"## ⚡️ This pull request contains optimizations for PR #789",
|
||||
),
|
||||
})
|
||||
|
||||
// Check label addition
|
||||
expect(mockDependencies.addLabelToPullRequest).toHaveBeenCalledWith(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
456,
|
||||
)
|
||||
|
||||
// Check comment on original PR
|
||||
expect(mockOctokit.rest.issues.createComment).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
issue_number: 789,
|
||||
body: "Original PR comment",
|
||||
})
|
||||
|
||||
// Verify originalPRComment was called with correct parameters
|
||||
expect(mockDependencies.originalPRComment).toHaveBeenCalledWith(
|
||||
mockPrCommentFields,
|
||||
456,
|
||||
"pr-789-branch",
|
||||
)
|
||||
|
||||
expect(result.data.id).toBe(123)
|
||||
})
|
||||
|
||||
it("should handle GitHub API errors during PR creation", async () => {
|
||||
const error = new Error("PR creation failed")
|
||||
mockOctokit.rest.pulls.create.mockRejectedValue(error)
|
||||
|
||||
await expect(
|
||||
createDependentPullRequest(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
789,
|
||||
"feature-branch",
|
||||
"pr-789-branch",
|
||||
mockPrCommentFields,
|
||||
"existing tests",
|
||||
"generated tests",
|
||||
"coverage message",
|
||||
),
|
||||
).rejects.toThrow("PR creation failed")
|
||||
})
|
||||
|
||||
it("should handle GitHub API errors during comment creation", async () => {
|
||||
mockOctokit.rest.pulls.create.mockResolvedValue({
|
||||
data: { id: 123, number: 456 },
|
||||
})
|
||||
mockOctokit.rest.issues.createComment.mockRejectedValue(new Error("Comment creation failed"))
|
||||
|
||||
await expect(
|
||||
createDependentPullRequest(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
789,
|
||||
"feature-branch",
|
||||
"pr-789-branch",
|
||||
mockPrCommentFields,
|
||||
"existing tests",
|
||||
"generated tests",
|
||||
"coverage message",
|
||||
),
|
||||
).rejects.toThrow("Comment creation failed")
|
||||
})
|
||||
})
|
||||
})
|
||||
601
js/cf-api/github/tests/github-utils.unit.test.ts
Normal file
601
js/cf-api/github/tests/github-utils.unit.test.ts
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"
|
||||
import {
|
||||
isUserCollaborator,
|
||||
getInstallationOctokitByOwner,
|
||||
ensureLabelExists,
|
||||
addLabelToPullRequest,
|
||||
setGithubUtilsDependencies,
|
||||
resetGithubUtilsDependencies,
|
||||
} from "../github-utils"
|
||||
|
||||
describe("GitHub Utils", () => {
|
||||
let mockOctokit: any
|
||||
let mockGithubApp: any
|
||||
let mockDependencies: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup mock Octokit
|
||||
mockOctokit = {
|
||||
rest: {
|
||||
repos: {
|
||||
checkCollaborator: jest.fn(),
|
||||
},
|
||||
issues: {
|
||||
getLabel: jest.fn(),
|
||||
createLabel: jest.fn(),
|
||||
addLabels: jest.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Setup mock GitHub App
|
||||
mockGithubApp = {
|
||||
octokit: {
|
||||
rest: {
|
||||
apps: {
|
||||
getRepoInstallation: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
getInstallationOctokit: jest.fn(),
|
||||
}
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies = {
|
||||
console: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
setGithubUtilsDependencies(mockDependencies)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetGithubUtilsDependencies()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("isUserCollaborator", () => {
|
||||
it("should return true when user is a collaborator (status 204)", async () => {
|
||||
;(mockOctokit.rest.repos.checkCollaborator as jest.MockedFunction<any>).mockResolvedValue({
|
||||
status: 204,
|
||||
})
|
||||
|
||||
const result = await isUserCollaborator(mockOctokit, "test-owner", "test-repo", "test-user")
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockOctokit.rest.repos.checkCollaborator).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
username: "test-user",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return false when user is a collaborator but status is not 204", async () => {
|
||||
;(mockOctokit.rest.repos.checkCollaborator as jest.MockedFunction<any>).mockResolvedValue({
|
||||
status: 200,
|
||||
})
|
||||
|
||||
const result = await isUserCollaborator(mockOctokit, "test-owner", "test-repo", "test-user")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when user is not a collaborator (404 error)", async () => {
|
||||
const error = new Error("Not Found")
|
||||
;(error as any).status = 404
|
||||
;(mockOctokit.rest.repos.checkCollaborator as jest.MockedFunction<any>).mockRejectedValue(
|
||||
error,
|
||||
)
|
||||
|
||||
const result = await isUserCollaborator(mockOctokit, "test-owner", "test-repo", "test-user")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should throw error for non-404 API errors", async () => {
|
||||
const error = new Error("Internal Server Error")
|
||||
;(error as any).status = 500
|
||||
;(mockOctokit.rest.repos.checkCollaborator as jest.MockedFunction<any>).mockRejectedValue(
|
||||
error,
|
||||
)
|
||||
|
||||
await expect(
|
||||
isUserCollaborator(mockOctokit, "test-owner", "test-repo", "test-user"),
|
||||
).rejects.toThrow("Internal Server Error")
|
||||
})
|
||||
|
||||
it("should throw error for API errors without status", async () => {
|
||||
const error = new Error("Network Error")
|
||||
;(mockOctokit.rest.repos.checkCollaborator as jest.MockedFunction<any>).mockRejectedValue(
|
||||
error,
|
||||
)
|
||||
|
||||
await expect(
|
||||
isUserCollaborator(mockOctokit, "test-owner", "test-repo", "test-user"),
|
||||
).rejects.toThrow("Network Error")
|
||||
})
|
||||
|
||||
it("should handle empty username", async () => {
|
||||
;(mockOctokit.rest.repos.checkCollaborator as jest.MockedFunction<any>).mockResolvedValue({
|
||||
status: 204,
|
||||
})
|
||||
|
||||
const result = await isUserCollaborator(mockOctokit, "test-owner", "test-repo", "")
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockOctokit.rest.repos.checkCollaborator).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
username: "",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle special characters in username", async () => {
|
||||
;(mockOctokit.rest.repos.checkCollaborator as jest.MockedFunction<any>).mockResolvedValue({
|
||||
status: 204,
|
||||
})
|
||||
|
||||
const result = await isUserCollaborator(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"user-with-dashes_and_underscores",
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockOctokit.rest.repos.checkCollaborator).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
username: "user-with-dashes_and_underscores",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getInstallationOctokitByOwner", () => {
|
||||
it("should return Octokit instance when installation exists", async () => {
|
||||
const mockInstallationOctokit = {
|
||||
rest: {
|
||||
/* mock octokit */
|
||||
},
|
||||
}
|
||||
|
||||
;(
|
||||
mockGithubApp.octokit.rest.apps.getRepoInstallation as jest.MockedFunction<any>
|
||||
).mockResolvedValue({ data: { id: 12345 } })
|
||||
;(mockGithubApp.getInstallationOctokit as jest.MockedFunction<any>).mockResolvedValue(
|
||||
mockInstallationOctokit,
|
||||
)
|
||||
|
||||
const result = await getInstallationOctokitByOwner(mockGithubApp, "test-owner", "test-repo")
|
||||
|
||||
expect(result).toBe(mockInstallationOctokit)
|
||||
expect(mockGithubApp.octokit.rest.apps.getRepoInstallation).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
})
|
||||
expect(mockGithubApp.getInstallationOctokit).toHaveBeenCalledWith(12345)
|
||||
})
|
||||
|
||||
it("should return Error when GitHub App is not installed (404)", async () => {
|
||||
const error = new Error("Not Found")
|
||||
;(error as any).status = 404
|
||||
;(
|
||||
mockGithubApp.octokit.rest.apps.getRepoInstallation as jest.MockedFunction<any>
|
||||
).mockRejectedValue(error)
|
||||
|
||||
const result = await getInstallationOctokitByOwner(mockGithubApp, "test-owner", "test-repo")
|
||||
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
expect((result as Error).message).toBe(
|
||||
"GitHub App is not installed on the repository test-owner/test-repo",
|
||||
)
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error getting installation Octokit for test-owner/test-repo:",
|
||||
error,
|
||||
)
|
||||
})
|
||||
|
||||
it("should return Error for other API errors", async () => {
|
||||
const error = new Error("Internal Server Error")
|
||||
;(error as any).status = 500
|
||||
;(
|
||||
mockGithubApp.octokit.rest.apps.getRepoInstallation as jest.MockedFunction<any>
|
||||
).mockRejectedValue(error)
|
||||
|
||||
const result = await getInstallationOctokitByOwner(mockGithubApp, "test-owner", "test-repo")
|
||||
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
expect((result as Error).message).toBe(
|
||||
"Error checking GitHub App installation status for repo test-owner/test-repo",
|
||||
)
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error getting installation Octokit for test-owner/test-repo:",
|
||||
error,
|
||||
)
|
||||
})
|
||||
|
||||
it("should return Error when getInstallationOctokit fails", async () => {
|
||||
;(
|
||||
mockGithubApp.octokit.rest.apps.getRepoInstallation as jest.MockedFunction<any>
|
||||
).mockResolvedValue({ data: { id: 12345 } })
|
||||
|
||||
const installationError = new Error("Installation Octokit failed")
|
||||
;(mockGithubApp.getInstallationOctokit as jest.MockedFunction<any>).mockRejectedValue(
|
||||
installationError,
|
||||
)
|
||||
|
||||
const result = await getInstallationOctokitByOwner(mockGithubApp, "test-owner", "test-repo")
|
||||
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
expect((result as Error).message).toBe(
|
||||
"Error checking GitHub App installation status for repo test-owner/test-repo",
|
||||
)
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error getting installation Octokit for test-owner/test-repo:",
|
||||
installationError,
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle missing installation ID", async () => {
|
||||
;(
|
||||
mockGithubApp.octokit.rest.apps.getRepoInstallation as jest.MockedFunction<any>
|
||||
).mockResolvedValue({ data: {} }) // Missing id
|
||||
|
||||
const result = await getInstallationOctokitByOwner(mockGithubApp, "test-owner", "test-repo")
|
||||
|
||||
expect(mockGithubApp.getInstallationOctokit).toHaveBeenCalledWith(undefined)
|
||||
// The behavior depends on what getInstallationOctokit does with undefined
|
||||
})
|
||||
|
||||
it("should handle network errors without status", async () => {
|
||||
const error = new Error("Network timeout")
|
||||
;(
|
||||
mockGithubApp.octokit.rest.apps.getRepoInstallation as jest.MockedFunction<any>
|
||||
).mockRejectedValue(error)
|
||||
|
||||
const result = await getInstallationOctokitByOwner(mockGithubApp, "test-owner", "test-repo")
|
||||
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
expect((result as Error).message).toBe(
|
||||
"Error checking GitHub App installation status for repo test-owner/test-repo",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ensureLabelExists", () => {
|
||||
it("should not create label when it already exists", async () => {
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockResolvedValue({
|
||||
data: { name: "test-label" },
|
||||
})
|
||||
|
||||
await ensureLabelExists(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"test-label",
|
||||
"FF0000",
|
||||
"Test label description",
|
||||
)
|
||||
|
||||
expect(mockOctokit.rest.issues.getLabel).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
name: "test-label",
|
||||
})
|
||||
expect(mockOctokit.rest.issues.createLabel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should create label when it doesn't exist (404 error)", async () => {
|
||||
const error = new Error("Not Found")
|
||||
;(error as any).status = 404
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockRejectedValue(error)
|
||||
|
||||
await ensureLabelExists(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"test-label",
|
||||
"FF0000",
|
||||
"Test label description",
|
||||
)
|
||||
|
||||
expect(mockOctokit.rest.issues.getLabel).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
name: "test-label",
|
||||
})
|
||||
expect(mockOctokit.rest.issues.createLabel).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
name: "test-label",
|
||||
color: "FF0000",
|
||||
description: "Test label description",
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error for non-404 API errors", async () => {
|
||||
const error = new Error("Internal Server Error")
|
||||
;(error as any).status = 500
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockRejectedValue(error)
|
||||
|
||||
await expect(
|
||||
ensureLabelExists(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"test-label",
|
||||
"FF0000",
|
||||
"Test label description",
|
||||
),
|
||||
).rejects.toThrow("Internal Server Error")
|
||||
|
||||
expect(mockOctokit.rest.issues.createLabel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle label creation failure", async () => {
|
||||
const getError = new Error("Not Found")
|
||||
;(getError as any).status = 404
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockRejectedValue(getError)
|
||||
|
||||
const createError = new Error("Label creation failed")
|
||||
;(mockOctokit.rest.issues.createLabel as jest.MockedFunction<any>).mockRejectedValue(
|
||||
createError,
|
||||
)
|
||||
|
||||
await expect(
|
||||
ensureLabelExists(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"test-label",
|
||||
"FF0000",
|
||||
"Test label description",
|
||||
),
|
||||
).rejects.toThrow("Label creation failed")
|
||||
})
|
||||
|
||||
it("should handle special characters in label name", async () => {
|
||||
const error = new Error("Not Found")
|
||||
;(error as any).status = 404
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockRejectedValue(error)
|
||||
|
||||
await ensureLabelExists(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
"⚡️ special-label",
|
||||
"FFC043",
|
||||
"Label with emoji and special chars",
|
||||
)
|
||||
|
||||
expect(mockOctokit.rest.issues.createLabel).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
name: "⚡️ special-label",
|
||||
color: "FFC043",
|
||||
description: "Label with emoji and special chars",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle empty color and description", async () => {
|
||||
const error = new Error("Not Found")
|
||||
;(error as any).status = 404
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockRejectedValue(error)
|
||||
|
||||
await ensureLabelExists(mockOctokit, "test-owner", "test-repo", "test-label", "", "")
|
||||
|
||||
expect(mockOctokit.rest.issues.createLabel).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
name: "test-label",
|
||||
color: "",
|
||||
description: "",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("addLabelToPullRequest", () => {
|
||||
beforeEach(() => {
|
||||
// Mock ensureLabelExists to always succeed
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockResolvedValue({
|
||||
data: { name: "⚡️ codeflash" },
|
||||
})
|
||||
})
|
||||
|
||||
it("should add label to pull request with default parameters", async () => {
|
||||
await addLabelToPullRequest(mockOctokit, "test-owner", "test-repo", 123)
|
||||
|
||||
expect(mockOctokit.rest.issues.getLabel).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
name: "⚡️ codeflash",
|
||||
})
|
||||
expect(mockOctokit.rest.issues.addLabels).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
issue_number: 123,
|
||||
labels: ["⚡️ codeflash"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should add custom label to pull request", async () => {
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockResolvedValue({
|
||||
data: { name: "custom-label" },
|
||||
})
|
||||
|
||||
await addLabelToPullRequest(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
456,
|
||||
"custom-label",
|
||||
"00FF00",
|
||||
"Custom label description",
|
||||
)
|
||||
|
||||
expect(mockOctokit.rest.issues.getLabel).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
name: "custom-label",
|
||||
})
|
||||
expect(mockOctokit.rest.issues.addLabels).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
issue_number: 456,
|
||||
labels: ["custom-label"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should create label if it doesn't exist and then add it", async () => {
|
||||
const error = new Error("Not Found")
|
||||
;(error as any).status = 404
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockRejectedValue(error)
|
||||
|
||||
await addLabelToPullRequest(
|
||||
mockOctokit,
|
||||
"test-owner",
|
||||
"test-repo",
|
||||
789,
|
||||
"new-label",
|
||||
"0000FF",
|
||||
"New label description",
|
||||
)
|
||||
|
||||
expect(mockOctokit.rest.issues.createLabel).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
name: "new-label",
|
||||
color: "0000FF",
|
||||
description: "New label description",
|
||||
})
|
||||
expect(mockOctokit.rest.issues.addLabels).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
issue_number: 789,
|
||||
labels: ["new-label"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle addLabels API failure", async () => {
|
||||
const error = new Error("Failed to add labels")
|
||||
;(mockOctokit.rest.issues.addLabels as jest.MockedFunction<any>).mockRejectedValue(error)
|
||||
|
||||
await expect(
|
||||
addLabelToPullRequest(mockOctokit, "test-owner", "test-repo", 123),
|
||||
).rejects.toThrow("Failed to add labels")
|
||||
})
|
||||
|
||||
it("should handle ensureLabelExists failure", async () => {
|
||||
const error = new Error("Label operation failed")
|
||||
;(error as any).status = 500
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockRejectedValue(error)
|
||||
|
||||
await expect(
|
||||
addLabelToPullRequest(mockOctokit, "test-owner", "test-repo", 123),
|
||||
).rejects.toThrow("Label operation failed")
|
||||
|
||||
expect(mockOctokit.rest.issues.addLabels).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle zero pull request number", async () => {
|
||||
await addLabelToPullRequest(mockOctokit, "test-owner", "test-repo", 0)
|
||||
|
||||
expect(mockOctokit.rest.issues.addLabels).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
issue_number: 0,
|
||||
labels: ["⚡️ codeflash"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle negative pull request number", async () => {
|
||||
await addLabelToPullRequest(mockOctokit, "test-owner", "test-repo", -1)
|
||||
|
||||
expect(mockOctokit.rest.issues.addLabels).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
issue_number: -1,
|
||||
labels: ["⚡️ codeflash"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle very long label name", async () => {
|
||||
const longLabelName = "a".repeat(100) // Very long label name
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockResolvedValue({
|
||||
data: { name: longLabelName },
|
||||
})
|
||||
|
||||
await addLabelToPullRequest(mockOctokit, "test-owner", "test-repo", 123, longLabelName)
|
||||
|
||||
expect(mockOctokit.rest.issues.addLabels).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
issue_number: 123,
|
||||
labels: [longLabelName],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("integration scenarios", () => {
|
||||
it("should handle complete workflow for new repository", async () => {
|
||||
// Simulate adding label to a PR in a new repository where the label doesn't exist
|
||||
const getError = new Error("Not Found")
|
||||
;(getError as any).status = 404
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockRejectedValue(getError)
|
||||
|
||||
await addLabelToPullRequest(
|
||||
mockOctokit,
|
||||
"new-owner",
|
||||
"new-repo",
|
||||
1,
|
||||
"🚀 optimization",
|
||||
"FF6B6B",
|
||||
"Performance optimization",
|
||||
)
|
||||
|
||||
// Verify label creation
|
||||
expect(mockOctokit.rest.issues.createLabel).toHaveBeenCalledWith({
|
||||
owner: "new-owner",
|
||||
repo: "new-repo",
|
||||
name: "🚀 optimization",
|
||||
color: "FF6B6B",
|
||||
description: "Performance optimization",
|
||||
})
|
||||
|
||||
// Verify label addition
|
||||
expect(mockOctokit.rest.issues.addLabels).toHaveBeenCalledWith({
|
||||
owner: "new-owner",
|
||||
repo: "new-repo",
|
||||
issue_number: 1,
|
||||
labels: ["🚀 optimization"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle repository with existing labels", async () => {
|
||||
// Simulate adding label to a PR where the label already exists
|
||||
;(mockOctokit.rest.issues.getLabel as jest.MockedFunction<any>).mockResolvedValue({
|
||||
data: { name: "existing-label" },
|
||||
})
|
||||
|
||||
await addLabelToPullRequest(
|
||||
mockOctokit,
|
||||
"existing-owner",
|
||||
"existing-repo",
|
||||
42,
|
||||
"existing-label",
|
||||
)
|
||||
|
||||
// Verify no label creation
|
||||
expect(mockOctokit.rest.issues.createLabel).not.toHaveBeenCalled()
|
||||
|
||||
// Verify label addition
|
||||
expect(mockOctokit.rest.issues.addLabels).toHaveBeenCalledWith({
|
||||
owner: "existing-owner",
|
||||
repo: "existing-repo",
|
||||
issue_number: 42,
|
||||
labels: ["existing-label"],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
592
js/cf-api/github/tests/slack_util.unit.test.ts
Normal file
592
js/cf-api/github/tests/slack_util.unit.test.ts
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"
|
||||
import {
|
||||
sendSlackMessage,
|
||||
setSendSlackMessageDependencies,
|
||||
resetSendSlackMessageDependencies,
|
||||
initializeWebClient,
|
||||
resetWebClient,
|
||||
} from "../slack_util"
|
||||
|
||||
describe("slack-message", () => {
|
||||
let mockDependencies: any
|
||||
let mockWebClient: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset web client before each test
|
||||
resetWebClient()
|
||||
|
||||
// Setup mock web client
|
||||
mockWebClient = {
|
||||
chat: {
|
||||
postMessage: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
// Setup mock dependencies
|
||||
mockDependencies = {
|
||||
WebClient: jest.fn().mockReturnValue(mockWebClient),
|
||||
getSlackToken: jest.fn(),
|
||||
getSlackChannelId: jest.fn(),
|
||||
console: {
|
||||
log: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
setSendSlackMessageDependencies(mockDependencies)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetSendSlackMessageDependencies()
|
||||
resetWebClient()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("initializeWebClient", () => {
|
||||
it("should throw error when SLACK_TOKEN is missing", () => {
|
||||
mockDependencies.getSlackToken.mockReturnValue(undefined)
|
||||
mockDependencies.getSlackChannelId.mockReturnValue("channel-id")
|
||||
|
||||
expect(() => initializeWebClient()).toThrow("Missing SLACK_TOKEN")
|
||||
})
|
||||
|
||||
it("should throw error when SLACK_CHANNEL_ID is missing", () => {
|
||||
mockDependencies.getSlackToken.mockReturnValue("token")
|
||||
mockDependencies.getSlackChannelId.mockReturnValue(undefined)
|
||||
|
||||
expect(() => initializeWebClient()).toThrow("Missing SLACK_CHANNEL_ID")
|
||||
})
|
||||
|
||||
it("should create WebClient with token when both env vars are present", () => {
|
||||
mockDependencies.getSlackToken.mockReturnValue("test-token")
|
||||
mockDependencies.getSlackChannelId.mockReturnValue("test-channel")
|
||||
|
||||
const client = initializeWebClient()
|
||||
|
||||
expect(mockDependencies.WebClient).toHaveBeenCalledWith("test-token", {})
|
||||
expect(client).toBe(mockWebClient)
|
||||
})
|
||||
|
||||
it("should reuse existing WebClient on subsequent calls", () => {
|
||||
mockDependencies.getSlackToken.mockReturnValue("test-token")
|
||||
mockDependencies.getSlackChannelId.mockReturnValue("test-channel")
|
||||
|
||||
const client1 = initializeWebClient()
|
||||
const client2 = initializeWebClient()
|
||||
|
||||
expect(mockDependencies.WebClient).toHaveBeenCalledTimes(1)
|
||||
expect(client1).toBe(client2)
|
||||
})
|
||||
|
||||
it("should throw error when SLACK_TOKEN is empty string", () => {
|
||||
mockDependencies.getSlackToken.mockReturnValue("")
|
||||
mockDependencies.getSlackChannelId.mockReturnValue("channel-id")
|
||||
|
||||
expect(() => initializeWebClient()).toThrow("Missing SLACK_TOKEN")
|
||||
})
|
||||
|
||||
it("should throw error when SLACK_CHANNEL_ID is empty string", () => {
|
||||
mockDependencies.getSlackToken.mockReturnValue("token")
|
||||
mockDependencies.getSlackChannelId.mockReturnValue("")
|
||||
|
||||
expect(() => initializeWebClient()).toThrow("Missing SLACK_CHANNEL_ID")
|
||||
})
|
||||
|
||||
it("should throw error when SLACK_TOKEN is null", () => {
|
||||
mockDependencies.getSlackToken.mockReturnValue(null)
|
||||
mockDependencies.getSlackChannelId.mockReturnValue("channel-id")
|
||||
|
||||
expect(() => initializeWebClient()).toThrow("Missing SLACK_TOKEN")
|
||||
})
|
||||
|
||||
it("should throw error when SLACK_CHANNEL_ID is null", () => {
|
||||
mockDependencies.getSlackToken.mockReturnValue("token")
|
||||
mockDependencies.getSlackChannelId.mockReturnValue(null)
|
||||
|
||||
expect(() => initializeWebClient()).toThrow("Missing SLACK_CHANNEL_ID")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendSlackMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Setup default successful environment
|
||||
mockDependencies.getSlackToken.mockReturnValue("test-token")
|
||||
mockDependencies.getSlackChannelId.mockReturnValue("test-channel")
|
||||
})
|
||||
|
||||
it("should send string message successfully", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await sendSlackMessage("Hello World")
|
||||
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "Hello World",
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should send string message with custom channel", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await sendSlackMessage("Hello World", "custom-channel")
|
||||
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "custom-channel",
|
||||
text: "Hello World",
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should send object with blocks successfully", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const message = {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "Hello World",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await sendSlackMessage(message)
|
||||
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "Notification from CodeFlash",
|
||||
blocks: message.blocks,
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should use custom text when provided with blocks", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const message = {
|
||||
text: "Custom fallback text",
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "Hello World",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await sendSlackMessage(message)
|
||||
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "Custom fallback text",
|
||||
blocks: message.blocks,
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle object without blocks property", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const message = { key: "value", nested: { prop: "test" } }
|
||||
|
||||
const result = await sendSlackMessage(message)
|
||||
|
||||
expect(mockDependencies.console.warn).toHaveBeenCalledWith(
|
||||
"Object passed to sendSlackMessage without blocks property",
|
||||
)
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: JSON.stringify(message),
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle invalid message types", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await sendSlackMessage(123)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith("Invalid message type", "number")
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "Invalid message",
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle null message", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await sendSlackMessage(null)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith("Invalid message type", "object")
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "Invalid message",
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle undefined message", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await sendSlackMessage(undefined)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Invalid message type",
|
||||
"undefined",
|
||||
)
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "Invalid message",
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should return full response when returnData is true", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456", channel: "C123" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await sendSlackMessage("Hello World", null, true)
|
||||
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it("should handle errors and still resolve", async () => {
|
||||
const error = new Error("Slack API error")
|
||||
mockWebClient.chat.postMessage.mockRejectedValue(error)
|
||||
|
||||
const result = await sendSlackMessage("Hello World")
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error sending Slack message:",
|
||||
error,
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should return error object when returnData is true and error occurs", async () => {
|
||||
const error = new Error("Slack API error")
|
||||
mockWebClient.chat.postMessage.mockRejectedValue(error)
|
||||
|
||||
const result = await sendSlackMessage("Hello World", null, true)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error sending Slack message:",
|
||||
error,
|
||||
)
|
||||
expect(result).toEqual({ error })
|
||||
})
|
||||
|
||||
it("should handle empty string message", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await sendSlackMessage("")
|
||||
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "",
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle boolean message type", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await sendSlackMessage(true)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith("Invalid message type", "boolean")
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "Invalid message",
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle array message type", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await sendSlackMessage([1, 2, 3])
|
||||
|
||||
// Arrays are objects, so they go through the object path and trigger a warning
|
||||
expect(mockDependencies.console.warn).toHaveBeenCalledWith(
|
||||
"Object passed to sendSlackMessage without blocks property",
|
||||
)
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "[1,2,3]",
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle complex nested blocks", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const message = {
|
||||
text: "Fallback",
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "Hello World",
|
||||
},
|
||||
accessory: {
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Click Me",
|
||||
},
|
||||
action_id: "button_1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: "Context text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await sendSlackMessage(message)
|
||||
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "Fallback",
|
||||
blocks: message.blocks,
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle object with empty blocks array", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const message = {
|
||||
blocks: [],
|
||||
}
|
||||
|
||||
const result = await sendSlackMessage(message)
|
||||
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "Notification from CodeFlash",
|
||||
blocks: [],
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle circular reference in object without blocks", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const message: any = { key: "value" }
|
||||
message.circular = message // Create circular reference
|
||||
|
||||
// Save the original JSON.stringify
|
||||
const originalStringify = JSON.stringify
|
||||
|
||||
// Replace JSON.stringify with a properly typed mock
|
||||
global.JSON.stringify = ((obj: any): string => {
|
||||
if (obj === message) {
|
||||
throw new TypeError("Converting circular structure to JSON")
|
||||
}
|
||||
return originalStringify(obj)
|
||||
}) as any
|
||||
|
||||
try {
|
||||
await sendSlackMessage(message)
|
||||
|
||||
expect(mockDependencies.console.warn).toHaveBeenCalledWith(
|
||||
"Object passed to sendSlackMessage without blocks property",
|
||||
)
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error sending Slack message:",
|
||||
expect.any(TypeError),
|
||||
)
|
||||
} finally {
|
||||
// Always restore JSON.stringify
|
||||
global.JSON.stringify = originalStringify
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle initialization error", async () => {
|
||||
mockDependencies.getSlackToken.mockReturnValue(undefined)
|
||||
|
||||
const result = await sendSlackMessage("Hello World")
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error sending Slack message:",
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle WebClient constructor error", async () => {
|
||||
const error = new Error("WebClient initialization failed")
|
||||
mockDependencies.WebClient.mockImplementation(() => {
|
||||
throw error
|
||||
})
|
||||
|
||||
const result = await sendSlackMessage("Hello World", null, true)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error sending Slack message:",
|
||||
error,
|
||||
)
|
||||
expect(result).toEqual({ error })
|
||||
})
|
||||
|
||||
it("should use channel parameter over environment variable", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
await sendSlackMessage("Test", "override-channel")
|
||||
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "override-channel",
|
||||
text: "Test",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle empty channel parameter by using default", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
await sendSlackMessage("Test", "")
|
||||
|
||||
// Empty string is falsy, so it should fall back to the default channel
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: "Test",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle very long messages", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const longMessage = "x".repeat(10000)
|
||||
const result = await sendSlackMessage(longMessage)
|
||||
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: longMessage,
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle special characters in message", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
|
||||
const specialMessage = "Hello <@U123> & team! Check this: https://example.com"
|
||||
const result = await sendSlackMessage(specialMessage)
|
||||
|
||||
expect(mockWebClient.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: "test-channel",
|
||||
text: specialMessage,
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle promise rejection from WebClient", async () => {
|
||||
mockWebClient.chat.postMessage.mockRejectedValue("Network error")
|
||||
|
||||
const result = await sendSlackMessage("Test", null, true)
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error sending Slack message:",
|
||||
"Network error",
|
||||
)
|
||||
expect(result).toEqual({ error: "Network error" })
|
||||
})
|
||||
|
||||
it("should handle synchronous error in postMessage", async () => {
|
||||
mockWebClient.chat.postMessage.mockImplementation(() => {
|
||||
throw new Error("Sync error")
|
||||
})
|
||||
|
||||
const result = await sendSlackMessage("Test")
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error sending Slack message:",
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle getSlackToken throwing error", async () => {
|
||||
mockDependencies.getSlackToken.mockImplementation(() => {
|
||||
throw new Error("Env error")
|
||||
})
|
||||
mockDependencies.getSlackChannelId.mockReturnValue("channel")
|
||||
|
||||
const result = await sendSlackMessage("Test")
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error sending Slack message:",
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle getSlackChannelId throwing error", async () => {
|
||||
mockDependencies.getSlackToken.mockReturnValue("token")
|
||||
mockDependencies.getSlackChannelId.mockImplementation(() => {
|
||||
throw new Error("Env error")
|
||||
})
|
||||
|
||||
const result = await sendSlackMessage("Test")
|
||||
|
||||
expect(mockDependencies.console.error).toHaveBeenCalledWith(
|
||||
"Error sending Slack message:",
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle object with null blocks property", async () => {
|
||||
const mockResponse = { ok: true, ts: "1234567890.123456" }
|
||||
mockWebClient.chat.postMessage.mockResolvedValue(mockResponse)
|
||||
mockDependencies.getSlackToken.mockReturnValue("token")
|
||||
mockDependencies.getSlackChannelId.mockReturnValue("channel")
|
||||
|
||||
const message = { blocks: null }
|
||||
|
||||
const result = await sendSlackMessage(message)
|
||||
|
||||
expect(mockDependencies.console.warn).toHaveBeenCalledWith(
|
||||
"Object passed to sendSlackMessage without blocks property",
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,10 @@ module.exports = {
|
|||
preset: "ts-jest/presets/default-esm",
|
||||
roots: ["."],
|
||||
testEnvironment: "node",
|
||||
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
|
||||
testEnvironmentOptions: {
|
||||
NODE_ENV: "test"
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
|
|
@ -15,7 +19,15 @@ module.exports = {
|
|||
moduleNameMapper: {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(@codeflash-ai)/)'
|
||||
],
|
||||
extensionsToTreatAsEsm: [".ts", ".tsx"],
|
||||
testRegex: ".*\\.test\\.ts$",
|
||||
testPathIgnorePatterns: process.env.CI ? ["e2e.test.ts"] : [],
|
||||
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
|
||||
testTimeout: 30000,
|
||||
forceExit: true,
|
||||
detectOpenHandles: true,
|
||||
clearMocks: true,
|
||||
}
|
||||
|
|
|
|||
122
js/cf-api/jest.setup.ts
Normal file
122
js/cf-api/jest.setup.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { jest, afterEach } from "@jest/globals"
|
||||
import * as dotenv from "dotenv"
|
||||
|
||||
// Load test environment variables
|
||||
dotenv.config({ path: ".env.test" })
|
||||
|
||||
// Ensure critical test environment variables are set
|
||||
process.env.NODE_ENV = "test"
|
||||
|
||||
// Mock github-repo-setup module early to prevent background Prisma calls
|
||||
// Note: Jest moduleNameMapper strips .js extensions, so this should match the import
|
||||
// @ts-ignore
|
||||
jest.mock("./endpoints/utils/github-repo-setup", () => ({
|
||||
registerRepositoryAndMember: jest.fn().mockImplementation(() => Promise.resolve(12345)),
|
||||
getInstallationId: jest.fn().mockImplementation(() => Promise.resolve(12345)),
|
||||
}))
|
||||
|
||||
// Also mock the direct import paths that might be used
|
||||
jest.mock("./endpoints/utils/github-repo-setup.js", () => ({
|
||||
registerRepositoryAndMember: jest.fn().mockImplementation(() => Promise.resolve(12345)),
|
||||
getInstallationId: jest.fn().mockImplementation(() => Promise.resolve(12345)),
|
||||
}))
|
||||
// Mock Prisma globally
|
||||
jest.mock(
|
||||
"@codeflash-ai/common",
|
||||
() => {
|
||||
const mockPrisma = {
|
||||
repositories: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
users: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
repository_members: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
app_installations: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
optimization_features: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
optimization_events: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
$disconnect: jest.fn(),
|
||||
}
|
||||
|
||||
return {
|
||||
prisma: mockPrisma,
|
||||
getAppInstallationByInstalltionId: jest.fn(),
|
||||
createAppInstallation: jest.fn(),
|
||||
upsertRepository: jest.fn(),
|
||||
createRepositoryMember: jest.fn(),
|
||||
createOrUpdateUser: jest.fn(),
|
||||
stripeClient: {
|
||||
subscriptions: {
|
||||
list: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
},
|
||||
customers: {
|
||||
list: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
},
|
||||
},
|
||||
getActiveSubscriptionByUserId: jest.fn(),
|
||||
getUserByAuth0Id: jest.fn(),
|
||||
createUser: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
}
|
||||
},
|
||||
{ virtual: true },
|
||||
)
|
||||
|
||||
// Mock Sentry globally
|
||||
jest.mock("@sentry/node", () => ({
|
||||
captureException: jest.fn(),
|
||||
captureMessage: jest.fn(),
|
||||
addBreadcrumb: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock is handled at the top of the file
|
||||
|
||||
// Increase test timeout for longer running tests
|
||||
jest.setTimeout(30000)
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
// Allow any pending promises to resolve
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
})
|
||||
163
js/cf-api/package-lock.json
generated
163
js/cf-api/package-lock.json
generated
|
|
@ -48,7 +48,8 @@
|
|||
"@types/body-parser": "^1.19.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"copyfiles": "^2.4.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
|
@ -59,7 +60,8 @@
|
|||
"lint-staged": "^15.4.3",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.2.1",
|
||||
"ts-jest": "^29.1.1",
|
||||
"supertest": "^7.1.1",
|
||||
"ts-jest": "^29.3.4",
|
||||
"ts-node": "^10.9.2"
|
||||
}
|
||||
},
|
||||
|
|
@ -2158,6 +2160,18 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
|
@ -4228,6 +4242,15 @@
|
|||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
||||
"integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.8.2.tgz",
|
||||
|
|
@ -4751,6 +4774,12 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz",
|
||||
|
|
@ -4833,7 +4862,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
|
||||
"integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expect": "^29.0.0",
|
||||
"pretty-format": "^29.0.0"
|
||||
|
|
@ -4854,6 +4882,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
|
|
@ -4968,6 +5002,28 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/superagent": {
|
||||
"version": "8.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
|
||||
"integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/cookiejar": "^2.1.5",
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supertest": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz",
|
||||
"integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tedious": {
|
||||
"version": "4.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
|
||||
|
|
@ -5530,6 +5586,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
|
|
@ -6232,6 +6294,15 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
|
|
@ -6281,6 +6352,12 @@
|
|||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookiejar": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/copyfiles": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz",
|
||||
|
|
@ -6630,6 +6707,16 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
|
||||
|
|
@ -7714,6 +7801,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
|
|
@ -7941,6 +8034,23 @@
|
|||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dezalgo": "^1.0.4",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
|
|
@ -9193,7 +9303,6 @@
|
|||
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
|
||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
|
|
@ -12526,6 +12635,51 @@
|
|||
"node": ">=12.*"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.1.tgz",
|
||||
"integrity": "sha512-O+PCv11lgTNJUzy49teNAWLjBZfc+A1enOwTpLlH6/rsvKcTwcdTT8m9azGkVqM7HBl5jpyZ7KTPhHweokBcdg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.0",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.4",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.0",
|
||||
"formidable": "^3.5.4",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.1.tgz",
|
||||
"integrity": "sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^10.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
|
|
@ -12710,7 +12864,6 @@
|
|||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.4.tgz",
|
||||
"integrity": "sha512-Iqbrm8IXOmV+ggWHOTEbjwyCf2xZlUMv5npExksXohL+tk8va4Fjhb+X2+Rt9NBmgO7bJ8WpnMLOwih/DnMlFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bs-logger": "^0.2.6",
|
||||
"ejs": "^3.1.10",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@
|
|||
"@types/body-parser": "^1.19.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"copyfiles": "^2.4.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
|
@ -74,7 +75,8 @@
|
|||
"lint-staged": "^15.4.3",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.2.1",
|
||||
"ts-jest": "^29.1.1",
|
||||
"supertest": "^7.1.1",
|
||||
"ts-jest": "^29.3.4",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
"prisma": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"strictNullChecks": false,
|
||||
"sourceMap": true,
|
||||
"target": "es2022",
|
||||
"types": ["node", "express", "jest"],
|
||||
"types": ["node", "express", "jest", "@types/jest"],
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
|
|
|
|||
Loading…
Reference in a new issue