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"); +});