Skip to content

Commit 23043f1

Browse files
sweetmantechclaude
andcommitted
refactor(sandbox): extract noSandboxResponse to its own file (SRP)
Per review feedback on PR #525 — pulls the inline `noSandboxResponse` helper out of `getSandboxReconnectHandler.ts` into its own file so it can be reused by future endpoints (e.g., `/snapshot` resume) and so the handler file stops carrying response-shape construction logic. The narrowed `ReconnectBody` type in the handler now only covers the two outcomes the handler actually constructs locally (`connected` / `expired`); the `no_sandbox` shape lives with its builder. TDD red -> green: 3 unit tests for the extracted helper covering 200 status, hasSnapshot derivation, and lifecycle envelope projection. Suite: 2526 -> 2529. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c66f12f commit 23043f1

3 files changed

Lines changed: 79 additions & 11 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { NextResponse } from "next/server";
3+
import { noSandboxResponse } from "@/lib/sandbox/noSandboxResponse";
4+
5+
vi.mock("@/lib/networking/getCorsHeaders", () => ({
6+
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
7+
}));
8+
9+
const baseRow = {
10+
id: "sess-1",
11+
account_id: "acc-1",
12+
sandbox_state: null,
13+
lifecycle_state: null,
14+
lifecycle_version: 0,
15+
sandbox_expires_at: null,
16+
hibernate_after: null,
17+
last_activity_at: null,
18+
snapshot_url: null,
19+
};
20+
21+
describe("noSandboxResponse", () => {
22+
it("returns a 200 NextResponse with status='no_sandbox'", async () => {
23+
const res = noSandboxResponse(baseRow as never);
24+
25+
expect(res).toBeInstanceOf(NextResponse);
26+
expect(res.status).toBe(200);
27+
const body = await res.json();
28+
expect(body.status).toBe("no_sandbox");
29+
});
30+
31+
it("derives hasSnapshot from snapshot_url presence", async () => {
32+
const withSnap = await noSandboxResponse({
33+
...baseRow,
34+
snapshot_url: "snap://x",
35+
} as never).json();
36+
const without = await noSandboxResponse(baseRow as never).json();
37+
38+
expect(withSnap.hasSnapshot).toBe(true);
39+
expect(without.hasSnapshot).toBe(false);
40+
});
41+
42+
it("includes the lifecycle envelope projected from the row", async () => {
43+
const body = await noSandboxResponse(baseRow as never).json();
44+
45+
expect(body.lifecycle).toMatchObject({
46+
serverTime: expect.any(Number),
47+
state: null,
48+
lastActivityAt: null,
49+
hibernateAfter: null,
50+
sandboxExpiresAt: null,
51+
});
52+
});
53+
});

lib/sandbox/getSandboxReconnectHandler.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,20 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext";
44
import { buildLifecycle } from "@/lib/sandbox/buildLifecycle";
55
import { connectSandbox } from "@/lib/sandbox/factory";
66
import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState";
7+
import { noSandboxResponse } from "@/lib/sandbox/noSandboxResponse";
78
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
89
import { updateSession } from "@/lib/supabase/sessions/updateSession";
910
import type { SandboxState } from "@/lib/sandbox/factory";
10-
import type { Tables } from "@/types/database.types";
1111

1212
const PROBE_TIMEOUT_MS = 15_000;
1313

1414
interface ReconnectBody {
15-
status: "connected" | "expired" | "no_sandbox";
15+
status: "connected" | "expired";
1616
hasSnapshot: boolean;
1717
expiresAt?: number;
1818
lifecycle: ReturnType<typeof buildLifecycle>;
1919
}
2020

21-
function noSandboxResponse(row: Tables<"sessions">): NextResponse {
22-
const body: ReconnectBody = {
23-
status: "no_sandbox",
24-
hasSnapshot: !!row.snapshot_url,
25-
lifecycle: buildLifecycle(row),
26-
};
27-
return NextResponse.json(body, { status: 200, headers: getCorsHeaders() });
28-
}
29-
3021
/**
3122
* Handles `GET /api/sandbox/reconnect`. Live runtime probe — actually
3223
* runs a quick command inside the sandbox to verify it is reachable.

lib/sandbox/noSandboxResponse.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { buildLifecycle } from "@/lib/sandbox/buildLifecycle";
4+
import type { Tables } from "@/types/database.types";
5+
6+
/**
7+
* Builds the `status: "no_sandbox"` response shared by sandbox lifecycle
8+
* endpoints (currently `/reconnect`). Used when the session row lacks
9+
* runtime metadata — there is no live sandbox to probe, so report that
10+
* directly along with whether a snapshot exists for resume affordances.
11+
*
12+
* @param row - The `sessions` row.
13+
* @returns A 200 NextResponse with `{status, hasSnapshot, lifecycle}`.
14+
*/
15+
export function noSandboxResponse(row: Tables<"sessions">): NextResponse {
16+
return NextResponse.json(
17+
{
18+
status: "no_sandbox" as const,
19+
hasSnapshot: !!row.snapshot_url,
20+
lifecycle: buildLifecycle(row),
21+
},
22+
{ status: 200, headers: getCorsHeaders() },
23+
);
24+
}

0 commit comments

Comments
 (0)