From 802b51085fcb3052af171030a1798f339debab89 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Tue, 7 Apr 2026 10:03:14 +0000 Subject: [PATCH 1/5] Generated with Hive: Fix graph-admin mock routing and extend mock cmd handler for local development --- .../mock/swarm-credentials-and-cmd.test.ts | 109 ++++++++++++++- src/__tests__/unit/services/swarm/cmd.test.ts | 126 +++++++++++++++++- .../mock/swarm-super-admin/api/cmd/route.ts | 71 ++++++++-- .../mock/swarm-super-admin/api/cmd/state.ts | 32 +++++ src/lib/auth/nextauth.ts | 13 +- src/services/swarm/cmd.ts | 13 +- src/utils/mockSetup.ts | 89 +++++++++++++ 7 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 src/app/api/mock/swarm-super-admin/api/cmd/state.ts diff --git a/src/__tests__/unit/api/mock/swarm-credentials-and-cmd.test.ts b/src/__tests__/unit/api/mock/swarm-credentials-and-cmd.test.ts index fb1ccbdd7b..dda1a30750 100644 --- a/src/__tests__/unit/api/mock/swarm-credentials-and-cmd.test.ts +++ b/src/__tests__/unit/api/mock/swarm-credentials-and-cmd.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/config/env", () => ({ env: { @@ -246,3 +246,110 @@ describe("GET /api/mock/swarm-super-admin/api/cmd", () => { expect(data).toMatchObject({ error: expect.stringContaining("Missing txt") }); }); }); + +// --------------------------------------------------------------------------- +// Boltwall stateful commands +// --------------------------------------------------------------------------- + +describe("Boltwall stateful mock commands", () => { + // Reset state before each test to ensure isolation + beforeEach(async () => { + const { resetMockBoltwallState } = await import( + "@/app/api/mock/swarm-super-admin/api/cmd/state" + ); + resetMockBoltwallState(); + }); + + async function cmdRequest(cmd: object) { + const { GET } = await import( + "@/app/api/mock/swarm-super-admin/api/cmd/route" + ); + const txt = encodeURIComponent(JSON.stringify(cmd)); + return GET( + makeRequest( + `http://localhost/api/mock/swarm-super-admin/api/cmd?txt=${txt}`, + { headers: { "x-jwt": "mock-jwt-token" } } + ) + ); + } + + it("GetBoltwallAccessibility returns { isPublic: false } by default", async () => { + const res = await cmdRequest({ cmd: "GetBoltwallAccessibility" }); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data).toEqual({ isPublic: false }); + }); + + it("UpdateBoltwallAccessibility + GetBoltwallAccessibility round-trip", async () => { + // Set to true + const updateRes = await cmdRequest({ + type: "Swarm", + data: { cmd: "UpdateBoltwallAccessibility", content: true }, + }); + expect(updateRes.status).toBe(200); + expect(await updateRes.json()).toEqual({ success: true }); + + // Confirm state persisted + const getRes = await cmdRequest({ cmd: "GetBoltwallAccessibility" }); + expect(getRes.status).toBe(200); + expect(await getRes.json()).toEqual({ isPublic: true }); + }); + + it("ListPaidEndpoint returns 2 endpoints by default", async () => { + const res = await cmdRequest({ cmd: "ListPaidEndpoint" }); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data.endpoints).toHaveLength(2); + expect(data.endpoints[0]).toMatchObject({ id: 1, route: "v2/search" }); + expect(data.endpoints[1]).toMatchObject({ id: 2, route: "node/content" }); + }); + + it("UpdatePaidEndpoint mutates endpoint status", async () => { + // Disable endpoint id=1 + const updateRes = await cmdRequest({ + type: "Swarm", + data: { cmd: "UpdatePaidEndpoint", content: { id: 1, status: false } }, + }); + expect(updateRes.status).toBe(200); + expect(await updateRes.json()).toEqual({ success: true }); + + // Re-fetch and confirm + const listRes = await cmdRequest({ cmd: "ListPaidEndpoint" }); + const data = await listRes.json(); + const ep1 = data.endpoints.find((e: { id: number }) => e.id === 1); + expect(ep1.status).toBe(false); + // Other endpoint unchanged + const ep2 = data.endpoints.find((e: { id: number }) => e.id === 2); + expect(ep2.status).toBe(true); + }); + + it("GetBoltwallSuperAdmin returns { pubkey: null, name: null }", async () => { + const res = await cmdRequest({ cmd: "GetBoltwallSuperAdmin" }); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data).toEqual({ pubkey: null, name: null }); + }); + + it("GetBotBalance returns { balance: 0 }", async () => { + const res = await cmdRequest({ cmd: "GetBotBalance" }); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data).toEqual({ balance: 0 }); + }); + + it("CreateBotInvoice returns an invoice string", async () => { + const res = await cmdRequest({ + type: "Swarm", + data: { cmd: "CreateBotInvoice", content: { amt_msat: 1000 } }, + }); + const data = await res.json(); + expect(res.status).toBe(200); + expect(typeof data.invoice).toBe("string"); + }); + + it("state resets between tests (isPublic is false again)", async () => { + const res = await cmdRequest({ cmd: "GetBoltwallAccessibility" }); + const data = await res.json(); + expect(data).toEqual({ isPublic: false }); + }); +}); diff --git a/src/__tests__/unit/services/swarm/cmd.test.ts b/src/__tests__/unit/services/swarm/cmd.test.ts index 7f44e25a78..8559130a14 100644 --- a/src/__tests__/unit/services/swarm/cmd.test.ts +++ b/src/__tests__/unit/services/swarm/cmd.test.ts @@ -1,14 +1,27 @@ -import { describe, test, expect, beforeEach, vi } from "vitest"; +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; // Mock fetch globally before importing const mockFetch = vi.fn(); global.fetch = mockFetch; -const { getSwarmCmdJwt } = await import("@/services/swarm/cmd"); +const { getSwarmCmdJwt, swarmCmdRequest } = await import("@/services/swarm/cmd"); describe("getSwarmCmdJwt", () => { + let savedUseMocks: string | undefined; + beforeEach(() => { vi.clearAllMocks(); + // Ensure production routing (no mocks) for getSwarmCmdJwt tests + savedUseMocks = process.env.USE_MOCKS; + delete process.env.USE_MOCKS; + }); + + afterEach(() => { + if (savedUseMocks !== undefined) { + process.env.USE_MOCKS = savedUseMocks; + } else { + delete process.env.USE_MOCKS; + } }); const swarmUrl = "https://swarm42.sphinx.chat"; @@ -113,3 +126,112 @@ describe("getSwarmCmdJwt", () => { ); }); }); + +// --------------------------------------------------------------------------- +// swarmCmdRequest — USE_MOCKS routing + double-encoded JSON (handled in cmd.ts ticket) +// --------------------------------------------------------------------------- + +describe("swarmCmdRequest", () => { + const swarmUrl = "https://swarm42.sphinx.chat"; + const jwt = "test-jwt-token"; + const cmd = { type: "Swarm" as const, data: { cmd: "GetBoltwallAccessibility" as const } }; + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.USE_MOCKS; + }); + + afterEach(() => { + delete process.env.USE_MOCKS; + }); + + test("routes to host:8800 in production (no USE_MOCKS)", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ isPublic: false }), + }); + + await swarmCmdRequest({ swarmUrl, jwt, cmd }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("swarm42.sphinx.chat:8800/api/cmd"); + }); + + test("routes to NEXTAUTH_URL mock endpoint when USE_MOCKS=true", async () => { + process.env.USE_MOCKS = "true"; + process.env.NEXTAUTH_URL = "http://localhost:3000"; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ isPublic: false }), + }); + + await swarmCmdRequest({ swarmUrl, jwt, cmd }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("localhost:3000/api/mock/swarm-super-admin/api/cmd"); + }); + + test("returns parsed object for single-encoded JSON response", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ isPublic: false }), + }); + + const result = await swarmCmdRequest({ swarmUrl, jwt, cmd }); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ isPublic: false }); + expect(result.rawText).toBeUndefined(); + }); + + test("handles double-encoded JSON (string wrapping a JSON object)", async () => { + // sphinx-swarm returns: "\"{\\"isPublic\\":false}\"" — a string that is itself valid JSON + const inner = JSON.stringify({ isPublic: false }); + const doubleEncoded = JSON.stringify(inner); // produces `"\"{\\"isPublic\\":false}\""` + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => doubleEncoded, + }); + + const result = await swarmCmdRequest({ swarmUrl, jwt, cmd }); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ isPublic: false }); + }); + + test("handles double-encoded JSON array", async () => { + const inner = JSON.stringify([{ id: 1, route: "v2/search" }]); + const doubleEncoded = JSON.stringify(inner); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => doubleEncoded, + }); + + const result = await swarmCmdRequest({ swarmUrl, jwt, cmd }); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([{ id: 1, route: "v2/search" }]); + }); + + test("returns rawText when response is not valid JSON", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + text: async () => "Service Unavailable", + }); + + const result = await swarmCmdRequest({ swarmUrl, jwt, cmd }); + + expect(result.ok).toBe(false); + expect(result.rawText).toBe("Service Unavailable"); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/src/app/api/mock/swarm-super-admin/api/cmd/route.ts b/src/app/api/mock/swarm-super-admin/api/cmd/route.ts index a0abc06506..0891715e1b 100644 --- a/src/app/api/mock/swarm-super-admin/api/cmd/route.ts +++ b/src/app/api/mock/swarm-super-admin/api/cmd/route.ts @@ -1,4 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; +import { + mockIsPublic, + mockEndpoints, + setMockIsPublic, + setMockEndpoints, +} from "./state"; const MOCK_CONTAINERS = [ { name: "sphinx", status: "running", image: "sphinxlightning/sphinx-relay:latest" }, @@ -6,6 +12,9 @@ const MOCK_CONTAINERS = [ { name: "lnd", status: "stopped", image: "lightninglabs/lnd:v0.18" }, ]; +// --------------------------------------------------------------------------- +// Static responses +// --------------------------------------------------------------------------- const MOCK_RESPONSES: Record = { ListContainers: { containers: MOCK_CONTAINERS }, StartContainer: { success: true }, @@ -25,15 +34,18 @@ const MOCK_RESPONSES: Record = { "lightninglabs/lnd": "v0.18", }, }, - GetBoltwallAccessibility: { isPublic: false }, - UpdateBoltwallAccessibility: { success: true }, - ListPaidEndpoint: { - endpoints: [ - { id: 1, route: "v2/search", method: "GET", status: true, fee: 10 }, - { id: 2, route: "node/content", method: "POST", status: true, fee: 10 }, - ], - }, - UpdatePaidEndpoint: { success: true }, + // Boltwall admin + GetBoltwallSuperAdmin: { pubkey: null, name: null }, + ListAdmins: { admins: [] }, + GetBotBalance: { balance: 0 }, + CreateBotInvoice: { invoice: "lnbcrt1mock000000000" }, + AddBoltwallUser: { success: true }, + AddBoltwallAdminPubkey: { success: true }, + DeleteSubAdmin: { success: true }, + UpdateUser: { success: true }, + GetEnrichedBoltwallUsers: { users: [] }, + UpdateNeo4jConfig: { success: true }, + UpdateEnv: { success: true }, }; /** @@ -53,7 +65,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "Missing txt param" }, { status: 400 }); } - let parsed: { cmd?: string; data?: { cmd?: string } }; + let parsed: { type?: string; cmd?: string; data?: { cmd?: string; content?: unknown }; content?: unknown }; try { parsed = JSON.parse(txt); } catch { @@ -63,7 +75,44 @@ export async function GET(request: NextRequest) { // SwarmCmd shape: { type: "Swarm", data: { cmd: "..." } } // Fall back to top-level .cmd for legacy callers const cmd = parsed.data?.cmd ?? parsed.cmd; - if (!cmd || !(cmd in MOCK_RESPONSES)) { + const content = parsed.data?.content ?? parsed.content; + + if (!cmd) { + return NextResponse.json({ error: "Unknown cmd: undefined" }, { status: 400 }); + } + + // --------------------------------------------------------------------------- + // Stateful boltwall commands + // --------------------------------------------------------------------------- + if (cmd === "GetBoltwallAccessibility") { + return NextResponse.json({ isPublic: mockIsPublic }); + } + + if (cmd === "UpdateBoltwallAccessibility") { + setMockIsPublic(Boolean(content)); + return NextResponse.json({ success: true }); + } + + if (cmd === "ListPaidEndpoint") { + return NextResponse.json({ endpoints: mockEndpoints }); + } + + if (cmd === "UpdatePaidEndpoint") { + const update = content as { id?: number; status?: boolean } | undefined; + if (update?.id !== undefined) { + setMockEndpoints( + mockEndpoints.map((ep) => + ep.id === update.id ? { ...ep, status: Boolean(update.status) } : ep + ) + ); + } + return NextResponse.json({ success: true }); + } + + // --------------------------------------------------------------------------- + // Static responses + // --------------------------------------------------------------------------- + if (!(cmd in MOCK_RESPONSES)) { return NextResponse.json({ error: `Unknown cmd: ${cmd}` }, { status: 400 }); } diff --git a/src/app/api/mock/swarm-super-admin/api/cmd/state.ts b/src/app/api/mock/swarm-super-admin/api/cmd/state.ts new file mode 100644 index 0000000000..31aa17f865 --- /dev/null +++ b/src/app/api/mock/swarm-super-admin/api/cmd/state.ts @@ -0,0 +1,32 @@ +// --------------------------------------------------------------------------- +// Mutable boltwall state (reset between tests via resetMockBoltwallState) +// --------------------------------------------------------------------------- +export interface PaidEndpoint { + id: number; + route: string; + method: string; + status: boolean; + fee: number; +} + +export let mockIsPublic = false; +export let mockEndpoints: PaidEndpoint[] = [ + { id: 1, route: "v2/search", method: "GET", status: true, fee: 10 }, + { id: 2, route: "node/content", method: "POST", status: true, fee: 10 }, +]; + +export function resetMockBoltwallState() { + mockIsPublic = false; + mockEndpoints = [ + { id: 1, route: "v2/search", method: "GET", status: true, fee: 10 }, + { id: 2, route: "node/content", method: "POST", status: true, fee: 10 }, + ]; +} + +export function setMockIsPublic(value: boolean) { + mockIsPublic = value; +} + +export function setMockEndpoints(endpoints: PaidEndpoint[]) { + mockEndpoints = endpoints; +} diff --git a/src/lib/auth/nextauth.ts b/src/lib/auth/nextauth.ts index 3aba45c40e..738772b152 100644 --- a/src/lib/auth/nextauth.ts +++ b/src/lib/auth/nextauth.ts @@ -1,7 +1,7 @@ import { db } from "@/lib/db"; import { EncryptionService } from "@/lib/encryption"; import { logger } from "@/lib/logger"; -import { ensureMockWorkspaceForUser, ensureStakworkMockWorkspace, ensureMockOrgData } from "@/utils/mockSetup"; +import { ensureMockWorkspaceForUser, ensureStakworkMockWorkspace, ensureMockOrgData, ensureGraphMindsetMockWorkspace } from "@/utils/mockSetup"; import { isSuperAdminUserId } from "@/config/env"; import { PrismaAdapter } from "@auth/prisma-adapter"; import axios from "axios"; @@ -271,6 +271,17 @@ export const authOptions: NextAuthOptions = { error ); } + + // Create graph_mindset workspace for testing /graph-admin — non-fatal + try { + await ensureGraphMindsetMockWorkspace(user.id as string); + } catch (error) { + logger.authError( + "Failed to create graph_mindset mock workspace - continuing authentication", + "SIGNIN_GRAPH_MINDSET_FAILED", + error + ); + } } catch (error) { logger.authError("Failed to handle mock authentication", "SIGNIN_MOCK", error); return false; diff --git a/src/services/swarm/cmd.ts b/src/services/swarm/cmd.ts index 676d3a3539..9eecde0249 100644 --- a/src/services/swarm/cmd.ts +++ b/src/services/swarm/cmd.ts @@ -33,6 +33,10 @@ export interface SwarmCmdResponse { } function getCmdBaseUrlFromSwarmUrl(swarmUrl: string): string { + if (process.env.USE_MOCKS === "true") { + const mockBase = process.env.NEXTAUTH_URL || "http://localhost:3000"; + return `${mockBase}/api/mock/swarm-super-admin`; + } const url = new URL(swarmUrl); return `${url.protocol}//${url.hostname}:8800`; } @@ -44,6 +48,7 @@ function getCmdBaseUrlFromSwarmUrl(swarmUrl: string): string { export async function getSwarmCmdJwt(swarmUrl: string, swarmPassword: string, username = "admin"): Promise { const baseUrl = getCmdBaseUrlFromSwarmUrl(swarmUrl); const loginUrl = `${baseUrl}/api/login`; + // Note: in mock mode (USE_MOCKS=true) loginUrl points to the mock login endpoint const allowInsecure = process.env.SWARM_CMD_ALLOW_INSECURE === "true"; const previousTlsSetting = process.env.NODE_TLS_REJECT_UNAUTHORIZED; @@ -98,7 +103,9 @@ export async function swarmCmdRequest({ tag?: string; }): Promise { const baseUrl = getCmdBaseUrlFromSwarmUrl(swarmUrl); - const url = new URL("/api/cmd", baseUrl); + // Use string concatenation so the path from baseUrl (e.g. mock path) is preserved. + // new URL("/api/cmd", base) would strip the base path for root-relative paths. + const url = new URL(`${baseUrl}/api/cmd`); url.searchParams.set("txt", JSON.stringify(cmd)); url.searchParams.set("tag", tag); @@ -120,6 +127,10 @@ export async function swarmCmdRequest({ let data: unknown = undefined; try { data = rawText ? JSON.parse(rawText) : undefined; + // Handle sphinx-swarm double-encoded JSON responses (string wrapping a JSON object/array) + if (typeof data === "string") { + try { data = JSON.parse(data); } catch { /* leave as string */ } + } } catch { data = undefined; } diff --git a/src/utils/mockSetup.ts b/src/utils/mockSetup.ts index 0a78362525..e18563b01d 100644 --- a/src/utils/mockSetup.ts +++ b/src/utils/mockSetup.ts @@ -54,6 +54,7 @@ export async function ensureMockWorkspaceForUser( let encryptedPoolApiKey: string | null = null; let encryptedGitHubToken: string | null = null; let encryptedGitHubRefreshToken: string | null = null; + let encryptedSwarmPassword: string | null = null; try { const encryptionService = EncryptionService.getInstance(); @@ -66,6 +67,9 @@ export async function ensureMockWorkspaceForUser( encryptedGitHubRefreshToken = JSON.stringify( encryptionService.encryptField("refresh_token", `ghr_mock_refresh_${mockGitHubUserId}`) ); + encryptedSwarmPassword = JSON.stringify( + encryptionService.encryptField("swarmPassword", "mock-swarm-password") + ); } catch { // Encryption not available (e.g., TOKEN_ENCRYPTION_KEY not set) // This is fine for E2E tests - mocks will work without encrypted keys @@ -187,6 +191,7 @@ export async function ensureMockWorkspaceForUser( podState: PodState.COMPLETED, // Skip "Validating..." message for mock users poolName: "mock-pool", poolApiKey: encryptedPoolApiKey, // Mock pool API key for Pool Manager mock + swarmPassword: encryptedSwarmPassword, // Mock swarm password for cmd API }, }); @@ -241,6 +246,7 @@ export async function ensureStakworkMockWorkspace( let encryptedPoolApiKey: string | null = null; let encryptedGitHubToken: string | null = null; let encryptedGitHubRefreshToken: string | null = null; + let encryptedSwarmPassword: string | null = null; try { const encryptionService = EncryptionService.getInstance(); @@ -253,6 +259,9 @@ export async function ensureStakworkMockWorkspace( encryptedGitHubRefreshToken = JSON.stringify( encryptionService.encryptField("refresh_token", `ghr_mock_stakwork_refresh_${mockGitHubUserId}`) ); + encryptedSwarmPassword = JSON.stringify( + encryptionService.encryptField("swarmPassword", "mock-swarm-password") + ); } catch { // Encryption not available (e.g., TOKEN_ENCRYPTION_KEY not set) // This is fine for E2E tests - mocks will work without encrypted keys @@ -385,6 +394,7 @@ export async function ensureStakworkMockWorkspace( podState: PodState.COMPLETED, // Skip "Validating..." message for mock users poolName: "mock-stakwork-pool", poolApiKey: encryptedPoolApiKey, // Mock pool API key for Pool Manager mock + swarmPassword: encryptedSwarmPassword, // Mock swarm password for cmd API }, }); @@ -403,6 +413,85 @@ export async function ensureStakworkMockWorkspace( return workspace.slug; } +/** + * Ensures a graph_mindset workspace exists for a given user. + * This enables testing of /graph-admin features in mock mode. + * Returns the workspace slug. + */ +export async function ensureGraphMindsetMockWorkspace( + userId: string, +): Promise { + const GRAPH_MINDSET_SLUG = "mock-graph-mindset"; + + const existing = await db.workspace.findFirst({ + where: { ownerId: userId, workspaceKind: "graph_mindset", deleted: false }, + select: { id: true, slug: true }, + }); + + if (existing?.slug) return existing.slug; + + let encryptedSwarmPassword: string | null = null; + try { + const encryptionService = EncryptionService.getInstance(); + encryptedSwarmPassword = JSON.stringify( + encryptionService.encryptField("swarmPassword", "mock-swarm-password") + ); + } catch { + // Encryption not available — swarmPassword will be null + } + + let slugCandidate = GRAPH_MINDSET_SLUG; + let suffix = 1; + while (await db.workspace.findUnique({ where: { slug: slugCandidate } })) { + slugCandidate = `${GRAPH_MINDSET_SLUG}-${++suffix}`; + } + + const workspace = await db.$transaction(async (tx) => { + const workspace = await tx.workspace.create({ + data: { + name: "Mock Graph Mindset", + description: "Development workspace for graph_mindset features (mock)", + slug: slugCandidate, + ownerId: userId, + workspaceKind: "graph_mindset", + logoUrl: `https://api.dicebear.com/7.x/identicons/svg?seed=${encodeURIComponent(slugCandidate)}`, + logoKey: null, + }, + select: { id: true, slug: true }, + }); + + await tx.workspaceMember.create({ + data: { + workspaceId: workspace.id, + userId, + role: "OWNER", + joinedAt: new Date(), + }, + }); + + await tx.swarm.create({ + data: { + name: slugify(`${workspace.slug}-swarm`), + status: SwarmStatus.ACTIVE, + instanceType: "XL", + environmentVariables: [], + services: [], + workspaceId: workspace.id, + swarmUrl: "http://localhost", + containerFilesSetUp: true, + poolState: PoolState.COMPLETE, + podState: PodState.COMPLETED, + poolName: "mock-graph-mindset-pool", + swarmPassword: encryptedSwarmPassword, + }, + }); + + return workspace; + }); + + return workspace.slug; +} + /** * Ensures a second "mock-org" SourceControlOrg (type=ORG) exists with 2 workspaces and 2 team * members, giving the org page meaningful multi-workspace data. From 80b721831bef2278ad71a4a868257197646f89ac Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Thu, 9 Apr 2026 10:50:41 +0000 Subject: [PATCH 2/5] fix: update GetBotBalance test to match new response shape --- src/__tests__/unit/api/mock/swarm-credentials-and-cmd.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/unit/api/mock/swarm-credentials-and-cmd.test.ts b/src/__tests__/unit/api/mock/swarm-credentials-and-cmd.test.ts index dda1a30750..c2c91ffda3 100644 --- a/src/__tests__/unit/api/mock/swarm-credentials-and-cmd.test.ts +++ b/src/__tests__/unit/api/mock/swarm-credentials-and-cmd.test.ts @@ -330,11 +330,11 @@ describe("Boltwall stateful mock commands", () => { expect(data).toEqual({ pubkey: null, name: null }); }); - it("GetBotBalance returns { balance: 0 }", async () => { + it("GetBotBalance returns { success: true, data: { msat } }", async () => { const res = await cmdRequest({ cmd: "GetBotBalance" }); const data = await res.json(); expect(res.status).toBe(200); - expect(data).toEqual({ balance: 0 }); + expect(data).toEqual({ success: true, message: "bot balance retrieved", data: { msat: 30000 } }); }); it("CreateBotInvoice returns an invoice string", async () => { From ce151e3d98860b4dbe505a8eb1a23949778ead8e Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Tue, 14 Apr 2026 18:42:55 +0000 Subject: [PATCH 3/5] Fix flaky test: wait for model select to render before asserting --- .../components/features/CompactTasksList.test.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/__tests__/unit/components/features/CompactTasksList.test.tsx b/src/__tests__/unit/components/features/CompactTasksList.test.tsx index a11e35927f..067a1fb1cf 100644 --- a/src/__tests__/unit/components/features/CompactTasksList.test.tsx +++ b/src/__tests__/unit/components/features/CompactTasksList.test.tsx @@ -1611,18 +1611,12 @@ describe("CompactTasksList", () => { /> ); + // Wait until the model select (data-value="") is present, meaning models have loaded await waitFor(() => { - // Models should be loaded - find the model select by its empty value const selects = screen.getAllByTestId("select"); - expect(selects.length).toBeGreaterThanOrEqual(1); + const modelSelect = selects.find((s) => s.getAttribute("data-value") === ""); + expect(modelSelect).toBeDefined(); }); - - // Simulate onValueChange being called with a model value - // The Select mock renders a div with data-testid="select" and data-value - // We need to find the model select - it's the one with value="" (task.model is null) - const selects = screen.getAllByTestId("select"); - const modelSelect = selects.find((s) => s.getAttribute("data-value") === ""); - expect(modelSelect).toBeDefined(); }); test("shows existing model value in selector", async () => { From 2ed94b3d25a357fd83bfe53649b1140de1ef43e1 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Thu, 30 Apr 2026 16:17:38 +0000 Subject: [PATCH 4/5] Generated with Hive: Improve Neon endpoint detection and polling in deploy preview CI workflow --- .github/workflows/deployPR.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deployPR.yml b/.github/workflows/deployPR.yml index 5afde46212..0ba7385f19 100644 --- a/.github/workflows/deployPR.yml +++ b/.github/workflows/deployPR.yml @@ -155,6 +155,16 @@ jobs: ENDPOINTS_JSON=$(neon_curl "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints" "endpoints") ENDPOINT_ID=$(echo "$ENDPOINTS_JSON" | jq -r ".endpoints[] | select(.branch_id==\"$BRANCH_ID\") | .id") + # Validate the cached endpoint is still accessible (it may have been deleted since list was fetched) + if [ -n "$ENDPOINT_ID" ] && [ "$ENDPOINT_ID" != "null" ]; then + VALIDATE_RESP=$(curl -s -H "Authorization: Bearer $NEON_API_KEY" \ + "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints/$ENDPOINT_ID") + if ! echo "$VALIDATE_RESP" | jq -e '.endpoint' > /dev/null 2>&1; then + echo "⚠️ Endpoint $ENDPOINT_ID found in list but no longer accessible, will create a new one" + ENDPOINT_ID="" + fi + fi + if [ -z "$ENDPOINT_ID" ] || [ "$ENDPOINT_ID" = "null" ]; then # Try to create a new endpoint first RESPONSE=$(curl -s -H "Authorization: Bearer $NEON_API_KEY" \ @@ -192,7 +202,11 @@ jobs: # Wait until endpoint is ready (GET endpoint does not return credentials) for i in {1..30}; do - STATUS=$(neon_curl "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints/$ENDPOINT_ID" "endpoint") + STATUS=$(neon_curl "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints/$ENDPOINT_ID" "endpoint") || { + echo "⚠️ Could not poll endpoint status (attempt $i), retrying..." + sleep 5 + continue + } STATE=$(echo "$STATUS" | jq -r '.endpoint.current_state') DB_HOST=$(echo "$STATUS" | jq -r '.endpoint.host') if [ "$STATE" = "ready" ] && [ -n "$DB_HOST" ] && [ "$DB_HOST" != "null" ]; then From 1175a175eb138fb6832baba3553c0640efe058d0 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Fri, 1 May 2026 05:50:11 +0000 Subject: [PATCH 5/5] Generated with Hive: Increase notification test timeout to fix async DB query failure in webhook PR merged integration test --- .../api/github/webhook-pr-merged-notification.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/integration/api/github/webhook-pr-merged-notification.test.ts b/src/__tests__/integration/api/github/webhook-pr-merged-notification.test.ts index 259d3a3a44..f04d96cc11 100644 --- a/src/__tests__/integration/api/github/webhook-pr-merged-notification.test.ts +++ b/src/__tests__/integration/api/github/webhook-pr-merged-notification.test.ts @@ -132,8 +132,8 @@ describe("GitHub Webhook — TASK_PR_MERGED notification", () => { // Route should succeed expect([200, 202]).toContain(res.status); - // Allow async notification to settle - await new Promise((r) => setTimeout(r, 300)); + // Allow async notification to settle (fire-and-forget block runs several DB queries) + await new Promise((r) => setTimeout(r, 2000)); const record = await db.notificationTrigger.findFirst({ where: {