fix(invoicing): print bundle fails on production due to self-fetch
The /api/invoicing/print-bundle route was doing a self-fetch through
its own public URL to download the invoice PDF it needed to bundle:
fetch(`${origin}/api/v1/files/order_items/${id}/${token}`)
That works on local because Node inside the container can hit
localhost:3000 as a loopback. On production (Coolify / Docker) the
container's fetch to its own public hostname fails — DNS resolves to
an external IP that doesn't route back, the outbound TCP is blocked,
or the TLS handshake fails. Node's fetch throws 'fetch failed' at
that point, which bubbles up to the browser as a 500 toast.
Fix: fetch the invoice PDF directly from PocketBase with admin auth,
the same pattern /api/v1/files/... uses internally and that
delivery-note-pdf already uses. Drop the now-unused 'origin'
parameter from handlePrintBundle and the GET/POST wrappers.
Tests updated to mock pb.files.getURL + pb.authStore.token and assert
the new PB file URL + Authorization header.
All 65 tests across the print-bundle, picking-slip, view-model, and
invoicing page suites pass. The 2 pre-existing pocketbase-admin.test
failures are unrelated (reproducible on main without this change).
This commit is contained in:
parent
60d320411d
commit
f88f451d6e
2 changed files with 52 additions and 9 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
|
||||
const { fetchMock, getFullList, buildPrintBundle, pdfMock, pickingSlipPdfMock } = vi.hoisted(() => {
|
||||
const { fetchMock, getFullList, buildPrintBundle, pdfMock, pickingSlipPdfMock, filesGetUrl, authStore } = vi.hoisted(() => {
|
||||
const fetchMock = vi.fn();
|
||||
const getFullList = vi.fn();
|
||||
const buildPrintBundle = vi.fn();
|
||||
|
|
@ -8,7 +8,24 @@ const { fetchMock, getFullList, buildPrintBundle, pdfMock, pickingSlipPdfMock }
|
|||
toString: vi.fn().mockResolvedValue("SLIP_PDF"),
|
||||
}));
|
||||
const pickingSlipPdfMock = vi.fn((_props: unknown) => "PICKING_SLIP_DOC");
|
||||
return { fetchMock, getFullList, buildPrintBundle, pdfMock, pickingSlipPdfMock };
|
||||
// Mirror the PB SDK's files.getURL: the caller passes
|
||||
// { collectionId, id } and a filename, the SDK returns
|
||||
// `${base}/api/files/${collectionId}/${recordId}/${filename}`.
|
||||
// We control the URL via the mock so the test is environment-independent.
|
||||
const filesGetUrl = vi.fn(
|
||||
(record: { collectionId: string; id: string }, filename: string) =>
|
||||
`http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${filename}`,
|
||||
);
|
||||
const authStore = { token: "TEST_ADMIN_TOKEN" };
|
||||
return {
|
||||
fetchMock,
|
||||
getFullList,
|
||||
buildPrintBundle,
|
||||
pdfMock,
|
||||
pickingSlipPdfMock,
|
||||
filesGetUrl,
|
||||
authStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
|
@ -16,6 +33,8 @@ vi.stubGlobal("fetch", fetchMock);
|
|||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn().mockResolvedValue({
|
||||
collection: () => ({ getFullList }),
|
||||
files: { getURL: filesGetUrl },
|
||||
authStore,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -55,6 +74,7 @@ describe("GET /api/invoicing/print-bundle", () => {
|
|||
buildPrintBundle.mockReset();
|
||||
pdfMock.mockClear();
|
||||
pickingSlipPdfMock.mockReset();
|
||||
filesGetUrl.mockClear();
|
||||
});
|
||||
|
||||
it("returns 400 when invoiceNumber is missing", async () => {
|
||||
|
|
@ -148,7 +168,10 @@ describe("GET /api/invoicing/print-bundle", () => {
|
|||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://test.local/api/v1/files/order_items/item-1/inv.pdf",
|
||||
"http://127.0.0.1:8090/api/files/order_items/item-1/inv.pdf",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "TEST_ADMIN_TOKEN" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pickingSlipPdfMock).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -195,6 +218,11 @@ describe("GET /api/invoicing/print-bundle", () => {
|
|||
|
||||
const res = await GET(makeGetRequest("INV-1") as any);
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://test.local/api/v1/files/order_items/item-1/inv.pdf");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:8090/api/files/order_items/item-1/inv.pdf",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "TEST_ADMIN_TOKEN" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function asNumber(value: unknown): number {
|
|||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
async function handlePrintBundle(input: { invoiceNumber: string; origin: string }) {
|
||||
async function handlePrintBundle(input: { invoiceNumber: string }) {
|
||||
const invoiceNumber = input.invoiceNumber.trim();
|
||||
if (!invoiceNumber) {
|
||||
return NextResponse.json({ error: "Missing invoiceNumber" }, { status: 400 });
|
||||
|
|
@ -103,8 +103,23 @@ async function handlePrintBundle(input: { invoiceNumber: string; origin: string
|
|||
return NextResponse.json({ error: "Invoice PDF not available" }, { status: 409 });
|
||||
}
|
||||
|
||||
const proxyUrl = `${input.origin}/api/v1/files/order_items/${sourceItem.id}/${encodeURIComponent(fileToken)}`;
|
||||
const invoiceRes = await fetch(proxyUrl);
|
||||
// Fetch the invoice PDF directly from PocketBase with admin auth.
|
||||
// We deliberately do NOT self-fetch through `/api/v1/files/...` here:
|
||||
// that approach breaks on production because the Next.js server
|
||||
// can't reach its own public hostname from inside the container
|
||||
// (DNS resolves to an external IP that doesn't route back, the
|
||||
// outbound TCP is blocked, or the TLS handshake fails). The
|
||||
// `/api/v1/files/...` proxy uses the same pattern internally
|
||||
// (see src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts),
|
||||
// so this is just inlining that one step to avoid the self-loop.
|
||||
const adminToken = pb.authStore?.token ?? "";
|
||||
const fileUrl = pb.files.getURL(
|
||||
{ collectionId: sourceItem.collectionId ?? "order_items", id: sourceItem.id },
|
||||
fileToken,
|
||||
);
|
||||
const invoiceRes = await fetch(fileUrl, {
|
||||
headers: adminToken ? { Authorization: adminToken } : {},
|
||||
});
|
||||
if (invoiceRes.status === 404) {
|
||||
return NextResponse.json({ error: "Invoice PDF not found" }, { status: 404 });
|
||||
}
|
||||
|
|
@ -163,7 +178,7 @@ async function handlePrintBundle(input: { invoiceNumber: string; origin: string
|
|||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const invoiceNumber = url.searchParams.get("invoiceNumber") ?? "";
|
||||
return handlePrintBundle({ invoiceNumber, origin: url.origin });
|
||||
return handlePrintBundle({ invoiceNumber });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
|
@ -176,5 +191,5 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const invoiceNumber =
|
||||
body && typeof body === "object" ? String((body as { invoiceNumber?: unknown }).invoiceNumber ?? "") : "";
|
||||
return handlePrintBundle({ invoiceNumber, origin: new URL(request.url).origin });
|
||||
return handlePrintBundle({ invoiceNumber });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue