Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
294745d
Generated with Hive: Fix stuck ingestRequestInProgress flag and rollb…
fayekelmith Jul 17, 2026
143c3e4
Generated with Hive: Add status field to repository select in getPrim…
fayekelmith Jul 17, 2026
5941b41
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 17, 2026
d5797fe
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 17, 2026
9d72115
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 17, 2026
5e09c70
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 17, 2026
49804d6
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 18, 2026
eafce0a
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 18, 2026
b2ff48c
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 18, 2026
2426044
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 18, 2026
6da4ac4
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 19, 2026
c4cbaab
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 19, 2026
fcce2f8
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 20, 2026
0d4fdae
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 20, 2026
5dcd67e
fix(test): use findUnique by PK for cascade delete assertion in phase…
tomsmith8 Jul 20, 2026
136e88d
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 20, 2026
71ddcf0
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 20, 2026
f499314
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 20, 2026
ae0c879
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 20, 2026
101d6fb
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 20, 2026
489e330
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 20, 2026
ab45277
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 21, 2026
7787ac7
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 21, 2026
2860aef
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 21, 2026
814a72a
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 21, 2026
1d4e0b1
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 21, 2026
04468c1
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 21, 2026
88dccbf
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 22, 2026
8cbef69
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 22, 2026
544151e
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 22, 2026
125c99e
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 22, 2026
9220f6b
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 23, 2026
c690268
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 23, 2026
f599cf3
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 23, 2026
8ed02a8
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 23, 2026
95414e3
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 23, 2026
1caea19
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 23, 2026
cf3f794
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 23, 2026
2d5eeb0
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 23, 2026
ad4f419
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 23, 2026
a9dc408
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 23, 2026
1b510a9
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 24, 2026
a7c41f9
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 24, 2026
ac38199
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 24, 2026
dcf6142
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 24, 2026
31b1ffb
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 24, 2026
23a3f02
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 24, 2026
bfea448
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 24, 2026
140b15b
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 24, 2026
6a93979
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 24, 2026
96f822a
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 26, 2026
2219ab0
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 26, 2026
85984ef
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 27, 2026
fce3c8e
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 27, 2026
32b75d7
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 28, 2026
0ef0c39
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 28, 2026
3acbc6d
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 28, 2026
38d4ffb
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 29, 2026
057b43a
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 29, 2026
e03e804
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 29, 2026
28bc7a7
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 29, 2026
7a8b901
Merge branch 'master' into bugfix/CMROJF8FL0017K004GR0PAITZ-fix-stuck…
tomsmith8 Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 12 additions & 7 deletions src/__tests__/integration/api/phases/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
123 changes: 123 additions & 0 deletions src/__tests__/integration/api/swarm-stakgraph-ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import {
getMockedSession,
} from "@/__tests__/support/helpers";

vi.mock("@/lib/auth/nextauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/auth/nextauth")>();
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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
});
});
});

Expand Down
Loading
Loading