fix(issue-7): revert dev artifact and fix test timeouts

- Revert next.config.ts: remove hardcoded Cloudflare tunnel URL from
  allowedDevOrigins (dev artifact, not part of the voice upload fix).
- Rewrite voice transcribe tests to use mock request objects instead of
  real Request/FormData/Blob instances that cause formData() to hang
  indefinitely in vitest's Node environment. All 9 tests now pass.

Made-with: Cursor
This commit is contained in:
iamlukethedev
2026-03-27 13:41:44 -05:00
committed by iamlukethedev
parent fdc7a4223a
commit 456cfae771
2 changed files with 62 additions and 81 deletions
+1 -5
View File
@@ -1,9 +1,5 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {};
allowedDevOrigins: [
"https://awareness-peninsula-laden-stanley.trycloudflare.com",
],
};
export default nextConfig; export default nextConfig;
+61 -76
View File
@@ -2,12 +2,15 @@
* Tests for the voice transcription API route — focusing on the upload size * Tests for the voice transcription API route — focusing on the upload size
* limit that must be enforced BEFORE the request body is buffered into memory * limit that must be enforced BEFORE the request body is buffered into memory
* (issue #7 fix). * (issue #7 fix).
*
* Uses mock request objects instead of real Request/FormData to avoid
* vitest environment issues where Request.formData() hangs on Blob bodies.
*/ */
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Module mocks — must be hoisted before the route import // Module mocks — must be hoisted before the route import.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
vi.mock("@/lib/openclaw/voiceTranscription", () => ({ vi.mock("@/lib/openclaw/voiceTranscription", () => ({
@@ -28,35 +31,37 @@ const { MAX_VOICE_UPLOAD_BYTES, POST } = await import(
"@/app/api/office/voice/transcribe/route" "@/app/api/office/voice/transcribe/route"
); );
/** Build a minimal multipart/form-data Request with an audio file blob. */ /** Create a File-like object that passes the route's duck-typing check. */
function buildAudioRequest( function makeAudioFile(byteLength: number) {
fileSizeBytes: number, return {
options: { contentLengthOverride?: number | null } = {}, arrayBuffer: () => Promise.resolve(new ArrayBuffer(byteLength)),
): Request { name: "voice.webm",
const audioBlob = new Blob([new Uint8Array(fileSizeBytes)], { type: "audio/webm" }); type: "audio/webm",
const formData = new FormData(); };
formData.append("audio", audioBlob, "voice.webm");
// Build headers
const headers: Record<string, string> = {};
if (options.contentLengthOverride !== undefined && options.contentLengthOverride !== null) {
headers["content-length"] = String(options.contentLengthOverride);
}
return new Request("http://localhost/api/office/voice/transcribe", {
method: "POST",
body: formData,
headers,
});
} }
/** Build a Request with no audio field in the form. */ /**
function buildNoAudioRequest(): Request { * Build a mock Request whose headers and formData are fully controlled,
const formData = new FormData(); * avoiding the real Request/Blob path that hangs in vitest.
return new Request("http://localhost/api/office/voice/transcribe", { */
method: "POST", function mockRequest(opts: {
body: formData, contentLength?: string;
}); audioFile?: ReturnType<typeof makeAudioFile> | null;
}): Request {
const headersMap = new Map<string, string>();
if (opts.contentLength !== undefined) {
headersMap.set("content-length", opts.contentLength);
}
const audio = opts.audioFile ?? null;
const fakeFormData = {
get: (key: string) => (key === "audio" ? audio : null),
};
return {
headers: { get: (name: string) => headersMap.get(name) ?? null },
formData: () => Promise.resolve(fakeFormData),
} as unknown as Request;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -68,22 +73,18 @@ describe("POST /api/office/voice/transcribe — size limit enforcement (issue #7
vi.clearAllMocks(); vi.clearAllMocks();
}); });
// ── Content-Length early rejection ────────────────────────────────────────
// The early Content-Length check uses MAX_VOICE_UPLOAD_BYTES + 1024 as its // The early Content-Length check uses MAX_VOICE_UPLOAD_BYTES + 1024 as its
// threshold because multipart/form-data requests include boundary/header // threshold because multipart/form-data requests include boundary/header
// overhead on top of the raw audio bytes. A request at exactly // overhead on top of the raw audio bytes.
// MAX_VOICE_UPLOAD_BYTES + 1 could still contain a valid audio file — the
// post-buffer check (which measures actual bytes) is the authoritative limit.
// The early check only rejects requests that are obviously too large.
const MULTIPART_OVERHEAD_ALLOWANCE = 1024; const MULTIPART_OVERHEAD_ALLOWANCE = 1024;
it("returns 413 immediately when Content-Length clearly exceeds the limit + overhead allowance", async () => { // ── Content-Length early rejection ────────────────────────────────────────
it("returns 413 immediately when Content-Length clearly exceeds the limit + overhead", async () => {
const oversizeBytes = MAX_VOICE_UPLOAD_BYTES + MULTIPART_OVERHEAD_ALLOWANCE + 1; const oversizeBytes = MAX_VOICE_UPLOAD_BYTES + MULTIPART_OVERHEAD_ALLOWANCE + 1;
const request = buildAudioRequest(1, { const request = mockRequest({
// Lie about size — we want to confirm the header check fires even when contentLength: String(oversizeBytes),
// the actual payload is small (verifying header-based early rejection). audioFile: makeAudioFile(1),
contentLengthOverride: oversizeBytes,
}); });
const response = await POST(request); const response = await POST(request);
@@ -94,52 +95,39 @@ describe("POST /api/office/voice/transcribe — size limit enforcement (issue #7
}); });
it("does NOT reject early when Content-Length is MAX + 1 (within multipart overhead allowance)", async () => { it("does NOT reject early when Content-Length is MAX + 1 (within multipart overhead allowance)", async () => {
// MAX_VOICE_UPLOAD_BYTES + 1 is within the multipart overhead window — const request = mockRequest({
// the actual audio file may still be within the limit. The early check contentLength: String(MAX_VOICE_UPLOAD_BYTES + 1),
// should pass; the post-buffer check is the authoritative limit. audioFile: makeAudioFile(1),
const request = buildAudioRequest(1, {
contentLengthOverride: MAX_VOICE_UPLOAD_BYTES + 1,
}); });
const response = await POST(request); const response = await POST(request);
// Should NOT return 413 from the early header check (body is 1 byte, fine).
expect(response.status).not.toBe(413); expect(response.status).not.toBe(413);
}); });
it("does NOT reject when Content-Length equals MAX_VOICE_UPLOAD_BYTES exactly", async () => { it("does NOT reject when Content-Length equals MAX_VOICE_UPLOAD_BYTES exactly", async () => {
// The actual body is tiny; we're testing the header path only here. const request = mockRequest({
const request = buildAudioRequest(1, { contentLength: String(MAX_VOICE_UPLOAD_BYTES),
contentLengthOverride: MAX_VOICE_UPLOAD_BYTES, audioFile: makeAudioFile(1),
}); });
const response = await POST(request); const response = await POST(request);
// Should not be a 413 from the header check (actual body is 1 byte, fine).
expect(response.status).not.toBe(413); expect(response.status).not.toBe(413);
}); });
// ── No Content-Length header — handled gracefully ───────────────────────── // ── No Content-Length header — handled gracefully ─────────────────────────
it("proceeds normally when Content-Length header is absent and file is within limit", async () => { it("proceeds normally when Content-Length header is absent and file is within limit", async () => {
// Small valid audio; no content-length header at all. const request = mockRequest({ audioFile: makeAudioFile(1024) });
const request = buildAudioRequest(1024 /* 1 KB */);
const response = await POST(request); const response = await POST(request);
// Should succeed (mocked transcription returns 200).
expect(response.status).toBe(200); expect(response.status).toBe(200);
const body = await response.json(); const body = await response.json();
expect(body.transcript).toBe("hello world"); expect(body.transcript).toBe("hello world");
}); });
it("returns 413 after buffering when Content-Length is absent but body exceeds limit", async () => { it("returns 413 after buffering when Content-Length is absent but body exceeds limit", async () => {
// Build a real oversized body with no content-length header. const request = mockRequest({
// We use MAX_VOICE_UPLOAD_BYTES + 1 bytes to trigger the post-buffer check. audioFile: makeAudioFile(MAX_VOICE_UPLOAD_BYTES + 1),
const oversizeBytes = MAX_VOICE_UPLOAD_BYTES + 1;
const audioBlob = new Blob([new Uint8Array(oversizeBytes)], { type: "audio/webm" });
const formData = new FormData();
formData.append("audio", audioBlob, "big.webm");
const request = new Request("http://localhost/api/office/voice/transcribe", {
method: "POST",
body: formData,
// No content-length header — the post-buffer check must catch this.
}); });
const response = await POST(request); const response = await POST(request);
@@ -151,9 +139,9 @@ describe("POST /api/office/voice/transcribe — size limit enforcement (issue #7
// ── Normal happy path ───────────────────────────────────────────────────── // ── Normal happy path ─────────────────────────────────────────────────────
it("returns 200 with transcript for a valid upload within the size limit", async () => { it("returns 200 with transcript for a valid upload within the size limit", async () => {
const request = buildAudioRequest(4096 /* 4 KB */); const request = mockRequest({ audioFile: makeAudioFile(4096) });
const response = await POST(request);
const response = await POST(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const body = await response.json(); const body = await response.json();
expect(body).toMatchObject({ expect(body).toMatchObject({
@@ -166,14 +154,17 @@ describe("POST /api/office/voice/transcribe — size limit enforcement (issue #7
// ── Edge cases ──────────────────────────────────────────────────────────── // ── Edge cases ────────────────────────────────────────────────────────────
it("returns 400 when no audio field is present in the form", async () => { it("returns 400 when no audio field is present in the form", async () => {
const response = await POST(buildNoAudioRequest()); const request = mockRequest({ audioFile: null });
const response = await POST(request);
expect(response.status).toBe(400); expect(response.status).toBe(400);
const body = await response.json(); const body = await response.json();
expect(body.error).toMatch(/audio file is required/i); expect(body.error).toMatch(/audio file is required/i);
}); });
it("returns 400 for an empty audio file (0 bytes)", async () => { it("returns 400 for an empty audio file (0 bytes)", async () => {
const request = buildAudioRequest(0); const request = mockRequest({ audioFile: makeAudioFile(0) });
const response = await POST(request); const response = await POST(request);
expect(response.status).toBe(400); expect(response.status).toBe(400);
const body = await response.json(); const body = await response.json();
@@ -181,17 +172,11 @@ describe("POST /api/office/voice/transcribe — size limit enforcement (issue #7
}); });
it("ignores a malformed (non-numeric) Content-Length header and falls through", async () => { it("ignores a malformed (non-numeric) Content-Length header and falls through", async () => {
const audioBlob = new Blob([new Uint8Array(512)], { type: "audio/webm" }); const request = mockRequest({
const formData = new FormData(); contentLength: "not-a-number",
formData.append("audio", audioBlob, "voice.webm"); audioFile: makeAudioFile(512),
const request = new Request("http://localhost/api/office/voice/transcribe", {
method: "POST",
body: formData,
headers: { "content-length": "not-a-number" },
}); });
// Should NOT blow up; header is NaN so we skip the early check and proceed.
const response = await POST(request); const response = await POST(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
}); });