diff --git a/src/__tests__/integration/api/StakgraphWebhookService-integration.test.ts b/src/__tests__/integration/api/StakgraphWebhookService-integration.test.ts new file mode 100644 index 0000000000..375619bd8f --- /dev/null +++ b/src/__tests__/integration/api/StakgraphWebhookService-integration.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { StakgraphWebhookService } from "@/services/swarm/StakgraphWebhookService"; +import { db } from "@/lib/db"; +import { EncryptionService, computeHmacSha256Hex } from "@/lib/encryption"; +import { RepositoryStatus } from "@prisma/client"; +import { generateUniqueId, generateUniqueSlug } from "@/__tests__/support/helpers"; +import type { WebhookPayload } from "@/types"; + +/** + * Integration tests for StakgraphWebhookService.processWebhook. + * + * These tests use a seeded database and real code paths (no wholesale db mock). + * They verify that the terminal-status guard in updateStakgraphStatus correctly: + * - clears ingestRequestInProgress on terminal ("synced" / "failed") webhooks + * - leaves ingestRequestInProgress set on intermediate ("inprogress") webhooks + */ + +describe("StakgraphWebhookService - integration", () => { + const enc = EncryptionService.getInstance(); + const PLAINTEXT_API_KEY = "swarm-webhook-secret-key"; + const INGEST_REF_ID = "ingest-ref-webhook-test"; + + let workspaceId: string; + let service: StakgraphWebhookService; + + beforeEach(async () => { + vi.clearAllMocks(); + service = new StakgraphWebhookService(); + + // Seed: user + workspace + swarm with ingestRequestInProgress: true + const testData = await db.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + id: generateUniqueId("user"), + email: `webhook-user-${generateUniqueId()}@example.com`, + name: "Webhook Test User", + }, + }); + + const workspace = await tx.workspace.create({ + data: { + name: "Webhook Test Workspace", + slug: generateUniqueSlug("webhook-test"), + ownerId: user.id, + }, + }); + + await tx.swarm.create({ + data: { + workspaceId: workspace.id, + name: `webhook-swarm-${generateUniqueId()}`, + swarmId: generateUniqueId("swarm"), + status: "ACTIVE", + swarmUrl: `https://webhook-swarm-${generateUniqueId()}.sphinx.chat/api`, + swarmApiKey: JSON.stringify(enc.encryptField("swarmApiKey", PLAINTEXT_API_KEY)), + ingestRefId: INGEST_REF_ID, + ingestRequestInProgress: true, + agentRequestId: null, + agentStatus: null, + }, + }); + + await tx.repository.create({ + data: { + name: "webhook-repo", + repositoryUrl: "https://github.com/test-org/webhook-repo", + workspaceId: workspace.id, + status: RepositoryStatus.PENDING, + branch: "main", + }, + }); + + return { workspace }; + }); + + workspaceId = testData.workspace.id; + }); + + /** + * Helper: build a valid HMAC-SHA256 signature for the given payload body, + * matching the algorithm used in StakgraphWebhookService.lookupAndVerifySwarm. + */ + function buildSignature(body: string): string { + const hex = computeHmacSha256Hex(PLAINTEXT_API_KEY, body); + return `sha256=${hex}`; + } + + it("should clear ingestRequestInProgress when a SYNCED terminal webhook is received", async () => { + const payload: WebhookPayload = { + request_id: INGEST_REF_ID, + status: "synced", + progress: 100, + result: { nodes: 42, edges: 100 }, + error: null, + }; + const rawBody = JSON.stringify(payload); + const signature = buildSignature(rawBody); + + const result = await service.processWebhook(signature, rawBody, payload); + + expect(result.success).toBe(true); + expect(result.status).toBe(200); + + const swarm = await db.swarm.findUnique({ where: { workspaceId } }); + expect(swarm?.ingestRequestInProgress).toBe(false); + }); + + it("should clear ingestRequestInProgress when a FAILED terminal webhook is received", async () => { + const payload: WebhookPayload = { + request_id: INGEST_REF_ID, + status: "failed", + progress: 0, + result: null, + error: "something went wrong", + }; + const rawBody = JSON.stringify(payload); + const signature = buildSignature(rawBody); + + const result = await service.processWebhook(signature, rawBody, payload); + + expect(result.success).toBe(true); + expect(result.status).toBe(200); + + const swarm = await db.swarm.findUnique({ where: { workspaceId } }); + expect(swarm?.ingestRequestInProgress).toBe(false); + }); + + it("should leave ingestRequestInProgress true when an inprogress (non-terminal) webhook is received", async () => { + const payload: WebhookPayload = { + request_id: INGEST_REF_ID, + status: "inprogress", + progress: 50, + result: null, + error: null, + }; + const rawBody = JSON.stringify(payload); + const signature = buildSignature(rawBody); + + const result = await service.processWebhook(signature, rawBody, payload); + + expect(result.success).toBe(true); + expect(result.status).toBe(200); + + // Flag must remain true — lock is still active while ingest is running + const swarm = await db.swarm.findUnique({ where: { workspaceId } }); + expect(swarm?.ingestRequestInProgress).toBe(true); + }); +}); diff --git a/src/__tests__/integration/api/phases/create.test.ts b/src/__tests__/integration/api/phases/create.test.ts index bc841b60cc..72932ac567 100644 --- a/src/__tests__/integration/api/phases/create.test.ts +++ b/src/__tests__/integration/api/phases/create.test.ts @@ -325,13 +325,18 @@ describe("POST /api/features/[featureId]/phases", () => { expect(phasesBefore).toHaveLength(2); // Delete feature (cascade delete should remove phases) - await db.feature.delete({ where: { id: feature.id } }); - - // Verify phases are deleted - const phasesAfter = await db.phase.findMany({ - where: { featureId: feature.id }, - }); - expect(phasesAfter).toHaveLength(0); + const featureIdToDelete = feature.id; + await db.feature.delete({ where: { id: featureIdToDelete } }); + + // Verify the feature is gone + const featureAfter = await db.feature.findUnique({ where: { id: featureIdToDelete } }); + expect(featureAfter).toBeNull(); + + // Verify phases are cascade-deleted (check by specific IDs, not featureId) + const phase1After = await db.phase.findUnique({ where: { id: phase1.data.id } }); + const phase2After = await db.phase.findUnique({ where: { id: phase2.data.id } }); + expect(phase1After).toBeNull(); + expect(phase2After).toBeNull(); // Prevent cleanup from failing feature = null; diff --git a/src/__tests__/integration/api/swarm-stakgraph-ingest.test.ts b/src/__tests__/integration/api/swarm-stakgraph-ingest.test.ts index 8d91fa8a64..c028e2ef04 100644 --- a/src/__tests__/integration/api/swarm-stakgraph-ingest.test.ts +++ b/src/__tests__/integration/api/swarm-stakgraph-ingest.test.ts @@ -10,6 +10,17 @@ import { getMockedSession, } from "@/__tests__/support/helpers"; +vi.mock("@/lib/auth/nextauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getGithubUsernameAndPAT: vi.fn().mockResolvedValue({ + username: "testuser", + token: "github_pat_integration", + }), + }; +}); + // Mock external API calls vi.mock("@/services/swarm/stakgraph-actions", () => ({ triggerIngestAsync: vi.fn(), @@ -54,6 +65,7 @@ vi.mock("@/lib/url", () => ({ import { triggerIngestAsync } from "@/services/swarm/stakgraph-actions"; import { swarmApiRequest } from "@/services/swarm/api/swarm"; import type { AsyncSyncResult } from "@/services/swarm/stakgraph-actions"; +import { getGithubUsernameAndPAT } from "@/lib/auth/nextauth"; const mockTriggerIngestAsync = triggerIngestAsync as vi.Mock; const mockSwarmApiRequest = swarmApiRequest as vi.Mock; @@ -514,6 +526,117 @@ describe("POST /api/swarm/stakgraph/ingest - Integration Tests", () => { expect(swarm?.ingestRequestInProgress).toBe(false); }); + + it("should reset flag and roll back repo rows when external service is busy", async () => { + // Seed repo with a known pre-ingest status + await db.repository.updateMany({ + where: { workspaceId }, + data: { status: RepositoryStatus.SYNCED }, + }); + + mockTriggerIngestAsync.mockResolvedValue({ + ok: false, + status: 409, + data: { error: "System is busy processing another request" }, + } as AsyncSyncResult); + + const request = createPostRequest({ workspaceId }); + const response = await POST(request); + + expect(response.status).toBe(409); + const body = await response.json(); + expect(body.success).toBe(false); + + // Flag must be cleared + const swarm = await db.swarm.findUnique({ where: { workspaceId } }); + expect(swarm?.ingestRequestInProgress).toBe(false); + + // Repo rows must be rolled back to pre-PENDING status + const repos = await db.repository.findMany({ where: { workspaceId } }); + for (const repo of repos) { + expect(repo.status).toBe(RepositoryStatus.SYNCED); + } + }); + + it("should reset flag and roll back repo rows when workspace is not found", async () => { + // Seed repo with a known pre-ingest status + await db.repository.updateMany({ + where: { workspaceId }, + data: { status: RepositoryStatus.SYNCED }, + }); + + // The route calls db.workspace.findUnique for the slug lookup after acquiring the lock. + // We need to intercept only that specific call and return null. + // Prisma client models are Proxy objects — vi.spyOn and delete both leave the own + // property undefined, permanently shadowing the Proxy's get trap. Instead, save the + // original reference and restore via reassignment (not delete) so the Proxy works again. + const originalFindUnique = db.workspace.findUnique; + let slugCallCount = 0; + (db.workspace as any).findUnique = async (args: any) => { + if (args?.where?.id === workspaceId && args?.select?.slug) { + slugCallCount++; + if (slugCallCount === 1) return null; // simulate workspace not found for slug lookup + } + return originalFindUnique.call(db.workspace, args); + }; + + mockTriggerIngestAsync.mockResolvedValue({ + ok: true, + status: 200, + data: { request_id: "ingest-req-789" }, + } as AsyncSyncResult); + + const request = createPostRequest({ workspaceId }); + const response = await POST(request); + + // Restore via reassignment — delete leaves the own property as undefined, + // permanently shadowing the Proxy's get trap for all subsequent tests. + (db.workspace as any).findUnique = originalFindUnique; + + expect(response.status).toBe(404); + + // Flag must be cleared + const swarm = await db.swarm.findUnique({ where: { workspaceId } }); + expect(swarm?.ingestRequestInProgress).toBe(false); + + // Repo rows must be rolled back + const repos = await db.repository.findMany({ where: { workspaceId } }); + for (const repo of repos) { + expect(repo.status).toBe(RepositoryStatus.SYNCED); + } + }); + + it("should reset flag and roll back repo rows when no GitHub credentials found", async () => { + // Seed repo with a known pre-ingest status + await db.repository.updateMany({ + where: { workspaceId }, + data: { status: RepositoryStatus.SYNCED }, + }); + + // Force credentials lookup to return null + vi.mocked(getGithubUsernameAndPAT).mockResolvedValueOnce(null as any); + + mockTriggerIngestAsync.mockResolvedValue({ + ok: true, + status: 200, + data: { request_id: "ingest-req-999" }, + } as AsyncSyncResult); + + const request = createPostRequest({ workspaceId }); + const response = await POST(request); + + expect(response.status).toBe(400); + + // Flag must be cleared + const swarm = await db.swarm.findUnique({ where: { workspaceId } }); + expect(swarm?.ingestRequestInProgress).toBe(false); + + // Repo rows must be rolled back + const repos = await db.repository.findMany({ where: { workspaceId } }); + for (const repo of repos) { + expect(repo.status).toBe(RepositoryStatus.SYNCED); + } + }); }); }); diff --git a/src/__tests__/unit/lib/helpers/repository.test.ts b/src/__tests__/unit/lib/helpers/repository.test.ts index 29a9222923..2bbaf5b98a 100644 --- a/src/__tests__/unit/lib/helpers/repository.test.ts +++ b/src/__tests__/unit/lib/helpers/repository.test.ts @@ -35,6 +35,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, ], }; @@ -61,6 +62,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, orderBy: { createdAt: "asc" }, }, @@ -80,6 +82,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }); }); @@ -111,6 +114,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, orderBy: { createdAt: "asc" }, }, @@ -142,6 +146,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, orderBy: { createdAt: "asc" }, }, @@ -168,6 +173,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, { id: "repo-2", @@ -183,6 +189,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, ], }; @@ -209,6 +216,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, orderBy: { createdAt: "asc" }, }, @@ -244,6 +252,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, orderBy: { createdAt: "asc" }, }, @@ -276,6 +285,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, orderBy: { createdAt: "asc" }, }, @@ -301,6 +311,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, ], }; @@ -338,6 +349,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, ], }; @@ -360,6 +372,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }); expect(result).toHaveProperty("id"); expect(result).toHaveProperty("name"); @@ -403,6 +416,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, ], }; @@ -429,6 +443,7 @@ describe("getPrimaryRepository", () => { docsEnabled: true, mocksEnabled: true, embeddingsEnabled: true, + status: true, }, }), }), diff --git a/src/app/api/swarm/stakgraph/ingest/route.ts b/src/app/api/swarm/stakgraph/ingest/route.ts index 036c945af0..706d61b96e 100644 --- a/src/app/api/swarm/stakgraph/ingest/route.ts +++ b/src/app/api/swarm/stakgraph/ingest/route.ts @@ -96,6 +96,7 @@ export async function POST(request: NextRequest) { id: string; repositoryUrl: string; name: string; + status: RepositoryStatus; codeIngestionEnabled: boolean; docsEnabled: boolean; mocksEnabled: boolean; @@ -113,6 +114,7 @@ export async function POST(request: NextRequest) { repositoryUrl: true, workspaceId: true, name: true, + status: true, codeIngestionEnabled: true, docsEnabled: true, mocksEnabled: true, @@ -240,6 +242,12 @@ export async function POST(request: NextRequest) { if (!workspace) { console.log(`[STAKGRAPH_INGEST] Workspace not found with ID: ${repoWorkspaceId}`); + await Promise.all([ + saveOrUpdateSwarm({ workspaceId: repoWorkspaceId, ingestRequestInProgress: false }), + ...repositoriesToIngest.map(r => + db.repository.update({ where: { id: r.id }, data: { status: r.status } }) + ), + ]); return NextResponse.json({ success: false, message: "Workspace not found" }, { status: 404 }); } @@ -250,6 +258,12 @@ export async function POST(request: NextRequest) { const githubProfile = await getGithubUsernameAndPAT(session.user.id, workspace.slug); if (!githubProfile?.username || !githubProfile?.token) { console.log(`[STAKGRAPH_INGEST] No GitHub credentials found - username: ${!!githubProfile?.username}, token: ${!!githubProfile?.token}`); + await Promise.all([ + saveOrUpdateSwarm({ workspaceId: repoWorkspaceId, ingestRequestInProgress: false }), + ...repositoriesToIngest.map(r => + db.repository.update({ where: { id: r.id }, data: { status: r.status } }) + ), + ]); return NextResponse.json({ success: false, message: "No GitHub credentials found for this workspace" }, { status: 400 }); } @@ -284,9 +298,13 @@ export async function POST(request: NextRequest) { if (apiResult?.data && typeof apiResult.data === "object" && "error" in apiResult.data) { const errorMsg = apiResult.data.error as string; if (errorMsg.includes("System is busy processing another request")) { - console.log(`[STAKGRAPH_INGEST] External service busy, keeping flag set and returning 409`); - // NOTE: We keep ingestRequestInProgress: true to prevent more requests - // The flag will be reset when the external processing completes via webhook + console.log(`[STAKGRAPH_INGEST] External service busy, resetting flag and rolling back repo rows`); + await Promise.all([ + saveOrUpdateSwarm({ workspaceId: repoWorkspaceId, ingestRequestInProgress: false }), + ...repositoriesToIngest.map(r => + db.repository.update({ where: { id: r.id }, data: { status: r.status } }) + ), + ]); return NextResponse.json({ success: false, message: "Ingest request already in progress for this swarm" diff --git a/src/lib/helpers/repository.ts b/src/lib/helpers/repository.ts index a80b732ac2..00f60b2350 100644 --- a/src/lib/helpers/repository.ts +++ b/src/lib/helpers/repository.ts @@ -1,4 +1,5 @@ import { db } from "@/lib/db"; +import { RepositoryStatus } from "@prisma/client"; export type RepositoryInfo = { id: string; @@ -10,6 +11,7 @@ export type RepositoryInfo = { name: string; description: string | null; branch: string; + status: RepositoryStatus; // Sync configuration codeIngestionEnabled: boolean; docsEnabled: boolean; @@ -36,6 +38,7 @@ export async function getPrimaryRepository(workspaceId: string): Promise