From 01015986b58b060064769c082d679240f665e771 Mon Sep 17 00:00:00 2001 From: Noel Hidalgo Date: Sun, 5 Jul 2026 23:56:52 -0400 Subject: [PATCH] fix: stop double-encoding the SoQL like pattern in getNoticesByAgency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The $where value embedded %25 and encodeURIComponent output, which URLSearchParams.set then encoded again — Socrata received a literal '%25NAME%25' pattern (with %20 for spaces) instead of the % wildcard, so agency matches with spaces or special characters silently failed. Per the SoQL like docs (https://dev.socrata.com/docs/functions/like.html) the wildcard is a literal % in the query; %25 is only its URL encoding, which the HTTP layer already applies once. Build the SoQL value unencoded (escaping single quotes by doubling) and let searchParams.set do the single encoding pass. Test written first; it fails against the old code and passes now. Also fix the test script glob (node --test with a bare directory arg fails on newer Node). Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + src/city-record.ts | 6 +++++- test/encoding.test.mjs | 29 +++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 test/encoding.test.mjs diff --git a/package.json b/package.json index 5170c34..7335da9 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "build": "tsc", "dev": "tsc --watch", "start": "node dist/index.js", + "test": "npm run build && node --test \"test/*.test.mjs\"", "prepare": "npm run build" }, "dependencies": { diff --git a/src/city-record.ts b/src/city-record.ts index a6b5888..dfac829 100644 --- a/src/city-record.ts +++ b/src/city-record.ts @@ -70,8 +70,12 @@ export async function getNoticesByAgency( agencyName: string, limit = 25 ): Promise { + // SoQL `like` uses a literal `%` wildcard (https://dev.socrata.com/docs/functions/like.html). + // Build the SoQL value unencoded; URLSearchParams performs the single + // URL-encoding pass. Single quotes are escaped by doubling per SQL rules. + const escaped = agencyName.replace(/'/g, "''"); return sodaFetch({ - $where: `upper(agency_name) like upper('%25${encodeURIComponent(agencyName)}%25')`, + $where: `upper(agency_name) like upper('%${escaped}%')`, $limit: String(limit), $order: "start_date DESC", }); diff --git a/test/encoding.test.mjs b/test/encoding.test.mjs new file mode 100644 index 0000000..01b453a --- /dev/null +++ b/test/encoding.test.mjs @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getNoticesByAgency } from "../dist/city-record.js"; + +test("getNoticesByAgency single-encodes the SoQL like pattern", async (t) => { + let capturedUrl; + t.mock.method(globalThis, "fetch", async (url) => { + capturedUrl = String(url); + return new Response("[]", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + await getNoticesByAgency("Parks & Recreation"); + + const where = new URL(capturedUrl).searchParams.get("$where"); + // URLSearchParams.get decodes once; the decoded SoQL must contain the + // literal % wildcard (SoQL `like`: https://dev.socrata.com/docs/functions/like.html) + // and the raw agency name — no residual percent-encoding from a second pass. + assert.equal( + where, + "upper(agency_name) like upper('%Parks & Recreation%')" + ); + // The raw query string must encode % exactly once (%25, not %2525). + const rawQuery = capturedUrl.split("?")[1]; + assert.ok(rawQuery.includes("%25"), "wildcard is URL-encoded once"); + assert.ok(!rawQuery.includes("%2525"), "wildcard is not double-encoded"); +});