Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@
# --- App ---
# NODE_ENV=development
# APP_URL=http://localhost:3000
# Web → API base URL used by Next.js Route Handlers when proxying to Fastify.
# Defaults to http://localhost:4000 when unset.
# LEARNPRO_API_URL=http://localhost:4000
6 changes: 5 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@learnpro/sandbox": "workspace:*",
"@learnpro/shared": "workspace:*",
"@monaco-editor/react": "^4.6.0",
"monaco-editor": "^0.52.2",
"next": "^15.1.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@next/eslint-plugin-next": "^15.1.3",
Expand Down
124 changes: 124 additions & 0 deletions apps/web/src/app/api/sandbox/run/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { POST } from "./route";

function postRequest(body: unknown): Request {
return new Request("http://localhost/api/sandbox/run", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}

describe("POST /api/sandbox/run (Next.js Route Handler)", () => {
const realFetch = globalThis.fetch;

beforeEach(() => {
process.env["LEARNPRO_API_URL"] = "http://api.test";
});

afterEach(() => {
globalThis.fetch = realFetch;
delete process.env["LEARNPRO_API_URL"];
});

it("forwards a valid request to the API and returns the upstream response", async () => {
const upstreamBody = JSON.stringify({
stdout: "hi\n",
stderr: "",
exit_code: 0,
duration_ms: 7,
killed_by: null,
language: "python",
runtime_version: "3.10.0",
});
const fakeFetch = vi.fn().mockResolvedValue(
new Response(upstreamBody, {
status: 200,
headers: { "content-type": "application/json" },
}),
);
globalThis.fetch = fakeFetch as unknown as typeof fetch;

const res = await POST(postRequest({ language: "python", code: "print('hi')" }));

expect(res.status).toBe(200);
const json = await res.json();
expect(json.stdout).toBe("hi\n");
expect(fakeFetch).toHaveBeenCalledWith(
"http://api.test/sandbox/run",
expect.objectContaining({ method: "POST" }),
);
const fwBody = JSON.parse(
(fakeFetch.mock.calls[0]?.[1] as RequestInit).body as string,
) as Record<string, unknown>;
expect(fwBody["language"]).toBe("python");
expect(fwBody["code"]).toBe("print('hi')");
});

it("returns 400 with error=invalid_json when the body is not JSON", async () => {
const req = new Request("http://localhost/api/sandbox/run", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const json = (await res.json()) as { error: string };
expect(json.error).toBe("invalid_json");
});

it("returns 400 with error=invalid_request when the body fails zod validation", async () => {
const res = await POST(postRequest({ language: "rust", code: "" }));
expect(res.status).toBe(400);
const json = (await res.json()) as { error: string };
expect(json.error).toBe("invalid_request");
});

it("returns 502 with error=api_unreachable when the upstream fetch throws", async () => {
globalThis.fetch = vi
.fn()
.mockRejectedValue(new Error("connect ECONNREFUSED")) as unknown as typeof fetch;

const res = await POST(postRequest({ language: "python", code: "print(1)" }));
expect(res.status).toBe(502);
const json = (await res.json()) as { error: string; message: string };
expect(json.error).toBe("api_unreachable");
expect(json.message).toContain("ECONNREFUSED");
});

it("propagates upstream non-2xx status codes through to the client", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "sandbox_unavailable", message: "piston down" }), {
status: 502,
headers: { "content-type": "application/json" },
}),
) as unknown as typeof fetch;

const res = await POST(postRequest({ language: "python", code: "print(1)" }));
expect(res.status).toBe(502);
const json = (await res.json()) as { error: string };
expect(json.error).toBe("sandbox_unavailable");
});

it("defaults to http://localhost:4000 when LEARNPRO_API_URL is unset", async () => {
delete process.env["LEARNPRO_API_URL"];
const fakeFetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
stdout: "ok",
stderr: "",
exit_code: 0,
duration_ms: 1,
killed_by: null,
language: "python",
runtime_version: "3.10.0",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
globalThis.fetch = fakeFetch as unknown as typeof fetch;

await POST(postRequest({ language: "python", code: "print(1)" }));
expect(fakeFetch).toHaveBeenCalledWith("http://localhost:4000/sandbox/run", expect.any(Object));
});
});
50 changes: 50 additions & 0 deletions apps/web/src/app/api/sandbox/run/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { SandboxRunRequestSchema } from "@learnpro/sandbox";

export const runtime = "nodejs";

const DEFAULT_API_URL = "http://localhost:4000";

export async function POST(req: Request) {
let json: unknown;
try {
json = await req.json();
} catch {
return NextResponse.json(
{ error: "invalid_json", message: "Request body must be valid JSON." },
{ status: 400 },
);
}

const parsed = SandboxRunRequestSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ error: "invalid_request", issues: parsed.error.issues },
{ status: 400 },
);
}

const apiUrl = process.env["LEARNPRO_API_URL"] ?? DEFAULT_API_URL;
let upstream: Response;
try {
upstream = await fetch(`${apiUrl}/sandbox/run`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(parsed.data),
});
} catch (err) {
return NextResponse.json(
{
error: "api_unreachable",
message: err instanceof Error ? err.message : String(err),
},
{ status: 502 },
);
}

const body = await upstream.text();
return new NextResponse(body, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
}
11 changes: 8 additions & 3 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ export default function HomePage() {
<main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<h1>LearnPro</h1>
<p>Adaptive AI-tutored self-hosted learning platform.</p>
<p>
Skeleton scaffold (STORY-052). See <a href="/health">/health</a> for the smoke check.
</p>
<ul>
<li>
<a href="/playground">/playground</a> — run Python or TypeScript in the sandbox
</li>
<li>
<a href="/health">/health</a> — service smoke check
</li>
</ul>
</main>
);
}
Loading
Loading