From 0b7562a320ab7e57fa0b4b0d1483af832b6d276d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 02:12:11 +1100 Subject: [PATCH] fix: land #42 and #43 fixes on main (preview parse_failed, CORS :8080) #42 and #43 were closed on 2026-10-10, but their fixes (15268ae, 09ff668) live only on PR #41's branch, which is unmerged and conflicts with main. This ports the code changes and regression tests onto main: - csvPreviewResultSchema accepts reason parse_failed plus message (#42) - default CORS_ORIGINS includes http://localhost:8080, the Vite dev port (#43) - docs, env.dev.example and start-frontend.ps1 updated to match - tests: preview-contract.test.ts (from 09ff668) and a CORS :8080 test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019YPoXzBSj89ZHgw7nfTmbB --- README.md | 2 +- api/config.py | 6 +- .../src/__tests__/preview-contract.test.ts | 55 +++++++++++++++++++ csv-table-hub-main/src/lib/csv.functions.ts | 5 +- docs/ENV_VARIABLES.md | 4 +- env.dev.example | 2 +- scripts/start-frontend.ps1 | 4 +- tests/test_api_coverage.py | 6 ++ 8 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 csv-table-hub-main/src/__tests__/preview-contract.test.ts diff --git a/README.md b/README.md index 9953933..67e021c 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ See **`docs/ENV_VARIABLES.md`** for the full reference. Key variables: | `PGPASSWORD` | _(empty)_ | PostgreSQL password | | `PGDATABASE` | `te_mgmt_dev` | Target database | | `API_KEY` | _(empty)_ | Shared secret for `X-API-Key` header (empty = unauthenticated) | -| `CORS_ORIGINS` | `http://localhost:5173,...` | Allowed frontend origins | +| `CORS_ORIGINS` | `http://localhost:8080,...` | Allowed frontend origins | | `TE_SCHEMA` | `te_dev` | Schema for the 12 T&E tables | | `API_ALLOW_DESTRUCTIVE` | `false` | Enable `DELETE /api/csv/files/{id}` | diff --git a/api/config.py b/api/config.py index b0fbf74..6e54a0c 100644 --- a/api/config.py +++ b/api/config.py @@ -16,9 +16,11 @@ class Settings: # Schema holding the fixed T&E tables (deploy_all.sh dev creates te_dev). TE_SCHEMA: str = os.environ.get("TE_SCHEMA", "te_dev") - # CORS origins for the frontend dev server. + # CORS origins for the frontend dev server. 8080 is the port the Vite dev + # server (scripts/start-frontend.ps1) actually listens on; 5173/3000 are + # kept for other local setups. CORS_ORIGINS: list = os.environ.get( - "CORS_ORIGINS", "http://localhost:5173,http://localhost:3000" + "CORS_ORIGINS", "http://localhost:8080,http://localhost:5173,http://localhost:3000" ).split(",") MAX_UPLOAD_BYTES: int = int(os.environ.get("MAX_UPLOAD_BYTES", str(50 * 1024 * 1024))) diff --git a/csv-table-hub-main/src/__tests__/preview-contract.test.ts b/csv-table-hub-main/src/__tests__/preview-contract.test.ts new file mode 100644 index 0000000..ab5df68 --- /dev/null +++ b/csv-table-hub-main/src/__tests__/preview-contract.test.ts @@ -0,0 +1,55 @@ +// Regression tests for issue #42: the frontend must accept every response +// shape /api/csv/preview can return — including reason "parse_failed", which +// the API sends (with a message) when its CSV parser throws. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { previewCsv } from "@/lib/csv.functions"; + +function mockFetchJson(payload: unknown, status = 200) { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("previewCsv response contract (issue #42)", () => { + it("accepts reason parse_failed and keeps the server message", async () => { + const message = "The CSV couldn't be parsed. Check the file's structure and try again."; + mockFetchJson({ status: "invalid_structure", reason: "parse_failed", message }); + + const result = await previewCsv("broken.csv", "a\n1\n"); + + expect(result.status).toBe("invalid_structure"); + if (result.status !== "invalid_structure") throw new Error("unreachable"); + expect(result.reason).toBe("parse_failed"); + expect(result.message).toBe(message); + }); + + it.each(["empty", "header_only", "no_columns"] as const)( + "still accepts reason %s without a message", + async (reason) => { + mockFetchJson({ status: "invalid_structure", reason }); + await expect(previewCsv("f.csv", "x")).resolves.toEqual({ status: "invalid_structure", reason }); + }, + ); + + it("still rejects a reason the API never sends", async () => { + mockFetchJson({ status: "invalid_structure", reason: "not_a_reason" }); + await expect(previewCsv("f.csv", "x")).rejects.toThrow(/Unexpected response shape/); + }); + + it("posts fileName and content to /api/csv/preview", async () => { + const fetchMock = mockFetchJson({ status: "invalid_structure", reason: "empty" }); + await previewCsv("f.csv", "a\n"); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toMatch(/\/api\/csv\/preview$/); + expect(JSON.parse(init.body)).toEqual({ fileName: "f.csv", content: "a\n" }); + }); +}); diff --git a/csv-table-hub-main/src/lib/csv.functions.ts b/csv-table-hub-main/src/lib/csv.functions.ts index a638181..365b4ce 100644 --- a/csv-table-hub-main/src/lib/csv.functions.ts +++ b/csv-table-hub-main/src/lib/csv.functions.ts @@ -175,7 +175,10 @@ const csvPreviewResultSchema = z.discriminatedUnion("status", [ }), z.object({ status: z.literal("invalid_structure"), - reason: z.enum(["empty", "header_only", "no_columns"]), + // "parse_failed" is returned (with a message) when the server-side parser + // throws — see the BUG-025 handler in api/routers/csv_routes.py. + reason: z.enum(["empty", "header_only", "no_columns", "parse_failed"]), + message: z.string().optional(), }), ]); export type CsvPreviewResult = z.infer; diff --git a/docs/ENV_VARIABLES.md b/docs/ENV_VARIABLES.md index 905ecc8..9061a52 100644 --- a/docs/ENV_VARIABLES.md +++ b/docs/ENV_VARIABLES.md @@ -37,7 +37,7 @@ Read by `api/config.py`. Set these in the shell before running | `API_MAX_ROWS` | `100000` | No | Maximum data rows accepted per upload. Files over this limit are rejected before any per-row work. | | `API_MAX_ROW_ERRORS` | `200` | No | Maximum row errors included in the JSON response body. Summary counts still reflect the true total. | | `API_POOL_GETCONN_TIMEOUT` | `5.0` | No | Seconds a request waits to borrow a database connection from the pool. Exceeded = 503 response. | -| `CORS_ORIGINS` | `http://localhost:5173,http://localhost:3000` | Yes | Comma-separated list of allowed CORS origins. Set to your frontend URL in production. | +| `CORS_ORIGINS` | `http://localhost:8080,http://localhost:5173,http://localhost:3000` | Yes | Comma-separated list of allowed CORS origins. Set to your frontend URL in production. | | `CSV_UPLOADS_SCHEMA` | `csv_uploads` | No | Schema where per-CSV tables and the file registry live. | | `TE_SCHEMA` | `te_dev` | No | Schema holding the 12 fixed T&E tables. Switch to `te_test`, `te_staging`, or `te_prod` per environment. | | `MAX_UPLOAD_BYTES` | `52428800` (50 MB) | No | Maximum file size accepted by the upload endpoint. | @@ -134,7 +134,7 @@ Change them by editing the source file directly. | `5.0` | `api/config.py` | Default pool connection timeout (seconds) | | `52428800` | `api/config.py` | Default max upload size (50 MB) | | `8000` | `scripts/start-api.ps1` | API port | -| `5173` | `api/config.py` (CORS default) | Frontend dev server port | +| `8080` | `api/config.py` (CORS default) | Frontend dev server port (also allows `5173`, `3000`) | | `organisations, personnel, …` | `api/config.py` (TE_TABLES) | The 12 fixed T&E table names | --- diff --git a/env.dev.example b/env.dev.example index 23075bc..ebe9947 100644 --- a/env.dev.example +++ b/env.dev.example @@ -23,7 +23,7 @@ CSV_UPLOADS_SCHEMA=csv_uploads TE_SCHEMA=te_dev # CORS — frontend dev server ports -CORS_ORIGINS=http://localhost:5173,http://localhost:3000 +CORS_ORIGINS=http://localhost:8080,http://localhost:5173,http://localhost:3000 # Upload limits MAX_UPLOAD_BYTES=52428800 diff --git a/scripts/start-frontend.ps1 b/scripts/start-frontend.ps1 index 1d5d7b8..c89e6ce 100644 --- a/scripts/start-frontend.ps1 +++ b/scripts/start-frontend.ps1 @@ -1,4 +1,4 @@ -# start-frontend.ps1 - Terminal 2: run the React frontend on http://localhost:5173 +# start-frontend.ps1 - Terminal 2: run the React frontend on http://localhost:8080 # First run installs node_modules automatically. $ErrorActionPreference = "Stop" @@ -10,5 +10,5 @@ if (-not (Test-Path "node_modules")) { npm install } -Write-Host "Frontend starting on http://localhost:5173" -ForegroundColor Green +Write-Host "Frontend starting on http://localhost:8080" -ForegroundColor Green npm run dev diff --git a/tests/test_api_coverage.py b/tests/test_api_coverage.py index 45a299d..5fe2f76 100644 --- a/tests/test_api_coverage.py +++ b/tests/test_api_coverage.py @@ -122,6 +122,12 @@ def test_cors_allows_configured_origin(self): self.assertEqual( r.headers.get("access-control-allow-origin"), "http://localhost:5173") + def test_default_origins_include_vite_dev_port(self): + """Issue #43: the Vite dev server runs on :8080 and must be allowed by default.""" + r = client.get("/api/health", headers={"Origin": "http://localhost:8080"}) + self.assertEqual( + r.headers.get("access-control-allow-origin"), "http://localhost:8080") + def test_cors_rejects_unconfigured_origin(self): r = client.get("/api/health", headers={"Origin": "http://evil.example.com"}) self.assertNotEqual(