From f5a0dcd2fc1ae270ea1448af24fcd5e20a1c120a Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:17:20 +0000 Subject: [PATCH 01/17] vitest jsdom project with testing-library --- apps/admin/package.json | 7 +- apps/admin/vitest.config.ts | 19 ++ apps/admin/vitest.setup.ts | 7 + pnpm-lock.yaml | 519 +++++++++++++++++++++++++++++++++++- 4 files changed, 549 insertions(+), 3 deletions(-) create mode 100644 apps/admin/vitest.setup.ts diff --git a/apps/admin/package.json b/apps/admin/package.json index 88b0148..195bd60 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -11,7 +11,7 @@ "start": "next start", "lint": "next lint --max-warnings=0", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests", + "test": "vitest run", "clean": "rimraf .next out *.tsbuildinfo" }, "dependencies": { @@ -27,6 +27,10 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/leaflet": "^1.9.15", "@types/node": "^22.10.2", "@types/react": "^19.0.2", @@ -34,6 +38,7 @@ "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-config-next": "15.5.25", + "jsdom": "^26.1.0", "postcss": "^8.5.28", "rimraf": "^6.0.1", "tailwindcss": "^3.4.17", diff --git a/apps/admin/vitest.config.ts b/apps/admin/vitest.config.ts index 0440f58..8eba1e1 100644 --- a/apps/admin/vitest.config.ts +++ b/apps/admin/vitest.config.ts @@ -7,4 +7,23 @@ export default defineConfig({ "@": fileURLToPath(new URL("./src", import.meta.url)), }, }, + // tsconfig keeps `jsx: preserve` for Next; tests need the automatic runtime. + esbuild: { jsx: "automatic" }, + test: { + projects: [ + { + extends: true, + test: { name: "node", environment: "node", include: ["src/**/*.test.ts"] }, + }, + { + extends: true, + test: { + name: "dom", + environment: "jsdom", + include: ["src/**/*.test.tsx"], + setupFiles: ["./vitest.setup.ts"], + }, + }, + ], + }, }) diff --git a/apps/admin/vitest.setup.ts b/apps/admin/vitest.setup.ts new file mode 100644 index 0000000..afd0699 --- /dev/null +++ b/apps/admin/vitest.setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest" +import { cleanup } from "@testing-library/react" +import { afterEach } from "vitest" + +afterEach(() => { + cleanup() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0a9b4c..7542151 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,18 @@ importers: specifier: ^5.0.2 version: 5.0.14(@types/react@19.2.17)(react@19.0.0) devDependencies: + '@testing-library/dom': + specifier: ^10.4.2 + version: 10.4.2 + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0)) + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@testing-library/user-event': + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.2) '@types/leaflet': specifier: ^1.9.15 version: 1.9.21 @@ -76,6 +88,9 @@ importers: eslint-config-next: specifier: 15.5.25 version: 15.5.25(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 postcss: specifier: '>=8.5.28 <9' version: 8.5.28 @@ -93,18 +108,64 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/node@22.19.20)(jiti@1.21.7) + version: 3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@civfix/shared@0.55.0': resolution: {integrity: sha512-f24XyngBWMV4L7ipARvX8oATSObFKkrEvCBQxJDobbpenHduZ3LkU6sC5uZfN/ZrsySiH0xcInvxgit966S3gA==} engines: {node: '>=20'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -708,6 +769,41 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@turbo/darwin-64@2.9.16': resolution: {integrity: sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw==} cpu: [x64] @@ -741,6 +837,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -981,13 +1080,25 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1001,6 +1112,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1170,17 +1284,28 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1210,6 +1335,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -1225,6 +1353,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1239,6 +1371,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1249,6 +1387,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-abstract@1.24.2: resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} @@ -1564,6 +1706,22 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1580,6 +1738,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1655,6 +1817,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1715,6 +1880,15 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1770,6 +1944,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -1779,6 +1956,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1794,6 +1975,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1860,6 +2045,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1919,6 +2107,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2015,6 +2206,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2033,6 +2228,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.0.0: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} @@ -2044,6 +2242,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2083,6 +2285,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2098,6 +2303,13 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -2207,6 +2419,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2240,6 +2456,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -2282,10 +2501,25 @@ packages: resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2425,6 +2659,27 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2455,6 +2710,25 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2482,12 +2756,52 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + '@civfix/shared@0.55.0': dependencies: zod: 3.25.76 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2911,6 +3225,43 @@ snapshots: '@tanstack/query-core': 5.101.0 react: 19.0.0 + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0) + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.2 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + '@turbo/darwin-64@2.9.16': optional: true @@ -2934,6 +3285,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3174,6 +3527,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -3181,10 +3536,14 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -3196,6 +3555,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -3391,12 +3754,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + cssesc@3.0.0: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -3423,6 +3798,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -3439,6 +3816,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.1.2: optional: true @@ -3450,6 +3829,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3460,6 +3843,8 @@ snapshots: emoji-regex@9.2.2: {} + entities@6.0.1: {} + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 @@ -3947,6 +4332,28 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3958,6 +4365,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -4041,6 +4450,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -4103,6 +4514,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -4153,12 +4591,16 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.5.1: {} lucide-react@0.469.0(react@19.0.0): dependencies: react: 19.0.0 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4172,6 +4614,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + min-indent@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.9 @@ -4233,6 +4677,8 @@ snapshots: normalize-path@3.0.0: {} + nwsapi@2.2.28: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -4306,6 +4752,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4372,6 +4822,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4389,6 +4845,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react@19.0.0: {} read-cache@1.0.0: @@ -4399,6 +4857,11 @@ snapshots: dependencies: picomatch: 2.3.2 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -4477,6 +4940,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.61.1 fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4500,6 +4965,12 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.25.0: {} semver@6.3.1: {} @@ -4667,6 +5138,10 @@ snapshots: strip-bom@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} strip-literal@3.1.0: @@ -4694,6 +5169,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwind-merge@2.6.1: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.19): @@ -4751,10 +5228,24 @@ snapshots: tinyspy@4.0.6: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4900,7 +5391,7 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 - vitest@3.2.7(@types/node@22.19.20)(jiti@1.21.7): + vitest@3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -4927,6 +5418,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.20 + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -4941,6 +5433,23 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4993,6 +5502,12 @@ snapshots: word-wrap@1.2.5: {} + ws@8.21.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yocto-queue@0.1.0: {} zod@3.25.76: {} From 2d0b3531a99f2059b67e27f962d7ffa77ac2c400 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:17:20 +0000 Subject: [PATCH 02/17] name the profile navigation test after its module --- .../{users-page.test.ts => profile-activity-navigation.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/admin/src/features/users/{users-page.test.ts => profile-activity-navigation.test.ts} (100%) diff --git a/apps/admin/src/features/users/users-page.test.ts b/apps/admin/src/features/users/profile-activity-navigation.test.ts similarity index 100% rename from apps/admin/src/features/users/users-page.test.ts rename to apps/admin/src/features/users/profile-activity-navigation.test.ts From ad5c436fe39244f117a953dd191e15a8dc836ce6 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:18:06 +0000 Subject: [PATCH 03/17] shared api mock and query render helpers for dom tests --- apps/admin/src/test/api-mock.ts | 27 +++++++++++++++++++++++++++ apps/admin/src/test/render.tsx | 19 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 apps/admin/src/test/api-mock.ts create mode 100644 apps/admin/src/test/render.tsx diff --git a/apps/admin/src/test/api-mock.ts b/apps/admin/src/test/api-mock.ts new file mode 100644 index 0000000..e8bedeb --- /dev/null +++ b/apps/admin/src/test/api-mock.ts @@ -0,0 +1,27 @@ +import { vi, type Mock } from "vitest" + +import type { api as realApi } from "@/lib/api" + +type ApiMethods = { [K in keyof typeof realApi]: Mock } + +const methods = new Map() + +/** + * Stand-in for the typed API client. Every method is a lazily created `vi.fn()`; an unconfigured + * method rejects so a test never silently renders against `undefined` data. + */ +export const apiMock = new Proxy({} as ApiMethods, { + get(_target, name) { + if (typeof name !== "string") return undefined + let fn = methods.get(name) + if (!fn) { + fn = vi.fn(() => Promise.reject(new Error(`api.${name} was not mocked`))) + methods.set(name, fn) + } + return fn + }, +}) + +export function resetApiMock(): void { + methods.clear() +} diff --git a/apps/admin/src/test/render.tsx b/apps/admin/src/test/render.tsx new file mode 100644 index 0000000..80d0092 --- /dev/null +++ b/apps/admin/src/test/render.tsx @@ -0,0 +1,19 @@ +import type { ReactElement } from "react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { render, type RenderResult } from "@testing-library/react" + +export function makeTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false }, + }, + }) +} + +export function renderWithQuery( + ui: ReactElement, + client: QueryClient = makeTestQueryClient(), +): RenderResult & { client: QueryClient } { + return { ...render({ui}), client } +} From 8155e475aea1f55d17ad4af112614d8bae89a475 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:31:34 +0000 Subject: [PATCH 04/17] reset api mocks after unmount so in-flight queries cannot leak --- apps/admin/vitest.setup.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/admin/vitest.setup.ts b/apps/admin/vitest.setup.ts index afd0699..aa1845d 100644 --- a/apps/admin/vitest.setup.ts +++ b/apps/admin/vitest.setup.ts @@ -2,6 +2,10 @@ import "@testing-library/jest-dom/vitest" import { cleanup } from "@testing-library/react" import { afterEach } from "vitest" +import { resetApiMock } from "@/test/api-mock" + +// Unmount first so a query still in flight from this test cannot land on the next test's mocks. afterEach(() => { cleanup() + resetApiMock() }) From 3f5814bea58206b6088bd98ee9928217dde3dacc Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:31:34 +0000 Subject: [PATCH 05/17] test: assert modal accessibility behaviorally --- .../shared/modal-accessibility.test.ts | 33 --- .../shared/modal-accessibility.test.tsx | 254 ++++++++++++++++++ 2 files changed, 254 insertions(+), 33 deletions(-) delete mode 100644 apps/admin/src/components/shared/modal-accessibility.test.ts create mode 100644 apps/admin/src/components/shared/modal-accessibility.test.tsx diff --git a/apps/admin/src/components/shared/modal-accessibility.test.ts b/apps/admin/src/components/shared/modal-accessibility.test.ts deleted file mode 100644 index ff9674c..0000000 --- a/apps/admin/src/components/shared/modal-accessibility.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { readFileSync } from "node:fs" - -import { describe, expect, it } from "vitest" - -import { ESCAPE_OWNER_SELECTOR } from "@/components/shell/escape-owner" - -const lightboxSource = readFileSync(new URL("./lightbox.tsx", import.meta.url), "utf8") -const dialogSource = readFileSync(new URL("./dialog.tsx", import.meta.url), "utf8") - -const lightboxFrame = - lightboxSource.match(/className="lightbox"[\s\S]{0,300}?aria-label=\{current\.alt\}/)?.[0] ?? "" - -describe("modal host markup", () => { - it("marks the lightbox frame as the modal dialog the shell yields Escape to", () => { - expect(ESCAPE_OWNER_SELECTOR).toContain('[role="dialog"][aria-modal="true"]') - expect(lightboxFrame).toMatch(/role="dialog"/) - expect(lightboxFrame).toMatch(/aria-modal="true"/) - expect(lightboxFrame).not.toMatch(/aria-hidden/) - }) - - it("traps and restores focus in both hosts through the shared helper", () => { - for (const source of [lightboxSource, dialogSource]) { - expect(source).toMatch(/useModalFocus\(/) - expect(source).toMatch(/ref=\{(frameRef|modalRef)\}/) - } - }) - - it("keeps an explicit outcome for a photo that fails to load", () => { - expect(lightboxSource).toMatch(/onError=/) - expect(lightboxSource).toMatch(/onLoad=/) - expect(lightboxSource).toContain("Refresh photo") - }) -}) diff --git a/apps/admin/src/components/shared/modal-accessibility.test.tsx b/apps/admin/src/components/shared/modal-accessibility.test.tsx new file mode 100644 index 0000000..0cda6ff --- /dev/null +++ b/apps/admin/src/components/shared/modal-accessibility.test.tsx @@ -0,0 +1,254 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { afterEach, describe, expect, it } from "vitest" + +import { confirmDialog, DialogHost, promptDialog } from "@/components/shared/dialog" +import { LightboxHost, openLightbox } from "@/components/shared/lightbox" +import { ESCAPE_OWNER_SELECTOR, hasEscapeOwner } from "@/components/shell/escape-owner" + +const PHOTOS = [ + { id: "p1", url: "https://media.test/p1.jpg", alt: "Pothole on Main St" }, + { id: "p2", url: "https://media.test/p2.jpg", alt: "Pothole close-up" }, +] + +function Opener({ onOpen }: { onOpen: () => void }) { + return ( + + ) +} + +afterEach(() => { + // The hosts are backed by module-level zustand stores; close anything a test left open. + if (document.querySelector('[role="dialog"]')) fireEvent.keyDown(window, { key: "Escape" }) +}) + +describe("ESCAPE_OWNER_SELECTOR", () => { + it("yields Escape to an open modal dialog", () => { + expect(ESCAPE_OWNER_SELECTOR).toContain('[role="dialog"][aria-modal="true"]') + }) +}) + +describe("LightboxHost", () => { + function renderLightbox() { + const user = userEvent.setup() + render( + <> + openLightbox(PHOTOS, 0)} /> + + , + ) + return { user, opener: screen.getByRole("button", { name: "Open modal" }) } + } + + it("renders nothing until opened", () => { + renderLightbox() + expect(screen.queryByRole("dialog")).toBeNull() + expect(hasEscapeOwner(document)).toBe(false) + }) + + it("marks the frame as a labelled modal dialog the shell yields Escape to", async () => { + const { user, opener } = renderLightbox() + await user.click(opener) + + const frame = screen.getByRole("dialog", { name: "Pothole on Main St" }) + expect(frame).toHaveAttribute("aria-modal", "true") + expect(frame).not.toHaveAttribute("aria-hidden") + expect(frame).toHaveClass("lightbox") + expect(frame.matches(ESCAPE_OWNER_SELECTOR)).toBe(true) + expect(hasEscapeOwner(document)).toBe(true) + }) + + it("moves focus inside on open, traps Tab, and restores focus to the opener on close", async () => { + const { user, opener } = renderLightbox() + await user.click(opener) + + const frame = screen.getByRole("dialog") + const close = screen.getByRole("button", { name: "Close" }) + const prev = screen.getByRole("button", { name: "Previous photo" }) + const next = screen.getByRole("button", { name: "Next photo" }) + expect(close).toHaveFocus() + + await user.tab() + expect(prev).toHaveFocus() + await user.tab() + expect(next).toHaveFocus() + await user.tab() + expect(close).toHaveFocus() + await user.tab({ shift: true }) + expect(next).toHaveFocus() + expect(frame).toContainElement(document.activeElement as HTMLElement) + + await user.click(next) + await user.click(screen.getByRole("button", { name: "Close" })) + expect(screen.queryByRole("dialog")).toBeNull() + expect(opener).toHaveFocus() + }) + + it("restores focus to the opener when closed with Escape", async () => { + const { user, opener } = renderLightbox() + await user.click(opener) + expect(opener).not.toHaveFocus() + + await user.keyboard("{Escape}") + expect(screen.queryByRole("dialog")).toBeNull() + expect(opener).toHaveFocus() + }) + + it("shows a loading state until the photo loads", async () => { + const { user, opener } = renderLightbox() + await user.click(opener) + + const img = screen.getByRole("img", { name: "Pothole on Main St" }) + expect(img).toHaveClass("pending") + expect(screen.getByRole("status")).toHaveTextContent("Loading photo...") + + fireEvent.load(img) + expect(img).not.toHaveClass("pending") + expect(screen.queryByRole("status")).toBeNull() + }) + + it("replaces a photo that fails to load with an explicit Refresh photo outcome", async () => { + const user = userEvent.setup() + let refreshes = 0 + render( + <> + openLightbox(PHOTOS.slice(0, 1), 0, () => (refreshes += 1))} /> + + , + ) + await user.click(screen.getByRole("button", { name: "Open modal" })) + + fireEvent.error(screen.getByRole("img", { name: "Pothole on Main St" })) + + expect(screen.queryByRole("img")).toBeNull() + expect(screen.getByRole("alert")).toHaveTextContent("This photo link expired") + const refresh = screen.getByRole("button", { name: "Refresh photo" }) + expect(refresh).toHaveAttribute("type", "button") + + await user.click(refresh) + expect(refreshes).toBe(1) + expect(screen.queryByRole("button", { name: "Refresh photo" })).toBeNull() + expect(screen.getByRole("img", { name: "Pothole on Main St" })).toHaveClass("pending") + }) +}) + +describe("DialogHost", () => { + function renderDialog(open: () => void) { + const user = userEvent.setup() + render( + <> + + + , + ) + return { user, opener: screen.getByRole("button", { name: "Open modal" }) } + } + + it("renders nothing until opened", () => { + renderDialog(() => void confirmDialog({ title: "Remove report?" })) + expect(screen.queryByRole("dialog")).toBeNull() + }) + + it("marks a confirm as a modal dialog the shell yields Escape to", async () => { + const { user, opener } = renderDialog(() => void confirmDialog({ title: "Remove report?" })) + await user.click(opener) + + const dialog = screen.getByRole("dialog") + expect(dialog).toHaveAttribute("aria-modal", "true") + expect(dialog).not.toHaveAttribute("aria-hidden") + expect(dialog).toHaveTextContent("Remove report?") + expect(dialog.matches(ESCAPE_OWNER_SELECTOR)).toBe(true) + expect(hasEscapeOwner(document)).toBe(true) + }) + + it("moves focus inside on open, traps Tab, and restores focus to the opener on close", async () => { + let result: Promise | undefined + const { user, opener } = renderDialog(() => { + result = confirmDialog({ title: "Remove report?", confirmLabel: "Remove" }) + }) + await user.click(opener) + + const dialog = screen.getByRole("dialog") + const close = screen.getByRole("button", { name: "Close" }) + const cancel = screen.getByRole("button", { name: "Cancel" }) + const confirm = screen.getByRole("button", { name: "Remove" }) + expect(close).toHaveFocus() + + await user.tab() + expect(cancel).toHaveFocus() + await user.tab() + expect(confirm).toHaveFocus() + await user.tab() + expect(close).toHaveFocus() + await user.tab({ shift: true }) + expect(confirm).toHaveFocus() + expect(dialog).toContainElement(document.activeElement as HTMLElement) + + await user.click(cancel) + await expect(result).resolves.toBe(false) + expect(screen.queryByRole("dialog")).toBeNull() + expect(opener).toHaveFocus() + }) + + it("focuses the prompt field on open and traps Tab inside the prompt", async () => { + let result: Promise | undefined + const { user, opener } = renderDialog(() => { + result = promptDialog({ title: "Reason", label: "Why?", confirmLabel: "Save" }) + }) + await user.click(opener) + + const dialog = screen.getByRole("dialog") + expect(dialog).toHaveAttribute("aria-modal", "true") + expect(dialog).not.toHaveAttribute("aria-hidden") + expect(dialog.matches(ESCAPE_OWNER_SELECTOR)).toBe(true) + const field = screen.getByRole("textbox") + expect(field).toHaveFocus() + + await user.type(field, "spam") + await user.tab() + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus() + await user.tab() + expect(screen.getByRole("button", { name: "Save" })).toHaveFocus() + await user.tab() + expect(screen.getByRole("button", { name: "Close" })).toHaveFocus() + await user.tab() + expect(field).toHaveFocus() + await user.tab({ shift: true }) + expect(screen.getByRole("button", { name: "Close" })).toHaveFocus() + + await user.click(screen.getByRole("button", { name: "Save" })) + await expect(result).resolves.toBe("spam") + expect(screen.queryByRole("dialog")).toBeNull() + }) + + // The textarea's autoFocus runs in React's commit phase, before useModalFocus's effect records the + // element to restore, so the hook captures the textarea itself and has nothing live to return to. + it("drops focus to the body when a prompt closes (current behavior: prompt does not restore focus to the opener)", async () => { + const { user, opener } = renderDialog(() => { + void promptDialog({ title: "Reason" }) + }) + await user.click(opener) + expect(screen.getByRole("textbox")).toHaveFocus() + + await user.click(screen.getByRole("button", { name: "Cancel" })) + expect(screen.queryByRole("dialog")).toBeNull() + expect(opener).not.toHaveFocus() + expect(document.activeElement).toBe(document.body) + }) + + it("restores focus to the opener when closed with Escape", async () => { + let result: Promise | undefined + const { user, opener } = renderDialog(() => { + result = confirmDialog({ title: "Remove report?" }) + }) + await user.click(opener) + expect(opener).not.toHaveFocus() + + await user.keyboard("{Escape}") + await expect(result).resolves.toBe(false) + expect(screen.queryByRole("dialog")).toBeNull() + expect(opener).toHaveFocus() + }) +}) From a762d11061f2e9120918b64f4f2eacadbaf6f6ad Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:31:34 +0000 Subject: [PATCH 06/17] test: assert home tile accessibility behaviorally --- .../features/home/home-accessibility.test.ts | 22 -- .../features/home/home-accessibility.test.tsx | 347 ++++++++++++++++++ 2 files changed, 347 insertions(+), 22 deletions(-) delete mode 100644 apps/admin/src/features/home/home-accessibility.test.ts create mode 100644 apps/admin/src/features/home/home-accessibility.test.tsx diff --git a/apps/admin/src/features/home/home-accessibility.test.ts b/apps/admin/src/features/home/home-accessibility.test.ts deleted file mode 100644 index c3c8646..0000000 --- a/apps/admin/src/features/home/home-accessibility.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { readFileSync } from "node:fs" - -import { describe, expect, it } from "vitest" - -const source = readFileSync(new URL("./home-page.tsx", import.meta.url), "utf8") -const previewRowSource = source.slice(source.indexOf("function PreviewRow"), source.indexOf("interface PreviewState")) -const sectionTileSource = source.slice(source.indexOf("function SectionTile"), source.indexOf("interface PreviewTile")) -const nativeButtonContents = (snippet: string) => - [...snippet.matchAll(/]*>([\s\S]*?)<\/button>/g)].map((match) => match[1] ?? "") - -describe("home tile accessibility structure", () => { - it("uses native sibling controls without nested interactive roles", () => { - expect(previewRowSource).toMatch(/ !/ !/ { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +// The live map loads Leaflet through next/dynamic; it is outside the tiles under test. +vi.mock("@/components/map/live-map", () => ({ + LiveMap: () =>
live map
, +})) + +const summary = { + discovery: { queue: 4, reportsWaiting: 9, overSla: 1 }, + reports: { flagged: 2, inProgress: 3, completed: 5 }, + events: { upcoming: 2, live: 0, attending: 12 }, + mail: { unread: 2, needsAction: 1 }, + users: { flagged: 2, highRisk: 1, suspended: 0 }, + analytics: { + pinsThisMonth: 20, + resolvedPct: 50, + coveragePct: 70, + cleanups: 3, + eventsThisMonth: 2, + newUsers: 6, + pinsByWeek: [1, 3, 2], + }, + livePins24h: 4, + moderationQueue: 2, + inboxUnread: 1, +} satisfies HomeSummaryResponse + +const actor = { id: "u-9", name: "Dana Reyes", handle: "dana", joined: "2025-01-01" } + +function discoveryTask(n: number): DiscoveryTaskDTO { + return { + id: `d-${n}`, + geoid: `06000${n}`, + place: `Place ${n}`, + layer: "place", + category: "trash", + catLabel: "Trash", + pop: 1200, + reports: 3, + perCategoryCounts: { trash: 3 }, + lastReport: "2026-09-20", + age: "2d", + overSla: false, + priority: "low", + contactState: { routed: [], missing: ["trash"] }, + notes: [], + } +} + +function report(n: number): AdminReportListItemDTO { + return { + id: `r-${n}`, + category: "graffiti", + status: "submitted", + flagged: false, + title: `Report ${n}`, + place: "Echo Park", + reporter: actor, + confirmations: 0, + submitted: { rel: "1h", abs: "Sep 23, 10:00" }, + coords: [34.07, -118.26], + address: "1 Main St", + hasPhoto: false, + thumbnailUrl: null, + } +} + +function event(n: number): AdminEventListItemDTO { + return { + id: `e-${n}`, + status: "upcoming", + eventKind: "cleanup", + flagged: false, + title: `Event ${n}`, + place: "Venice", + attendees: 5, + capacity: null, + bags: 0, + organizer: actor, + date: { rel: "in 2d", abs: "Sep 25" }, + coords: [33.98, -118.47], + } +} + +function user(n: number): AdminUserListItemDTO { + return { + id: `u-${n}`, + name: `User ${n}`, + handle: `user${n}`, + city: "Pasadena", + joined: "2025-02-02", + avatar: null, + status: "active", + reports: 1, + cleanups: 0, + removals: 0, + strikes: 0, + risk: "low", + lastActive: "1h", + flagged: false, + flagReason: null, + } +} + +function moderationItem(n: number): ModerationListItemDTO { + return { + id: `mod-${n}`, + flag: "flag", + reporter: "Pat", + category: null, + reason: "Spam", + age: "20m", + priority: "low", + kind: "image", + subjectId: `s-${n}`, + destinationKind: null, + destinationId: null, + reporterId: null, + } +} + +const mailPage = { + items: [ + { + id: "m-1", + dir: "out", + from: "routing@civfix.org", + to: "works@la.gov", + org: "LA Public Works", + subject: "Pothole on Main", + preview: "Hello", + ts: "5m", + unread: false, + status: "sent", + jurisdictionGeoid: null, + reportId: null, + }, + ], + nextCursor: null, +} satisfies MailListResponse + +const inboxPage = { + items: [ + { + id: "in-1", + from: "resident@example.com", + recipient: "hello@civfix.org", + localPart: "hello", + subject: "Question", + preview: "", + ts: "9m", + status: "unread", + unread: true, + hasAttachments: false, + }, + ], + nextCursor: null, +} satisfies InboxListResponse + +function mockPopulatedHome() { + apiMock.adminHomeSummary.mockResolvedValue(summary) + apiMock.listDiscovery.mockResolvedValue({ + items: [discoveryTask(1), discoveryTask(2)], + nextCursor: null, + } satisfies DiscoveryListResponse) + apiMock.listAdminReports.mockResolvedValue({ + items: [report(1), report(2)], + nextCursor: null, + counts: { all: 2, submitted: 2, in_progress: 0, completed: 0, flagged: 0 }, + } satisfies AdminReportListResponse) + apiMock.listAdminEvents.mockResolvedValue({ + items: [event(1), event(2)], + nextCursor: null, + counts: { all: 2, upcoming: 2, in_progress: 0, completed: 0, flagged: 0 }, + } satisfies AdminEventListResponse) + apiMock.listMail.mockResolvedValue(mailPage) + apiMock.listInbox.mockResolvedValue(inboxPage) + apiMock.listAdminUsers.mockResolvedValue({ + items: [user(1), user(2)], + nextCursor: null, + } satisfies AdminUserListResponse) + apiMock.listModeration.mockResolvedValue({ + items: [moderationItem(1), moderationItem(2)], + nextCursor: null, + } satisfies ModerationListResponse) +} + +const PREVIEW_TILES = 6 +const ROWS_PER_TILE = 2 + +async function renderLoadedHome(): Promise { + mockPopulatedHome() + const { container } = renderWithQuery() + await waitFor(() => + expect(container.querySelectorAll(".slr")).toHaveLength(PREVIEW_TILES * ROWS_PER_TILE), + ) + return container +} + +function tilesOf(container: HTMLElement): HTMLElement[] { + return [...container.querySelectorAll(".stile")] +} + +/** + * React keeps each host element's current props on an expando property. Reading it is the only way a + * rendered DOM can show that no custom key handler was attached to a control. + */ +function reactPropsOf(el: Element): Record { + const key = Object.keys(el).find((k) => k.startsWith("__reactProps$")) + return key ? ((el as unknown as Record>)[key] ?? {}) : {} +} + +beforeEach(() => { + vi.spyOn(window, "scrollTo").mockImplementation(() => {}) + window.history.replaceState(null, "", "/") +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("home tile accessibility structure", () => { + it("renders every section tile with a native head and foot button as sibling controls", async () => { + const container = await renderLoadedHome() + + const tiles = tilesOf(container) + expect(tiles).toHaveLength(PREVIEW_TILES + 1) + for (const tile of tiles) { + const children = [...tile.children] + const head = children[0] + const foot = children[children.length - 1] + + expect(head?.tagName).toBe("BUTTON") + expect(head).toHaveAttribute("type", "button") + expect(head).toHaveClass("stile-head") + + expect(foot?.tagName).toBe("BUTTON") + expect(foot).toHaveAttribute("type", "button") + expect(foot).toHaveClass("stile-foot", "opens") + + expect(tile.tagName).not.toBe("BUTTON") + expect(tile).not.toHaveAttribute("role") + expect(tile.querySelectorAll(".stile-head")).toHaveLength(1) + expect(tile.querySelectorAll(".stile-foot")).toHaveLength(1) + } + }) + + it("renders every preview row as a native type=button with the slr class", async () => { + const container = await renderLoadedHome() + + const rows = [...container.querySelectorAll(".slr")] + expect(rows).toHaveLength(PREVIEW_TILES * ROWS_PER_TILE) + for (const row of rows) { + expect(row.tagName).toBe("BUTTON") + expect(row).toHaveAttribute("type", "button") + expect(row).toHaveClass("slr") + expect(row.closest(".stile-list")).not.toBeNull() + expect(row.parentElement?.closest("button")).toBeNull() + } + }) + + it("uses only native buttons as the tile controls", async () => { + const container = await renderLoadedHome() + + for (const tile of tilesOf(container)) { + for (const button of tile.querySelectorAll("button")) { + expect( + ["stile-head", "stile-foot", "slr"].some((cls) => button.classList.contains(cls)), + ).toBe(true) + } + } + }) + + it("sets no role=button, tabindex or custom key handler anywhere inside a tile", async () => { + const container = await renderLoadedHome() + + for (const tile of tilesOf(container)) { + // Guards against a vacuous pass if React ever stops exposing props on the element. + expect(reactPropsOf(tile.querySelector(".stile-head")!)).toHaveProperty("onClick") + expect(tile.querySelectorAll('[role="button"]')).toHaveLength(0) + expect(tile.querySelectorAll("[tabindex]")).toHaveLength(0) + for (const el of [tile, ...tile.querySelectorAll("*")]) { + const props = reactPropsOf(el) + expect(props).not.toHaveProperty("onKeyDown") + expect(props).not.toHaveProperty("onKeyUp") + expect(props).not.toHaveProperty("onKeyPress") + expect(props).not.toHaveProperty("tabIndex") + expect(props).not.toHaveProperty("role") + } + } + }) + + it("puts no div and no nested interactive element inside any tile button", async () => { + const container = await renderLoadedHome() + + const buttons = tilesOf(container).flatMap((tile) => [...tile.querySelectorAll("button")]) + expect(buttons).toHaveLength((PREVIEW_TILES + 1) * 2 + PREVIEW_TILES * ROWS_PER_TILE) + for (const button of buttons) { + expect(button.querySelector("div")).toBeNull() + expect(button.querySelector("button, a, input, select, textarea, [tabindex], [role]")).toBeNull() + } + }) + + it("activates tile controls from the keyboard through native button behavior", async () => { + const container = await renderLoadedHome() + const kb = userEvent.setup() + + const row = screen.getByRole("button", { name: /Place 1/ }) + row.focus() + await kb.keyboard("{Enter}") + expect(window.location.hash).toBe("#/discovery/060001") + + const reportsHead = tilesOf(container) + .map((tile) => tile.querySelector(".stile-head")) + .find((head) => head?.textContent?.startsWith("Reports")) + reportsHead?.focus() + await kb.keyboard(" ") + expect(window.location.hash).toBe("#/reports") + }) +}) From 56f6e8305ed2dd90f5c0b02634eafbb479268738 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:31:34 +0000 Subject: [PATCH 07/17] characterize hash routing and toast store --- apps/admin/src/store/ui-store.test.ts | 295 ++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 apps/admin/src/store/ui-store.test.ts diff --git a/apps/admin/src/store/ui-store.test.ts b/apps/admin/src/store/ui-store.test.ts new file mode 100644 index 0000000..7ebd7c1 --- /dev/null +++ b/apps/admin/src/store/ui-store.test.ts @@ -0,0 +1,295 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type * as UiStore from "./ui-store" + +type UiStoreModule = typeof UiStore + +function setHash(hash: string): void { + window.history.replaceState(null, "", `/${hash}`) +} + +// parseHash runs once at module scope, so the initial route is only observable on a fresh import. +async function loadWithHash(hash: string): Promise { + setHash(hash) + vi.resetModules() + return import("./ui-store") +} + +async function initialRoute(hash: string): Promise<{ page: string; focusId: string | null }> { + const { useUiStore } = await loadWithHash(hash) + const { page, focusId } = useUiStore.getState() + return { page, focusId } +} + +beforeEach(() => { + vi.spyOn(window, "scrollTo").mockImplementation(() => undefined) +}) + +afterEach(() => { + vi.restoreAllMocks() + setHash("") +}) + +describe("section registry", () => { + it("lists the routable sections in order", async () => { + const { SECTIONS } = await loadWithHash("") + expect(SECTIONS).toEqual([ + "discovery", + "reports", + "events", + "mail", + "users", + "moderation", + "analytics", + "orgs", + "hosts", + "pages", + ]) + }) + + it("labels every page including home", async () => { + const { PAGE_LABEL } = await loadWithHash("") + expect(PAGE_LABEL).toEqual({ + home: "Dashboard", + discovery: "Jurisdictions", + reports: "Reports", + events: "Events", + mail: "Mail", + users: "Users", + moderation: "Moderation", + analytics: "Analytics", + orgs: "Organizations", + hosts: "Host messaging", + pages: "Signup pages", + }) + }) +}) + +describe("initial route from the hash at module load", () => { + it("starts on home with no focus for an empty hash", async () => { + expect(await initialRoute("")).toEqual({ page: "home", focusId: null }) + }) + + it("starts on home for a bare #/", async () => { + expect(await initialRoute("#/")).toEqual({ page: "home", focusId: null }) + }) + + it.each([ + "discovery", + "reports", + "events", + "mail", + "users", + "moderation", + "analytics", + "orgs", + "hosts", + "pages", + ])("opens the %s section with no focus", async (section) => { + expect(await initialRoute(`#/${section}`)).toEqual({ page: section, focusId: null }) + }) + + it("accepts a section without the leading slash", async () => { + expect(await initialRoute("#reports")).toEqual({ page: "reports", focusId: null }) + }) + + it("reads the segment after the section as the focus id", async () => { + expect(await initialRoute("#/reports/rep-123")).toEqual({ page: "reports", focusId: "rep-123" }) + }) + + it("decodes a percent-encoded focus id", async () => { + expect(await initialRoute("#/users/a%20b%3Ac")).toEqual({ page: "users", focusId: "a b:c" }) + }) + + it("keeps a plus sign literal in the focus id", async () => { + expect(await initialRoute("#/users/a+b")).toEqual({ page: "users", focusId: "a+b" }) + }) + + it("passes the mail inbox: prefix through untouched", async () => { + expect(await initialRoute("#/mail/inbox%3Amsg-1")).toEqual({ + page: "mail", + focusId: "inbox:msg-1", + }) + expect(await initialRoute("#/mail/inbox:msg-1")).toEqual({ page: "mail", focusId: "inbox:msg-1" }) + }) + + it("joins every segment after the section so orgs id/tab survives", async () => { + expect(await initialRoute("#/orgs/org-1/members")).toEqual({ + page: "orgs", + focusId: "org-1/members", + }) + expect(await initialRoute("#/orgs/org-1%2Fmembers")).toEqual({ + page: "orgs", + focusId: "org-1/members", + }) + }) + + it("yields an empty-string focus id for a trailing slash (current behavior)", async () => { + expect(await initialRoute("#/reports/")).toEqual({ page: "reports", focusId: "" }) + }) + + it("falls back to home and drops the focus for an unknown section", async () => { + expect(await initialRoute("#/nope/abc")).toEqual({ page: "home", focusId: null }) + }) + + it("treats #/home as an unknown section and lands on home", async () => { + expect(await initialRoute("#/home/abc")).toEqual({ page: "home", focusId: null }) + }) + + it("matches section names case-sensitively", async () => { + expect(await initialRoute("#/Reports")).toEqual({ page: "home", focusId: null }) + }) + + it("does not strip a query string from the section segment (current behavior)", async () => { + expect(await initialRoute("#/reports?tab=open")).toEqual({ page: "home", focusId: null }) + }) + + it("throws URIError on a malformed percent-encoding at import (current behavior)", async () => { + setHash("#/reports/%E0%A4%A") + vi.resetModules() + await expect(import("./ui-store")).rejects.toThrow(URIError) + }) + + it("ignores a malformed percent-encoding in an unknown section", async () => { + expect(await initialRoute("#/nope/%E0%A4%A")).toEqual({ page: "home", focusId: null }) + }) +}) + +describe("syncFromHash", () => { + it("re-reads the current hash into the store", async () => { + const { useUiStore } = await loadWithHash("") + setHash("#/events/ev-9") + useUiStore.getState().syncFromHash() + expect(useUiStore.getState()).toMatchObject({ page: "events", focusId: "ev-9" }) + }) + + it("throws URIError on a malformed percent-encoding and leaves the store as it was (current behavior)", async () => { + const { useUiStore } = await loadWithHash("#/users/u-1") + setHash("#/reports/%E0%A4%A") + expect(() => useUiStore.getState().syncFromHash()).toThrow(URIError) + expect(useUiStore.getState()).toMatchObject({ page: "users", focusId: "u-1" }) + }) +}) + +describe("nav", () => { + it("sets the page and focus, pushes the hash and scrolls to top", async () => { + const { useUiStore } = await loadWithHash("") + const push = vi.spyOn(window.history, "pushState") + useUiStore.getState().nav("reports", "rep-1") + expect(useUiStore.getState()).toMatchObject({ page: "reports", focusId: "rep-1" }) + expect(push).toHaveBeenCalledTimes(1) + expect(push).toHaveBeenCalledWith(null, "", "#/reports/rep-1") + expect(window.location.hash).toBe("#/reports/rep-1") + expect(window.scrollTo).toHaveBeenCalledWith(0, 0) + }) + + it("defaults the focus to null", async () => { + const { useUiStore } = await loadWithHash("#/reports/rep-1") + useUiStore.getState().nav("events") + expect(useUiStore.getState()).toMatchObject({ page: "events", focusId: null }) + expect(window.location.hash).toBe("#/events") + }) + + it("writes #/ for home", async () => { + const { useUiStore } = await loadWithHash("#/users") + useUiStore.getState().nav("home") + expect(window.location.hash).toBe("#/") + expect(useUiStore.getState()).toMatchObject({ page: "home", focusId: null }) + }) + + it("keeps a focus id in the store for home although the hash drops it (current behavior)", async () => { + const { useUiStore } = await loadWithHash("") + useUiStore.getState().nav("home", "stray") + expect(useUiStore.getState()).toMatchObject({ page: "home", focusId: "stray" }) + expect(window.location.hash).toBe("#/") + }) + + it("treats an empty-string focus like no focus in the hash", async () => { + const { useUiStore } = await loadWithHash("") + useUiStore.getState().nav("mail", "") + expect(window.location.hash).toBe("#/mail") + expect(useUiStore.getState().focusId).toBe("") + }) + + it("encodes the whole focus id as one segment", async () => { + const { useUiStore } = await loadWithHash("") + useUiStore.getState().nav("orgs", "org-1/members") + expect(window.location.hash).toBe("#/orgs/org-1%2Fmembers") + useUiStore.getState().nav("mail", "inbox:msg-1") + expect(window.location.hash).toBe("#/mail/inbox%3Amsg-1") + }) + + it("does not push a history entry when the hash already matches", async () => { + const { useUiStore } = await loadWithHash("#/reports/rep-1") + const push = vi.spyOn(window.history, "pushState") + useUiStore.getState().nav("reports", "rep-1") + expect(push).not.toHaveBeenCalled() + expect(window.scrollTo).toHaveBeenCalledWith(0, 0) + }) + + it.each([ + ["reports", "plain-id"], + ["mail", "inbox:msg-1"], + ["orgs", "org-1/members"], + ["users", "a b?c#d%e&f"], + ["events", "ünïcode-✓"], + ] as const)("round-trips %s focus %j through the hash", async (page, focusId) => { + const { useUiStore } = await loadWithHash("") + useUiStore.getState().nav(page, focusId) + useUiStore.setState({ page: "home", focusId: null }) + useUiStore.getState().syncFromHash() + expect(useUiStore.getState()).toMatchObject({ page, focusId }) + }) + + it("round-trips a section with no focus", async () => { + const { useUiStore } = await loadWithHash("") + useUiStore.getState().nav("analytics") + useUiStore.setState({ page: "home", focusId: "x" }) + useUiStore.getState().syncFromHash() + expect(useUiStore.getState()).toMatchObject({ page: "analytics", focusId: null }) + }) +}) + +describe("toast", () => { + it("starts with no toast", async () => { + const { useUiStore } = await loadWithHash("") + expect(useUiStore.getState().toast).toBeNull() + }) + + it("shows a toast with an id that increases on every call", async () => { + const { useUiStore } = await loadWithHash("") + useUiStore.getState().showToast("Saved") + expect(useUiStore.getState().toast).toEqual({ id: 1, text: "Saved" }) + useUiStore.getState().showToast("Saved") + expect(useUiStore.getState().toast).toEqual({ id: 2, text: "Saved" }) + }) + + it("replaces the current toast rather than queueing", async () => { + const { useUiStore } = await loadWithHash("") + useUiStore.getState().showToast("first") + useUiStore.getState().showToast("second") + expect(useUiStore.getState().toast?.text).toBe("second") + }) + + it("dismisses the toast", async () => { + const { useUiStore } = await loadWithHash("") + useUiStore.getState().showToast("Saved") + useUiStore.getState().dismissToast() + expect(useUiStore.getState().toast).toBeNull() + }) + + it("does not reset the toast id sequence on dismiss", async () => { + const { useUiStore } = await loadWithHash("") + useUiStore.getState().showToast("a") + useUiStore.getState().dismissToast() + useUiStore.getState().showToast("b") + expect(useUiStore.getState().toast).toEqual({ id: 2, text: "b" }) + }) + + it("leaves page and focus alone", async () => { + const { useUiStore } = await loadWithHash("#/users/u-1") + useUiStore.getState().showToast("x") + expect(useUiStore.getState()).toMatchObject({ page: "users", focusId: "u-1" }) + }) +}) From 860d192123be96c53c6f5e3c3738b2a7d070bb0f Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:31:34 +0000 Subject: [PATCH 08/17] characterize operator bootstrap and logout --- apps/admin/src/hooks/use-admin-auth.test.tsx | 349 +++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 apps/admin/src/hooks/use-admin-auth.test.tsx diff --git a/apps/admin/src/hooks/use-admin-auth.test.tsx b/apps/admin/src/hooks/use-admin-auth.test.tsx new file mode 100644 index 0000000..3dbe29a --- /dev/null +++ b/apps/admin/src/hooks/use-admin-auth.test.tsx @@ -0,0 +1,349 @@ +import type { ReactNode } from "react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { act, renderHook } from "@testing-library/react" +import { QueryClientProvider, type QueryClient } from "@tanstack/react-query" +import { AppError, ErrorCode, type AdminLoginResponse, type AdminOperatorDTO } from "@civfix/shared" + +import { apiMock } from "@/test/api-mock" +import { makeTestQueryClient } from "@/test/render" +import { API_BASE_URL, getApiClient } from "@/lib/api" +import type * as ApiModule from "@/lib/api" +import { useAuthStore } from "@/store/auth-store" +import { useAdminLogout, useOperatorBootstrap, useOperatorSession } from "./use-admin-auth" + +// The real client binds window.fetch when @/lib/api first evaluates, so the stub must exist before +// that import for getApiClient() to route through it. +const { fetchMock } = vi.hoisted(() => { + const fetchMock = vi.fn() + globalThis.fetch = fetchMock + return { fetchMock } +}) + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +const OPERATOR: AdminOperatorDTO = { id: "op-1", name: "Ada Ops", email: "ada@civfix.org", role: "operator" } + +function loginResponse(user: Partial = {}, csrfToken?: string): AdminLoginResponse { + return { + user: { + id: "op-2", + displayName: "Grace Ops", + email: "grace@civfix.org", + role: "operator", + locale: "en", + createdAt: "2026-01-01T00:00:00.000Z", + ...user, + }, + ...(csrfToken !== undefined ? { csrfToken } : {}), + } +} + +function foreignAppError(code: ErrorCode, message = code): Error { + const err = new Error(message) as Error & { code: ErrorCode; httpStatus: number } + err.name = "AppError" + err.code = code + err.httpStatus = code === ErrorCode.FORBIDDEN ? 403 : 500 + return err +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) +} + +function wrapperFor(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +function renderBootstrap() { + return renderHook(() => useOperatorBootstrap(), { wrapper: wrapperFor(makeTestQueryClient()) }) +} + +async function bootstrap(): Promise { + const { result } = renderBootstrap() + let outcome = "" + await act(async () => { + outcome = await result.current() + }) + return outcome +} + +function resetAuthStore(): void { + useAuthStore.setState({ status: "idle", operator: null, csrfToken: null }) +} + +beforeEach(() => { + resetAuthStore() + fetchMock.mockReset() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("useOperatorBootstrap: session reuse", () => { + it("adopts a valid session without calling the exchange", async () => { + apiMock.adminSession.mockResolvedValue({ authenticated: true, operator: OPERATOR, csrfToken: "csrf-s" }) + expect(await bootstrap()).toBe("ok") + expect(apiMock.adminAccessExchange).not.toHaveBeenCalled() + expect(useAuthStore.getState()).toMatchObject({ + status: "authenticated", + operator: OPERATOR, + csrfToken: "csrf-s", + }) + }) + + it("keeps a previously stored CSRF token when the session omits one", async () => { + useAuthStore.setState({ csrfToken: "csrf-old" }) + apiMock.adminSession.mockResolvedValue({ authenticated: true, operator: OPERATOR }) + expect(await bootstrap()).toBe("ok") + expect(useAuthStore.getState().csrfToken).toBe("csrf-old") + }) + + it("marks a non-operator session authenticated but not an operator (current behavior)", async () => { + const citizen: AdminOperatorDTO = { ...OPERATOR, role: "citizen" } + apiMock.adminSession.mockResolvedValue({ authenticated: true, operator: citizen, csrfToken: "c" }) + const client = makeTestQueryClient() + const boot = renderHook(() => useOperatorBootstrap(), { wrapper: wrapperFor(client) }) + await act(async () => { + await boot.result.current() + }) + const session = renderHook(() => useOperatorSession(), { wrapper: wrapperFor(client) }) + expect(session.result.current).toEqual({ isOperator: false, operator: citizen, status: "authenticated" }) + }) + + it("sets status to loading while the session check is in flight", async () => { + let resolveSession: (value: unknown) => void = () => undefined + apiMock.adminSession.mockReturnValue( + new Promise((resolve) => { + resolveSession = resolve + }), + ) + const { result } = renderBootstrap() + let pending: Promise = Promise.resolve("") + act(() => { + pending = result.current() + }) + expect(useAuthStore.getState().status).toBe("loading") + await act(async () => { + resolveSession({ authenticated: true, operator: OPERATOR, csrfToken: "c" }) + await pending + }) + expect(useAuthStore.getState().status).toBe("authenticated") + }) + + it.each([ + ["unauthenticated", { authenticated: false }], + ["authenticated without an operator", { authenticated: true }], + ])("falls through to the exchange when the session is %s", async (_label, session) => { + apiMock.adminSession.mockResolvedValue(session) + apiMock.adminAccessExchange.mockResolvedValue(loginResponse({}, "csrf-x")) + expect(await bootstrap()).toBe("ok") + expect(apiMock.adminAccessExchange).toHaveBeenCalledTimes(1) + }) + + it.each([ + ["a 401", () => new AppError(ErrorCode.UNAUTHORIZED, "no session")], + ["a network failure", () => new TypeError("Failed to fetch")], + ["a 500", () => foreignAppError(ErrorCode.INTERNAL)], + ])("swallows %s on the session check and tries the exchange", async (_label, makeError) => { + apiMock.adminSession.mockRejectedValue(makeError()) + apiMock.adminAccessExchange.mockResolvedValue(loginResponse({}, "csrf-x")) + expect(await bootstrap()).toBe("ok") + expect(apiMock.adminAccessExchange).toHaveBeenCalledTimes(1) + }) +}) + +describe("useOperatorBootstrap: Access exchange", () => { + beforeEach(() => { + apiMock.adminSession.mockResolvedValue({ authenticated: false }) + }) + + it("stores the operator mapped from the login user plus the CSRF token", async () => { + apiMock.adminAccessExchange.mockResolvedValue(loginResponse({}, "csrf-x")) + expect(await bootstrap()).toBe("ok") + expect(apiMock.adminAccessExchange).toHaveBeenCalledWith() + expect(useAuthStore.getState()).toMatchObject({ + status: "authenticated", + operator: { id: "op-2", name: "Grace Ops", email: "grace@civfix.org", role: "operator" }, + csrfToken: "csrf-x", + }) + }) + + it("stores exactly id, name, email and role and drops the other user fields", async () => { + apiMock.adminAccessExchange.mockResolvedValue(loginResponse({ handle: "grace" }, "csrf-x")) + await bootstrap() + expect(Object.keys(useAuthStore.getState().operator ?? {}).sort()).toEqual(["email", "id", "name", "role"]) + }) + + it.each([null, undefined])("maps a %s email to an empty string", async (email) => { + apiMock.adminAccessExchange.mockResolvedValue(loginResponse({ email }, "csrf-x")) + await bootstrap() + expect(useAuthStore.getState().operator?.email).toBe("") + }) + + it("keeps a previously stored CSRF token when the exchange omits one", async () => { + useAuthStore.setState({ csrfToken: "csrf-old" }) + apiMock.adminAccessExchange.mockResolvedValue(loginResponse()) + await bootstrap() + expect(useAuthStore.getState().csrfToken).toBe("csrf-old") + }) + + it.each([ + ["a local AppError", () => new AppError(ErrorCode.FORBIDDEN, "not on allowlist")], + ["a foreign AppError from the client bundle", () => foreignAppError(ErrorCode.FORBIDDEN)], + ])("returns forbidden on a FORBIDDEN %s", async (_label, makeError) => { + apiMock.adminAccessExchange.mockRejectedValue(makeError()) + expect(await bootstrap()).toBe("forbidden") + expect(useAuthStore.getState()).toMatchObject({ status: "forbidden", operator: null }) + }) + + it("leaves a previously stored operator and CSRF token in place on forbidden (current behavior)", async () => { + useAuthStore.setState({ operator: OPERATOR, csrfToken: "csrf-old", status: "authenticated" }) + apiMock.adminAccessExchange.mockRejectedValue(new AppError(ErrorCode.FORBIDDEN, "no")) + expect(await bootstrap()).toBe("forbidden") + expect(useAuthStore.getState()).toMatchObject({ + status: "forbidden", + operator: OPERATOR, + csrfToken: "csrf-old", + }) + }) + + it.each([ + ["UNAUTHORIZED", () => new AppError(ErrorCode.UNAUTHORIZED, "no jwt")], + ["INTERNAL", () => foreignAppError(ErrorCode.INTERNAL)], + ["NOT_FOUND", () => new AppError(ErrorCode.NOT_FOUND, "no route")], + ["a network failure", () => new TypeError("Failed to fetch")], + ["a non-Error rejection", () => "boom"], + ])("returns error and goes anonymous on %s", async (_label, makeError) => { + apiMock.adminAccessExchange.mockRejectedValue(makeError()) + expect(await bootstrap()).toBe("error") + expect(useAuthStore.getState().status).toBe("anonymous") + }) + + it("leaves a previously stored operator in place on a retryable error (current behavior)", async () => { + useAuthStore.setState({ operator: OPERATOR, csrfToken: "csrf-old", status: "authenticated" }) + apiMock.adminAccessExchange.mockRejectedValue(new TypeError("Failed to fetch")) + expect(await bootstrap()).toBe("error") + expect(useAuthStore.getState()).toMatchObject({ + status: "anonymous", + operator: OPERATOR, + csrfToken: "csrf-old", + }) + }) + + it("can be retried after an error", async () => { + apiMock.adminAccessExchange + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(loginResponse({}, "csrf-x")) + const { result } = renderBootstrap() + const outcomes: string[] = [] + await act(async () => { + outcomes.push(await result.current()) + outcomes.push(await result.current()) + }) + expect(outcomes).toEqual(["error", "ok"]) + expect(apiMock.adminSession).toHaveBeenCalledTimes(2) + expect(useAuthStore.getState().status).toBe("authenticated") + }) + + it("returns a stable callback across renders", () => { + const { result, rerender } = renderBootstrap() + const first = result.current + rerender() + expect(result.current).toBe(first) + }) +}) + +describe("useAdminLogout", () => { + let assign: ReturnType + + beforeEach(() => { + assign = vi.fn() + vi.stubGlobal("location", { ...window.location, assign }) + useAuthStore.setState({ operator: OPERATOR, csrfToken: "csrf-s", status: "authenticated" }) + }) + + async function logout(client: QueryClient): Promise { + const { result } = renderHook(() => useAdminLogout(), { wrapper: wrapperFor(client) }) + await act(async () => { + await result.current() + }) + } + + it("revokes the session, clears the store and the cache, then leaves via Access logout", async () => { + apiMock.adminLogout.mockResolvedValue({ ok: true }) + const client = makeTestQueryClient() + client.setQueryData(["admin", "reports", "list", null], { items: [] }) + await logout(client) + expect(apiMock.adminLogout).toHaveBeenCalledTimes(1) + expect(useAuthStore.getState()).toMatchObject({ status: "anonymous", operator: null, csrfToken: null }) + expect(client.getQueryCache().getAll()).toHaveLength(0) + expect(assign).toHaveBeenCalledTimes(1) + expect(assign).toHaveBeenCalledWith(`${API_BASE_URL}/cdn-cgi/access/logout`) + }) + + it("clears local state before navigating", async () => { + apiMock.adminLogout.mockResolvedValue({ ok: true }) + let statusAtNavigation: string | null = null + assign.mockImplementation(() => { + statusAtNavigation = useAuthStore.getState().status + }) + await logout(makeTestQueryClient()) + expect(statusAtNavigation).toBe("anonymous") + }) + + it.each([ + ["a 401", () => new AppError(ErrorCode.UNAUTHORIZED, "expired")], + ["a network failure", () => new TypeError("Failed to fetch")], + ])("still clears and navigates when the logout call fails with %s", async (_label, makeError) => { + apiMock.adminLogout.mockRejectedValue(makeError()) + const client = makeTestQueryClient() + client.setQueryData(["admin", "home", "summary"], { n: 1 }) + await logout(client) + expect(useAuthStore.getState()).toMatchObject({ status: "anonymous", operator: null, csrfToken: null }) + expect(client.getQueryCache().getAll()).toHaveLength(0) + expect(assign).toHaveBeenCalledWith(`${API_BASE_URL}/cdn-cgi/access/logout`) + }) +}) + +describe("real API client 401 handling", () => { + beforeEach(() => { + useAuthStore.setState({ operator: OPERATOR, csrfToken: "csrf-s", status: "authenticated" }) + }) + + it("clears the auth store on a 401 from the session endpoint", async () => { + fetchMock.mockResolvedValue(jsonResponse(401, { code: "UNAUTHORIZED", message: "expired" })) + await expect(getApiClient().adminSession()).rejects.toMatchObject({ code: ErrorCode.UNAUTHORIZED }) + expect(useAuthStore.getState()).toMatchObject({ status: "anonymous", operator: null, csrfToken: null }) + }) + + it("clears the auth store on a 401 from any data endpoint", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 401 })) + await expect(getApiClient().adminHomeSummary()).rejects.toMatchObject({ code: ErrorCode.UNAUTHORIZED }) + expect(useAuthStore.getState().status).toBe("anonymous") + }) + + it("leaves the auth store alone on a 403", async () => { + fetchMock.mockResolvedValue(jsonResponse(403, { code: "FORBIDDEN", message: "no" })) + await expect(getApiClient().adminHomeSummary()).rejects.toMatchObject({ code: ErrorCode.FORBIDDEN }) + expect(useAuthStore.getState()).toMatchObject({ status: "authenticated", operator: OPERATOR }) + }) + + it("echoes the stored CSRF token and the web client header on logout", async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { ok: true })) + await getApiClient() + .adminLogout() + .catch(() => undefined) + const init = fetchMock.mock.calls[0]?.[1] + const headers = (init?.headers ?? {}) as Record + expect(fetchMock.mock.calls[0]?.[0]).toBe(`${API_BASE_URL}/v1/admin/auth/logout`) + expect(headers["x-csrf-token"]).toBe("csrf-s") + expect(headers["x-client"]).toBe("web") + expect(init?.credentials).toBe("include") + }) +}) From e8fd72ab9f0002b5929f4f8cff735b0d3ebad047 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:31:34 +0000 Subject: [PATCH 09/17] characterize query client, key factories, error copy and dates --- apps/admin/src/lib/dates.test.ts | 60 ++++ apps/admin/src/lib/error-messages.test.ts | 177 ++++++++++++ apps/admin/src/lib/query.test.ts | 316 ++++++++++++++++++++++ 3 files changed, 553 insertions(+) create mode 100644 apps/admin/src/lib/dates.test.ts create mode 100644 apps/admin/src/lib/error-messages.test.ts create mode 100644 apps/admin/src/lib/query.test.ts diff --git a/apps/admin/src/lib/dates.test.ts b/apps/admin/src/lib/dates.test.ts new file mode 100644 index 0000000..bc5e4a3 --- /dev/null +++ b/apps/admin/src/lib/dates.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest" + +import { formatDate, formatDateTime } from "./dates" + +const PLACEHOLDER = "\u2014" + +// Expected strings are built with the same Intl arguments the code uses so the runner's locale and +// time zone cannot flake the assertions. +function expectedDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) +} + +function expectedDateTime(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }) +} + +describe("formatDate", () => { + it.each([null, undefined, ""])("renders the placeholder for %j", (value) => { + expect(formatDate(value)).toBe(PLACEHOLDER) + }) + + it.each(["2026-09-23T14:05:00.000Z", "2026-01-01", "2024-02-29T23:59:59+05:30"])( + "formats %s as a short local date", + (iso) => { + expect(formatDate(iso)).toBe(expectedDate(iso)) + }, + ) + + it.each(["not a date", "2026-13-45", " "])("returns an unparseable string %j unchanged", (value) => { + expect(formatDate(value)).toBe(value) + }) +}) + +describe("formatDateTime", () => { + it.each([null, undefined, ""])("renders the placeholder for %j", (value) => { + expect(formatDateTime(value)).toBe(PLACEHOLDER) + }) + + it.each(["2026-09-23T14:05:00.000Z", "2026-01-01T00:00:00Z", "2024-02-29T23:59:59+05:30"])( + "formats %s as a short local date and time", + (iso) => { + expect(formatDateTime(iso)).toBe(expectedDateTime(iso)) + }, + ) + + it("is longer than the date-only form for the same instant", () => { + const iso = "2026-09-23T14:05:00.000Z" + expect(formatDateTime(iso).length).toBeGreaterThan(formatDate(iso).length) + }) + + it.each(["not a date", "2026-13-45", " "])("returns an unparseable string %j unchanged", (value) => { + expect(formatDateTime(value)).toBe(value) + }) +}) diff --git a/apps/admin/src/lib/error-messages.test.ts b/apps/admin/src/lib/error-messages.test.ts new file mode 100644 index 0000000..c0ee7ad --- /dev/null +++ b/apps/admin/src/lib/error-messages.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest" +import { AppError, ErrorCode } from "@civfix/shared" + +import { errorMessage } from "./error-messages" + +const GENERIC = "Something went wrong. Please try again." + +function foreignAppError(code: ErrorCode, message: string, fields?: Record): Error { + const err = new Error(message) as Error & { code: ErrorCode; fields?: Record } + err.name = "AppError" + err.code = code + if (fields) err.fields = fields + return err +} + +function validation(message: string, fields: Record): AppError { + return new AppError(ErrorCode.VALIDATION, message, { fields }) +} + +describe("errorMessage server message and fallback", () => { + it("shows the server message by default", () => { + expect(errorMessage(new AppError(ErrorCode.CONFLICT, "Slug taken"))).toBe("Slug taken") + }) + + it("uses the default generic fallback when the server message is empty", () => { + expect(errorMessage(new AppError(ErrorCode.CONFLICT, ""))).toBe(GENERIC) + }) + + it("uses a caller fallback when the server message is empty", () => { + expect(errorMessage(new AppError(ErrorCode.CONFLICT, ""), {}, { fallback: "Try later" })).toBe( + "Try later", + ) + }) + + it("prefers the server message over a caller fallback", () => { + expect(errorMessage(new AppError(ErrorCode.CONFLICT, "Slug taken"), {}, { fallback: "Try later" })).toBe( + "Slug taken", + ) + }) + + it("ignores the server message when preferServerMessage is false", () => { + const err = new AppError(ErrorCode.CONFLICT, "Slug taken") + expect(errorMessage(err, {}, { preferServerMessage: false })).toBe(GENERIC) + expect(errorMessage(err, {}, { preferServerMessage: false, fallback: "Try later" })).toBe("Try later") + }) + + it("reads the message of a foreign AppError from the client bundle", () => { + expect(errorMessage(foreignAppError(ErrorCode.FORBIDDEN, "Nope"))).toBe("Nope") + }) + + it("shows a plain Error's message as INTERNAL", () => { + expect(errorMessage(new TypeError("Failed to fetch"))).toBe("Failed to fetch") + }) + + it("shows the network fallback for a plain Error with no message", () => { + expect(errorMessage(new Error(""))).toBe("Network request failed") + }) + + it("shows \"Unknown error\" for a non-Error value rather than the fallback (current behavior)", () => { + expect(errorMessage("boom")).toBe("Unknown error") + expect(errorMessage(undefined, {}, { fallback: "Try later" })).toBe("Unknown error") + }) + + it("uses the fallback for a non-Error value when preferServerMessage is false", () => { + expect(errorMessage(null, {}, { preferServerMessage: false, fallback: "Try later" })).toBe("Try later") + }) +}) + +describe("errorMessage code overrides", () => { + it("uses the override for the matching code", () => { + const err = new AppError(ErrorCode.RATE_LIMITED, "Too many requests") + expect(errorMessage(err, { [ErrorCode.RATE_LIMITED]: "Slow down" })).toBe("Slow down") + }) + + it("wins over preferServerMessage false and the fallback", () => { + const err = new AppError(ErrorCode.NOT_FOUND, "missing") + expect( + errorMessage(err, { [ErrorCode.NOT_FOUND]: "Gone" }, { preferServerMessage: false, fallback: "x" }), + ).toBe("Gone") + }) + + it("ignores overrides for other codes", () => { + const err = new AppError(ErrorCode.CONFLICT, "Slug taken") + expect(errorMessage(err, { [ErrorCode.NOT_FOUND]: "Gone" })).toBe("Slug taken") + }) + + it("applies an empty-string override as the message", () => { + const err = new AppError(ErrorCode.CONFLICT, "Slug taken") + expect(errorMessage(err, { [ErrorCode.CONFLICT]: "" })).toBe("") + }) + + it("maps a non-Error value through the INTERNAL override", () => { + expect(errorMessage(42, { [ErrorCode.INTERNAL]: "Server trouble" })).toBe("Server trouble") + }) + + it("matches a foreign AppError's code", () => { + expect( + errorMessage(foreignAppError(ErrorCode.FORBIDDEN, "Nope"), { [ErrorCode.FORBIDDEN]: "Operators only" }), + ).toBe("Operators only") + }) +}) + +describe("errorMessage field errors", () => { + const fields = { + contactEmail: { unverified: "Verify the contact email first", invalid: "Email looks wrong" }, + slug: { taken: "That slug is in use" }, + } + + it("maps a field value to its copy", () => { + expect(errorMessage(validation("bad", { contactEmail: "unverified" }), {}, { fields })).toBe( + "Verify the contact email first", + ) + }) + + it("beats a code override", () => { + expect( + errorMessage( + validation("bad", { slug: "taken" }), + { [ErrorCode.VALIDATION]: "Check the form" }, + { fields }, + ), + ).toBe("That slug is in use") + }) + + it("takes the first matching field in the order of opts.fields", () => { + expect( + errorMessage(validation("bad", { slug: "taken", contactEmail: "invalid" }), {}, { fields }), + ).toBe("Email looks wrong") + }) + + it("skips a field whose value has no copy", () => { + expect( + errorMessage(validation("bad", { contactEmail: "bounced", slug: "taken" }), {}, { fields }), + ).toBe("That slug is in use") + }) + + it("falls through to the override when no field value matches", () => { + expect( + errorMessage( + validation("bad", { contactEmail: "bounced" }), + { [ErrorCode.VALIDATION]: "Check the form" }, + { fields }, + ), + ).toBe("Check the form") + }) + + it("falls through to the server message when no field or override matches", () => { + expect(errorMessage(validation("Server says no", { other: "x" }), {}, { fields })).toBe("Server says no") + }) + + it("ignores opts.fields when the error carries no fields", () => { + expect(errorMessage(new AppError(ErrorCode.VALIDATION, "Server says no"), {}, { fields })).toBe( + "Server says no", + ) + }) + + it("ignores the error's fields when opts.fields is not given", () => { + expect(errorMessage(validation("Server says no", { slug: "taken" }))).toBe("Server says no") + }) + + it("matches fields on any error code, not only VALIDATION", () => { + const err = new AppError(ErrorCode.CONFLICT, "conflict", { fields: { slug: "taken" } }) + expect(errorMessage(err, {}, { fields })).toBe("That slug is in use") + }) + + it("reads fields from a foreign AppError", () => { + expect( + errorMessage(foreignAppError(ErrorCode.VALIDATION, "bad", { slug: "taken" }), {}, { fields }), + ).toBe("That slug is in use") + }) + + it("returns an inherited Object.prototype member when a field value names one (current behavior)", () => { + expect(errorMessage(validation("bad", { slug: "toString" }), {}, { fields })).toBe( + Object.prototype.toString, + ) + }) +}) diff --git a/apps/admin/src/lib/query.test.ts b/apps/admin/src/lib/query.test.ts new file mode 100644 index 0000000..18041fd --- /dev/null +++ b/apps/admin/src/lib/query.test.ts @@ -0,0 +1,316 @@ +import { afterEach, describe, expect, it } from "vitest" +import { MutationObserver, type QueryClient } from "@tanstack/react-query" +import { AppError, ErrorCode } from "@civfix/shared" + +import { makeQueryClient, queryKeys } from "./query" +import { useUiStore } from "@/store/ui-store" + +function foreignAppError(code: ErrorCode, message: string): Error { + const err = new Error(message) as Error & { code: ErrorCode } + err.name = "AppError" + err.code = code + return err +} + +function retryOf(client: QueryClient): (failureCount: number, error: Error) => boolean { + const retry = client.getDefaultOptions().queries?.retry + if (typeof retry !== "function") throw new Error("expected a retry function") + return (failureCount, error) => Boolean(retry(failureCount, error)) +} + +async function failMutation(client: QueryClient, error: unknown): Promise { + const observer = new MutationObserver(client, { mutationFn: () => Promise.reject(error) }) + await observer.mutate().catch(() => undefined) +} + +afterEach(() => { + useUiStore.setState({ toast: null }) +}) + +describe("makeQueryClient query defaults", () => { + it("sets staleTime, gcTime and refetchOnWindowFocus", () => { + const queries = makeQueryClient().getDefaultOptions().queries + expect(queries?.staleTime).toBe(30_000) + expect(queries?.gcTime).toBe(300_000) + expect(queries?.refetchOnWindowFocus).toBe(false) + }) + + it("returns a new client on every call", () => { + expect(makeQueryClient()).not.toBe(makeQueryClient()) + }) + + it.each([ErrorCode.UNAUTHORIZED, ErrorCode.FORBIDDEN, ErrorCode.NOT_FOUND, ErrorCode.VALIDATION])( + "never retries %s", + (code) => { + const retry = retryOf(makeQueryClient()) + expect(retry(0, new AppError(code, "x"))).toBe(false) + expect(retry(1, new AppError(code, "x"))).toBe(false) + }, + ) + + it("recognizes a no-retry code on a foreign AppError from the client bundle", () => { + const retry = retryOf(makeQueryClient()) + expect(retry(0, foreignAppError(ErrorCode.NOT_FOUND, "missing"))).toBe(false) + }) + + it.each([ErrorCode.INTERNAL, ErrorCode.CONFLICT, ErrorCode.RATE_LIMITED])( + "retries %s while failureCount is below 2", + (code) => { + const retry = retryOf(makeQueryClient()) + expect(retry(0, new AppError(code, "x"))).toBe(true) + expect(retry(1, new AppError(code, "x"))).toBe(true) + expect(retry(2, new AppError(code, "x"))).toBe(false) + expect(retry(3, new AppError(code, "x"))).toBe(false) + }, + ) + + it("treats a plain network error as retryable INTERNAL", () => { + const retry = retryOf(makeQueryClient()) + expect(retry(1, new TypeError("Failed to fetch"))).toBe(true) + expect(retry(2, new TypeError("Failed to fetch"))).toBe(false) + }) +}) + +describe("makeQueryClient mutation defaults", () => { + it("does not retry mutations", () => { + expect(makeQueryClient().getDefaultOptions().mutations?.retry).toBe(false) + }) + + it("toasts the server message of a failed mutation", async () => { + await failMutation(makeQueryClient(), new AppError(ErrorCode.CONFLICT, "Slug already taken")) + expect(useUiStore.getState().toast?.text).toBe("Slug already taken") + }) + + it("toasts the message of a foreign AppError", async () => { + await failMutation(makeQueryClient(), foreignAppError(ErrorCode.FORBIDDEN, "Not allowed")) + expect(useUiStore.getState().toast?.text).toBe("Not allowed") + }) + + it("falls back to the generic copy when the error has no message", async () => { + await failMutation(makeQueryClient(), new AppError(ErrorCode.INTERNAL, "")) + expect(useUiStore.getState().toast?.text).toBe("Something went wrong. Please try again.") + }) + + it("toasts the INTERNAL \"Unknown error\" message for a non-error rejection", async () => { + await failMutation(makeQueryClient(), "boom") + expect(useUiStore.getState().toast?.text).toBe("Unknown error") + }) + + it("does not toast a successful mutation", async () => { + const observer = new MutationObserver(makeQueryClient(), { mutationFn: () => Promise.resolve(1) }) + await observer.mutate() + expect(useUiStore.getState().toast).toBeNull() + }) + + it("does not toast when the mutation sets its own onError, which replaces the default", async () => { + const seen: unknown[] = [] + const observer = new MutationObserver(makeQueryClient(), { + mutationFn: () => Promise.reject(new AppError(ErrorCode.CONFLICT, "own handler")), + onError: (err) => { + seen.push(err) + }, + }) + await observer.mutate().catch(() => undefined) + expect(seen).toHaveLength(1) + expect(useUiStore.getState().toast).toBeNull() + }) +}) + +describe("queryKeys static keys", () => { + it("session", () => { + expect(queryKeys.session).toEqual(["admin", "session"]) + }) + + it("home", () => { + expect(queryKeys.home).toEqual({ + all: ["admin", "home"], + summary: ["admin", "home", "summary"], + map: ["admin", "home", "map"], + }) + }) + + it("analytics", () => { + expect(queryKeys.analytics).toEqual({ + all: ["admin", "analytics"], + kpis: ["admin", "analytics", "kpis"], + pinsByWeek: ["admin", "analytics", "pins-by-week"], + byCategory: ["admin", "analytics", "by-category"], + funnel: ["admin", "analytics", "funnel"], + coverage: ["admin", "analytics", "coverage"], + resolutionByCategory: ["admin", "analytics", "resolution-by-category"], + events: ["admin", "analytics", "events"], + topJurisdictions: ["admin", "analytics", "top-jurisdictions"], + topContributors: ["admin", "analytics", "top-contributors"], + heatmap: ["admin", "analytics", "heatmap"], + retention: ["admin", "analytics", "retention"], + }) + }) + + it("mail stats and forward template", () => { + expect(queryKeys.mail.stats).toEqual(["admin", "mail", "stats"]) + expect(queryKeys.mail.forwardTemplate).toEqual(["admin", "mail", "forward-template"]) + }) + + it("covers exactly these top-level domains", () => { + expect(Object.keys(queryKeys)).toEqual([ + "session", + "home", + "discovery", + "jurisdictions", + "reports", + "events", + "users", + "mail", + "inbox", + "moderation", + "govClaims", + "analytics", + "orgs", + "media", + "hosts", + "pages", + "audit", + ]) + }) +}) + +describe("queryKeys `all` prefixes", () => { + it.each([ + ["discovery", queryKeys.discovery.all, ["admin", "discovery"]], + ["jurisdictions", queryKeys.jurisdictions.all, ["admin", "jurisdictions"]], + ["reports", queryKeys.reports.all, ["admin", "reports"]], + ["events", queryKeys.events.all, ["admin", "events"]], + ["users", queryKeys.users.all, ["admin", "users"]], + ["mail", queryKeys.mail.all, ["admin", "mail"]], + ["inbox", queryKeys.inbox.all, ["admin", "inbox"]], + ["moderation", queryKeys.moderation.all, ["admin", "moderation"]], + ["govClaims", queryKeys.govClaims.all, ["admin", "gov-claims"]], + ["orgs", queryKeys.orgs.all, ["admin", "orgs"]], + ["media", queryKeys.media.all, ["admin", "media"]], + ["hosts", queryKeys.hosts.all, ["admin", "hosts"]], + ["pages", queryKeys.pages.all, ["admin", "pages"]], + ["audit", queryKeys.audit.all, ["admin", "audit"]], + ] as const)("%s.all", (_name, key, expected) => { + expect(key).toEqual(expected) + }) +}) + +describe("queryKeys param factories", () => { + const params = { q: "x", limit: 10 } + + it.each([ + ["discovery.list", queryKeys.discovery.list, ["admin", "discovery", "list"]], + ["jurisdictions.list", queryKeys.jurisdictions.list, ["admin", "jurisdictions", "list"]], + ["reports.list", queryKeys.reports.list, ["admin", "reports", "list"]], + ["reports.page", queryKeys.reports.page, ["admin", "reports", "page"]], + ["events.list", queryKeys.events.list, ["admin", "events", "list"]], + ["users.list", queryKeys.users.list, ["admin", "users", "list"]], + ["users.page", queryKeys.users.page, ["admin", "users", "page"]], + ["mail.list", queryKeys.mail.list, ["admin", "mail", "list"]], + ["inbox.list", queryKeys.inbox.list, ["admin", "inbox", "list"]], + ["moderation.list", queryKeys.moderation.list, ["admin", "moderation", "list"]], + ["govClaims.list", queryKeys.govClaims.list, ["admin", "gov-claims", "list"]], + ["orgs.list", queryKeys.orgs.list, ["admin", "orgs", "list"]], + ["hosts.list", queryKeys.hosts.list, ["admin", "hosts", "list"]], + ["hosts.broadcasts", queryKeys.hosts.broadcasts, ["admin", "hosts", "broadcasts"]], + ["pages.list", queryKeys.pages.list, ["admin", "pages", "list"]], + ["audit.list", queryKeys.audit.list, ["admin", "audit", "list"]], + ] as const)("%s appends params, or null when omitted", (_name, factory, prefix) => { + expect(factory(params)).toEqual([...prefix, params]) + expect(factory()).toEqual([...prefix, null]) + expect(factory(undefined)).toEqual([...prefix, null]) + }) + + it("normalizes a null param to null and keeps other falsy params (current behavior)", () => { + expect(queryKeys.reports.list(null)).toEqual(["admin", "reports", "list", null]) + expect(queryKeys.reports.list(0)).toEqual(["admin", "reports", "list", 0]) + expect(queryKeys.reports.list("")).toEqual(["admin", "reports", "list", ""]) + }) +}) + +describe("queryKeys id factories", () => { + it.each([ + ["discovery.detail", queryKeys.discovery.detail, ["admin", "discovery", "detail"]], + ["jurisdictions.geometry", queryKeys.jurisdictions.geometry, ["admin", "jurisdictions", "geometry"]], + ["reports.detail", queryKeys.reports.detail, ["admin", "reports", "detail"]], + ["events.detail", queryKeys.events.detail, ["admin", "events", "detail"]], + ["users.detail", queryKeys.users.detail, ["admin", "users", "detail"]], + ["mail.detail", queryKeys.mail.detail, ["admin", "mail", "detail"]], + ["inbox.detail", queryKeys.inbox.detail, ["admin", "inbox", "detail"]], + ["moderation.detail", queryKeys.moderation.detail, ["admin", "moderation", "detail"]], + ["govClaims.detail", queryKeys.govClaims.detail, ["admin", "gov-claims", "detail"]], + ["orgs.detail", queryKeys.orgs.detail, ["admin", "orgs", "detail"]], + ["media.document", queryKeys.media.document, ["admin", "media", "document"]], + ["pages.preview", queryKeys.pages.preview, ["admin", "pages", "preview"]], + ] as const)("%s appends the id", (_name, factory, prefix) => { + expect(factory("id-1")).toEqual([...prefix, "id-1"]) + }) + + it("keeps an empty id as the disabled-query placeholder", () => { + expect(queryKeys.reports.detail("")).toEqual(["admin", "reports", "detail", ""]) + }) +}) + +describe("queryKeys id + params factories", () => { + const params = { cursor: "c" } + + it.each([ + ["reports.chat", queryKeys.reports.chat, "reports", "chat"], + ["users.reports", queryKeys.users.reports, "users", "reports"], + ["users.events", queryKeys.users.events, "users", "events"], + ["users.messages", queryKeys.users.messages, "users", "messages"], + ["orgs.members", queryKeys.orgs.members, "orgs", "members"], + ["orgs.events", queryKeys.orgs.events, "orgs", "events"], + ] as const)("%s puts the id before the sub-resource", (_name, factory, domain, sub) => { + expect(factory("id-1", params)).toEqual(["admin", domain, "id-1", sub, params]) + expect(factory("id-1")).toEqual(["admin", domain, "id-1", sub, null]) + }) + + it("nests id-scoped keys directly under the domain prefix, not under detail", () => { + const [a, b, c] = queryKeys.users.reports("u-1") + expect([a, b]).toEqual(queryKeys.users.all) + expect(c).toBe("u-1") + expect(queryKeys.users.reports("u-1").slice(0, 3)).not.toEqual(queryKeys.users.detail("u-1").slice(0, 3)) + }) +}) + +describe("list vs infinite key sharing", () => { + const params = { status: "open" } + + // The list and infinite hooks for these domains both call `.list(params)`, so a flat page + // and an InfiniteData payload land in one cache entry when both hooks mount with equal params. + it.each([ + ["events", queryKeys.events.list], + ["inbox", queryKeys.inbox.list], + ["mail", queryKeys.mail.list], + ["moderation", queryKeys.moderation.list], + ] as const)( + "%s list hook shares a key with its infinite hook (known collision)", + (_name, listFactory) => { + const flatKey = listFactory(params) + const infiniteKey = listFactory(params) + expect(flatKey).toEqual(infiniteKey) + }, + ) + + it("reports list hook uses reports.page, apart from the infinite reports.list", () => { + expect(queryKeys.reports.page(params)).not.toEqual(queryKeys.reports.list(params)) + expect(queryKeys.reports.page(params)).toEqual(["admin", "reports", "page", params]) + expect(queryKeys.reports.list(params)).toEqual(["admin", "reports", "list", params]) + }) + + it("users list hook uses users.page, apart from the infinite users.list", () => { + expect(queryKeys.users.page(params)).not.toEqual(queryKeys.users.list(params)) + expect(queryKeys.users.page(params)).toEqual(["admin", "users", "page", params]) + expect(queryKeys.users.list(params)).toEqual(["admin", "users", "list", params]) + }) + + it("keeps every page and list key under its domain `all` prefix so one invalidation hits both", () => { + for (const [all, keys] of [ + [queryKeys.reports.all, [queryKeys.reports.page(params), queryKeys.reports.list(params)]], + [queryKeys.users.all, [queryKeys.users.page(params), queryKeys.users.list(params)]], + ] as const) { + for (const key of keys) expect(key.slice(0, all.length)).toEqual(all) + } + }) +}) From c9e2f350ff9ee2455a04139ccc56e2743d0f3a9c Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:31:34 +0000 Subject: [PATCH 10/17] characterize leaflet map pins, tooltips and teardown --- .../src/components/map/leaflet-map.test.tsx | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 apps/admin/src/components/map/leaflet-map.test.tsx diff --git a/apps/admin/src/components/map/leaflet-map.test.tsx b/apps/admin/src/components/map/leaflet-map.test.tsx new file mode 100644 index 0000000..78dc33e --- /dev/null +++ b/apps/admin/src/components/map/leaflet-map.test.tsx @@ -0,0 +1,157 @@ +import { fireEvent, render } from "@testing-library/react" +import L from "leaflet" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { BoundaryMap } from "@/components/map/boundary-map" +import { LeafletMap, type MapPin } from "@/components/map/leaflet-map" + +// jsdom has no layout engine: Leaflet sizes the map from clientWidth/clientHeight (0 in jsdom, which +// makes fitBounds produce NaN coordinates) and the wrappers observe their container for resizes. +beforeEach(() => { + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800) + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600) + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const PINS: MapPin[] = [ + { id: "r1", lat: 34.05, lng: -118.25, category: "pothole", tip: "Deep pothole", place: "Los Angeles" }, + { id: "r2", lat: 34.06, lng: -118.24, category: "graffiti", draft: true, label: "Tagged wall" }, + { id: "e1", lat: 34.07, lng: -118.23, kind: "event", tip: "Park cleanup" }, +] + +function markerIcons(container: HTMLElement): HTMLElement[] { + return [...container.querySelectorAll(".leaflet-marker-icon")] +} + +function hoverAndReadTooltip(container: HTMLElement, icon: HTMLElement): HTMLElement | null { + fireEvent.mouseOver(icon) + return container.querySelector(".leaflet-tooltip") +} + +describe("LeafletMap", () => { + it("renders one marker per pin with the state-specific pin markup", () => { + const { container } = render() + + const icons = markerIcons(container) + expect(icons).toHaveLength(3) + expect(icons.map((el) => el.querySelector(".pi-pin2")?.className)).toEqual([ + "pi-pin2 routed", + "pi-pin2 needs", + "pi-pin2 event", + ]) + for (const el of icons) expect(el).toHaveClass("pi-pin2-wrap") + }) + + it("renders the active pin with the active treatment", () => { + const { container } = render() + + const active = container.querySelectorAll(".pi-pin2.is-active") + expect(active).toHaveLength(1) + expect(active[0]).toHaveClass("needs") + }) + + it("reconciles markers when the pin list changes", () => { + const { container, rerender } = render() + expect(markerIcons(container)).toHaveLength(3) + + rerender() + expect(markerIcons(container)).toHaveLength(1) + }) + + it("shows the title and place in the tooltip, joined by a middle dot", () => { + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip).not.toBeNull() + expect(tip).toHaveClass("pi-map-tip") + expect(tip).toHaveTextContent("Deep pothole · Los Angeles") + }) + + it("falls back to the label when a pin has no tip and omits the place suffix", () => { + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[1]!) + expect(tip?.textContent).toBe("Tagged wall") + }) + + it("binds no tooltip for a pin with neither tip nor label", () => { + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip).toBeNull() + }) + + it("renders markup in a pin title (current behavior: tooltip string is parsed as HTML)", () => { + const pin: MapPin = { id: "h", lat: 34, lng: -118, tip: "x", place: "y" } + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip?.querySelector("b")?.textContent).toBe("x") + expect(tip?.querySelector("i")?.textContent).toBe("y") + expect(tip?.textContent).toBe("x · y") + expect(tip?.textContent).not.toContain("") + }) + + it("calls onPinTap with the tapped pin", () => { + const onPinTap = vi.fn() + const { container } = render() + + fireEvent.click(markerIcons(container)[2]!) + expect(onPinTap).toHaveBeenCalledTimes(1) + expect(onPinTap).toHaveBeenCalledWith(PINS[2]) + }) + + it("removes the Leaflet map on unmount", () => { + const remove = vi.spyOn(L.Map.prototype, "remove") + const { unmount } = render() + expect(remove).not.toHaveBeenCalled() + + unmount() + expect(remove).toHaveBeenCalledTimes(1) + }) +}) + +describe("BoundaryMap", () => { + const square = { + type: "Polygon", + coordinates: [ + [ + [-118.3, 34.0], + [-118.2, 34.0], + [-118.2, 34.1], + [-118.3, 34.1], + [-118.3, 34.0], + ], + ], + } + const bbox: [number, number, number, number] = [-118.3, 34.0, -118.2, 34.1] + + it("draws the boundary polygon", () => { + const { container } = render() + + const path = container.querySelector("path.leaflet-interactive") + expect(path).not.toBeNull() + expect(path).toHaveAttribute("stroke", "#3F7CAC") + }) + + it("removes the Leaflet map on unmount", () => { + const remove = vi.spyOn(L.Map.prototype, "remove") + const { unmount } = render() + expect(remove).not.toHaveBeenCalled() + + unmount() + expect(remove).toHaveBeenCalledTimes(1) + }) +}) From 6cbe1cd5f39ed2e809210d6f0d2b9d25de1af0cc Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 19:31:34 +0000 Subject: [PATCH 11/17] characterize section page list, detail and deep-link states --- .../analytics/analytics-page.test.tsx | 485 +++++++++++++++++ .../discovery/discovery-page.test.tsx | 330 ++++++++++++ .../src/features/events/events-page.test.tsx | 237 +++++++++ .../src/features/home/home-page.test.tsx | 494 ++++++++++++++++++ .../src/features/hosts/hosts-page.test.tsx | 365 +++++++++++++ .../src/features/mail/mail-page.test.tsx | 458 ++++++++++++++++ .../moderation/moderation-page.test.tsx | 418 +++++++++++++++ .../src/features/orgs/orgs-page.test.tsx | 474 +++++++++++++++++ .../src/features/pages/pages-page.test.tsx | 362 +++++++++++++ .../features/reports/reports-page.test.tsx | 297 +++++++++++ .../src/features/users/users-page.test.tsx | 245 +++++++++ 11 files changed, 4165 insertions(+) create mode 100644 apps/admin/src/features/analytics/analytics-page.test.tsx create mode 100644 apps/admin/src/features/discovery/discovery-page.test.tsx create mode 100644 apps/admin/src/features/events/events-page.test.tsx create mode 100644 apps/admin/src/features/home/home-page.test.tsx create mode 100644 apps/admin/src/features/hosts/hosts-page.test.tsx create mode 100644 apps/admin/src/features/mail/mail-page.test.tsx create mode 100644 apps/admin/src/features/moderation/moderation-page.test.tsx create mode 100644 apps/admin/src/features/orgs/orgs-page.test.tsx create mode 100644 apps/admin/src/features/pages/pages-page.test.tsx create mode 100644 apps/admin/src/features/reports/reports-page.test.tsx create mode 100644 apps/admin/src/features/users/users-page.test.tsx diff --git a/apps/admin/src/features/analytics/analytics-page.test.tsx b/apps/admin/src/features/analytics/analytics-page.test.tsx new file mode 100644 index 0000000..601b6ee --- /dev/null +++ b/apps/admin/src/features/analytics/analytics-page.test.tsx @@ -0,0 +1,485 @@ +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { + AnalyticsByCategoryResponse, + AnalyticsCoverageResponse, + AnalyticsEventsResponse, + AnalyticsFunnelResponse, + AnalyticsKpisResponse, + AnalyticsPinsByWeekResponse, + AnalyticsResolutionByCategoryResponse, + AnalyticsTopContributorsResponse, + AnalyticsTopJurisdictionsResponse, +} from "@civfix/shared" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { Toast } from "@/components/shell/toast" +import type * as ApiModule from "@/lib/api" +import { AnalyticsPage } from "@/features/analytics/analytics-page" +import { useUiStore } from "@/store/ui-store" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +afterEach(() => { + useUiStore.setState({ toast: null }) + vi.restoreAllMocks() +}) + +const ANALYTICS_METHODS = [ + "analyticsKpis", + "analyticsPinsByWeek", + "analyticsByCategory", + "analyticsFunnel", + "analyticsCoverage", + "analyticsResolutionByCategory", + "analyticsEvents", + "analyticsTopJurisdictions", + "analyticsTopContributors", +] as const + +const CARD_TITLES = [ + "Pins per week", + "By category", + "Report funnel", + "Mapping coverage", + "Median resolution time", + "Cleanup events", + "Top jurisdictions", + "Top contributors", +] as const + +const kpis = { + kpis: [ + { label: "Pins dropped", num: 1234, delta: "+12% vs last month", dir: "up" }, + { label: "Reports routed", num: 900, delta: "+3%", dir: "up" }, + { label: "Resolved", num: 41.6, delta: "-2 pts", dir: "down" }, + { label: "Cleanups planned", num: 7, delta: "no change", dir: "flat" }, + ], +} satisfies AnalyticsKpisResponse + +const pinsByWeek = { + weeks: [3, 1200], + labels: ["Wk A", "Wk B"], +} satisfies AnalyticsPinsByWeekResponse + +const byCategory = { + rows: [ + { cat: "trash", count: 12, pct: 70 }, + { cat: "graffiti", count: 5, pct: 30 }, + ], +} satisfies AnalyticsByCategoryResponse + +const funnel = { + stages: [ + { stage: "Pin dropped", count: 1500, pct: 100 }, + { stage: "Resolved", count: 300, pct: 20 }, + ], +} satisfies AnalyticsFunnelResponse + +const coverage = { pct: 75, mapped: 30, needsMapping: 10 } satisfies AnalyticsCoverageResponse + +const resolution = { + rows: [ + { cat: "trash", hours: 5 }, + { cat: "graffiti", hours: 50 }, + { cat: "hazard", hours: 0.5 }, + { cat: "water", hours: 0 }, + ], +} satisfies AnalyticsResolutionByCategoryResponse + +const events = { + thisMonth: 4, + volunteers: 1250, + bags: 0, + byMonth: [2, 6], + monthLabels: ["Aug", "Sep"], +} satisfies AnalyticsEventsResponse + +const topJurisdictions = { + rows: [ + { org: "Oakland", pins: 120, resolved: 64 }, + { org: "Berkeley", pins: 45, resolved: 20 }, + ], +} satisfies AnalyticsTopJurisdictionsResponse + +const topContributors = { + rows: [ + { name: "Ada Lovelace", city: "Oakland", reports: 17, cleanups: 3 }, + { name: "grace", city: "", reports: 9, cleanups: 1 }, + ], +} satisfies AnalyticsTopContributorsResponse + +function mockPopulated() { + apiMock.analyticsKpis.mockResolvedValue(kpis) + apiMock.analyticsPinsByWeek.mockResolvedValue(pinsByWeek) + apiMock.analyticsByCategory.mockResolvedValue(byCategory) + apiMock.analyticsFunnel.mockResolvedValue(funnel) + apiMock.analyticsCoverage.mockResolvedValue(coverage) + apiMock.analyticsResolutionByCategory.mockResolvedValue(resolution) + apiMock.analyticsEvents.mockResolvedValue(events) + apiMock.analyticsTopJurisdictions.mockResolvedValue(topJurisdictions) + apiMock.analyticsTopContributors.mockResolvedValue(topContributors) +} + +function card(title: string): HTMLElement { + const section = screen.getByRole("heading", { level: 3, name: title }).closest("section") + if (!section) throw new Error(`no card section for ${title}`) + return section +} + +async function findCard(title: string): Promise { + await screen.findByRole("heading", { level: 3, name: title }) + return card(title) +} + +// jsdom's Blob has no text(); FileReader is its supported read path. +function readBlob(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result)) + reader.onerror = () => reject(reader.error) + reader.readAsText(blob) + }) +} + +function renderPage() { + return renderWithQuery( + <> + + + , + ) +} + +describe("AnalyticsPage", () => { + it("renders the page header with every card title and its meta line", async () => { + mockPopulated() + renderPage() + + expect(screen.getByRole("heading", { level: 1, name: "Analytics" })).toBeInTheDocument() + expect(screen.getByText(/The numbers are the proof civfix works\./)).toBeInTheDocument() + const metas: Record<(typeof CARD_TITLES)[number], string | RegExp> = { + "Pins per week": "8-week trend", + "By category": "this month", + "Report funnel": /^pin .* resolved$/, + "Mapping coverage": "jurisdictions", + "Median resolution time": "by report type", + "Cleanup events": "8-month trend", + "Top jurisdictions": "by pin volume", + "Top contributors": "reports + cleanups", + } + for (const title of CARD_TITLES) { + expect(within(await findCard(title)).getByText(metas[title])).toBeInTheDocument() + } + }) + + it("calls each analytics endpoint once with no arguments", async () => { + mockPopulated() + renderPage() + + await screen.findByText("Oakland", { selector: ".td-strong" }) + for (const method of ANALYTICS_METHODS) { + expect(apiMock[method]).toHaveBeenCalledTimes(1) + expect(apiMock[method]).toHaveBeenCalledWith() + } + }) + + it("shows a loading state for the KPI strip and every card while requests are in flight", () => { + for (const method of ANALYTICS_METHODS) { + apiMock[method].mockReturnValue(new Promise(() => {})) + } + renderPage() + + expect(screen.getByText("Loading KPIs...")).toBeInTheDocument() + for (const title of CARD_TITLES) { + expect(within(card(title)).getByText(`Loading ${title.toLowerCase()}...`)).toBeInTheDocument() + } + expect(screen.getAllByRole("status")).toHaveLength(CARD_TITLES.length + 1) + expect(screen.getByRole("button", { name: "Export" })).toBeDisabled() + }) + + it("shows an error with the server message and a retry in the KPI strip and every card", async () => { + for (const method of ANALYTICS_METHODS) { + apiMock[method].mockRejectedValue(new Error(`${method} is down`)) + } + renderPage() + + await waitFor(() => expect(screen.getAllByRole("alert")).toHaveLength(CARD_TITLES.length + 1)) + expect(screen.getByText("analyticsKpis is down")).toBeInTheDocument() + const cardMethods: Record<(typeof CARD_TITLES)[number], string> = { + "Pins per week": "analyticsPinsByWeek", + "By category": "analyticsByCategory", + "Report funnel": "analyticsFunnel", + "Mapping coverage": "analyticsCoverage", + "Median resolution time": "analyticsResolutionByCategory", + "Cleanup events": "analyticsEvents", + "Top jurisdictions": "analyticsTopJurisdictions", + "Top contributors": "analyticsTopContributors", + } + for (const title of CARD_TITLES) { + const alert = within(card(title)).getByRole("alert") + expect(within(alert).getByText("Could not load this")).toBeInTheDocument() + expect(within(alert).getByText(`${cardMethods[title]} is down`)).toBeInTheDocument() + expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument() + } + expect(screen.getByRole("button", { name: "Export" })).toBeDisabled() + }) + + it("isolates a failing card: the other cards still render their data", async () => { + mockPopulated() + apiMock.analyticsFunnel.mockRejectedValue(new Error("funnel exploded")) + renderPage() + + expect(await within(card("Report funnel")).findByText("funnel exploded")).toBeInTheDocument() + expect(within(card("Mapping coverage")).getByText("30 mapped")).toBeInTheDocument() + expect(screen.getAllByRole("alert")).toHaveLength(1) + }) + + it("retries only the failed card when Try again is pressed", async () => { + mockPopulated() + apiMock.analyticsCoverage.mockRejectedValueOnce(new Error("coverage offline")) + renderPage() + + const coverageCard = await findCard("Mapping coverage") + await within(coverageCard).findByText("coverage offline") + await userEvent.click(within(coverageCard).getByRole("button", { name: "Try again" })) + + expect(await within(coverageCard).findByText("30 mapped")).toBeInTheDocument() + expect(apiMock.analyticsCoverage).toHaveBeenCalledTimes(2) + expect(apiMock.analyticsFunnel).toHaveBeenCalledTimes(1) + }) + + it("retries the KPI strip from its error state", async () => { + mockPopulated() + apiMock.analyticsKpis.mockRejectedValueOnce(new Error("kpis offline")) + renderPage() + + const alert = (await screen.findByText("kpis offline")).closest("[role=alert]") as HTMLElement + await userEvent.click(within(alert).getByRole("button", { name: "Try again" })) + + expect(await screen.findByText("1,234")).toBeInTheDocument() + expect(screen.queryByText("kpis offline")).not.toBeInTheDocument() + expect(apiMock.analyticsKpis).toHaveBeenCalledTimes(2) + }) + + it("shows the 'No data yet' empty state on every card when the aggregates are empty", async () => { + apiMock.analyticsKpis.mockResolvedValue({ kpis: [] } satisfies AnalyticsKpisResponse) + apiMock.analyticsPinsByWeek.mockResolvedValue({ weeks: [], labels: [] } satisfies AnalyticsPinsByWeekResponse) + apiMock.analyticsByCategory.mockResolvedValue({ + rows: [{ cat: "trash", count: 0, pct: 0 }], + } satisfies AnalyticsByCategoryResponse) + apiMock.analyticsFunnel.mockResolvedValue({ stages: [] } satisfies AnalyticsFunnelResponse) + apiMock.analyticsCoverage.mockResolvedValue({ + pct: 0, + mapped: 0, + needsMapping: 0, + } satisfies AnalyticsCoverageResponse) + apiMock.analyticsResolutionByCategory.mockResolvedValue({ + rows: [{ cat: "trash", hours: 0 }], + } satisfies AnalyticsResolutionByCategoryResponse) + apiMock.analyticsEvents.mockResolvedValue({ + thisMonth: 3, + volunteers: 10, + bags: 2, + byMonth: [], + monthLabels: [], + } satisfies AnalyticsEventsResponse) + apiMock.analyticsTopJurisdictions.mockResolvedValue({ rows: [] } satisfies AnalyticsTopJurisdictionsResponse) + apiMock.analyticsTopContributors.mockResolvedValue({ rows: [] } satisfies AnalyticsTopContributorsResponse) + renderPage() + + await waitFor(() => expect(screen.getAllByText("No data yet")).toHaveLength(CARD_TITLES.length)) + for (const title of CARD_TITLES) { + const c = card(title) + expect(within(c).getByText("No data yet")).toBeInTheDocument() + expect(within(c).getByText("Nothing to show for this window.")).toBeInTheDocument() + } + expect(screen.queryByText("events this month")).not.toBeInTheDocument() + expect(screen.queryByRole("status")).not.toBeInTheDocument() + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + // An empty KPI list still counts as loaded, so Export is enabled with an empty strip. + expect(screen.getByRole("button", { name: "Export" })).toBeEnabled() + }) + + it("renders the KPI strip: hides routing KPIs, shows resolved as a rounded percent, counts with separators", async () => { + mockPopulated() + renderPage() + + expect(await screen.findByText("Pins dropped")).toBeInTheDocument() + expect(screen.getByText("1,234")).toBeInTheDocument() + expect(screen.getByText("+12% vs last month")).toBeInTheDocument() + expect(screen.getByText("Resolved", { selector: ".statcell-label" })).toBeInTheDocument() + expect(screen.getByText("42%")).toBeInTheDocument() + expect(screen.getByText("-2 pts")).toBeInTheDocument() + expect(screen.getByText("Cleanups planned")).toBeInTheDocument() + expect(screen.getByText("7")).toBeInTheDocument() + expect(screen.getByText("no change")).toBeInTheDocument() + expect(screen.queryByText("Reports routed")).not.toBeInTheDocument() + expect(screen.queryByText("900")).not.toBeInTheDocument() + }) + + it("renders the pins-per-week bars with compact values and week labels", async () => { + mockPopulated() + renderPage() + + const c = await findCard("Pins per week") + expect(await within(c).findByText("1.2k")).toBeInTheDocument() + expect(within(c).getByText("3")).toBeInTheDocument() + expect(within(c).getByText("Wk A")).toBeInTheDocument() + expect(within(c).getByText("Wk B")).toBeInTheDocument() + }) + + it("renders the by-category breakdown with labels and counts", async () => { + mockPopulated() + renderPage() + + const c = await findCard("By category") + expect(await within(c).findByText("Trash")).toBeInTheDocument() + expect(within(c).getByText("12")).toBeInTheDocument() + expect(within(c).getByText("Graffiti")).toBeInTheDocument() + expect(within(c).getByText("5")).toBeInTheDocument() + }) + + it("renders the report funnel stages with formatted counts", async () => { + mockPopulated() + renderPage() + + const c = await findCard("Report funnel") + expect(await within(c).findByText("Pin dropped")).toBeInTheDocument() + expect(within(c).getByText("1,500")).toBeInTheDocument() + expect(within(c).getByText("Resolved")).toBeInTheDocument() + expect(within(c).getByText("300")).toBeInTheDocument() + }) + + it("renders mapping coverage as a percent with the mapped and needs-mapping legend", async () => { + mockPopulated() + renderPage() + + const c = await findCard("Mapping coverage") + expect(await within(c).findByText("30 mapped")).toBeInTheDocument() + expect(within(c).getByText("10 need mapping")).toBeInTheDocument() + expect(within(c).getByText("75", { exact: false })).toHaveTextContent("75%") + expect(within(c).getByText("routed & live")).toBeInTheDocument() + }) + + it("renders median resolution time humanized per category", async () => { + mockPopulated() + renderPage() + + const c = await findCard("Median resolution time") + expect(await within(c).findByText("Trash")).toBeInTheDocument() + expect(within(c).getByText("5h")).toBeInTheDocument() + expect(within(c).getByText("Graffiti")).toBeInTheDocument() + expect(within(c).getByText("2d 2h")).toBeInTheDocument() + expect(within(c).getByText("Hazard")).toBeInTheDocument() + expect(within(c).getByText("<1h")).toBeInTheDocument() + expect(within(c).getByText("Water")).toBeInTheDocument() + expect(within(c).getByText(/^\u2014$/)).toBeInTheDocument() + }) + + it("renders cleanup events stats and hides bags collected when there are none", async () => { + mockPopulated() + renderPage() + + const c = await findCard("Cleanup events") + expect(await within(c).findByText("events this month")).toBeInTheDocument() + expect(within(c).getByText("4")).toBeInTheDocument() + expect(within(c).getByText("volunteers")).toBeInTheDocument() + expect(within(c).getByText("1,250")).toBeInTheDocument() + expect(within(c).queryByText("bags collected")).not.toBeInTheDocument() + expect(within(c).getByText("Aug")).toBeInTheDocument() + expect(within(c).getByText("Sep")).toBeInTheDocument() + }) + + it("shows bags collected when the events aggregate has bags", async () => { + mockPopulated() + apiMock.analyticsEvents.mockResolvedValue({ ...events, bags: 2500 } satisfies AnalyticsEventsResponse) + renderPage() + + const c = await findCard("Cleanup events") + expect(await within(c).findByText("bags collected")).toBeInTheDocument() + expect(within(c).getByText("2,500")).toBeInTheDocument() + }) + + it("renders the top jurisdictions table", async () => { + mockPopulated() + renderPage() + + const c = await findCard("Top jurisdictions") + expect(await within(c).findByText("Oakland")).toBeInTheDocument() + for (const header of ["Jurisdiction", "Pins", "Resolved"]) { + expect(within(c).getByText(header)).toBeInTheDocument() + } + expect(within(c).getByText("120")).toBeInTheDocument() + expect(within(c).getByText("64%")).toBeInTheDocument() + expect(within(c).getByText("Berkeley")).toBeInTheDocument() + expect(within(c).getByText("45")).toBeInTheDocument() + expect(within(c).getByText("20%")).toBeInTheDocument() + }) + + it("renders the top contributors table with initials and a placeholder for a missing city", async () => { + mockPopulated() + renderPage() + + const c = await findCard("Top contributors") + expect(await within(c).findByText("Ada Lovelace")).toBeInTheDocument() + for (const header of ["Neighbor", "Reports", "Cleanups"]) { + expect(within(c).getByText(header)).toBeInTheDocument() + } + expect(within(c).getByText("AL")).toBeInTheDocument() + expect(within(c).getByText("Oakland")).toBeInTheDocument() + expect(within(c).getByText("17")).toBeInTheDocument() + expect(within(c).getByText("grace")).toBeInTheDocument() + expect(within(c).getByText("G")).toBeInTheDocument() + expect(within(c).getByText(/^\u2014$/)).toBeInTheDocument() + expect(within(c).getByText("9")).toBeInTheDocument() + }) + + it("exports the KPIs, event totals and category shares as a CSV download and confirms with a toast", async () => { + mockPopulated() + const blobs: Blob[] = [] + const createObjectURL = vi.fn((blob: Blob) => { + blobs.push(blob) + return "blob:analytics" + }) + Object.defineProperty(URL, "createObjectURL", { configurable: true, writable: true, value: createObjectURL }) + Object.defineProperty(URL, "revokeObjectURL", { configurable: true, writable: true, value: vi.fn() }) + const downloads: string[] = [] + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { + downloads.push(this.download) + }) + renderPage() + + const exportButton = screen.getByRole("button", { name: "Export" }) + expect(exportButton).toBeDisabled() + await within(await findCard("Cleanup events")).findByText("events this month") + await within(card("By category")).findByText("Trash") + await waitFor(() => expect(exportButton).toBeEnabled()) + await userEvent.click(exportButton) + + expect(downloads).toEqual(["civfix-analytics.csv"]) + expect(blobs).toHaveLength(1) + const blob = blobs[0] as Blob + expect(blob.type).toBe("text/csv") + expect(await readBlob(blob)).toBe( + [ + '"Metric","Value","Change"', + '"Pins dropped","1234","\'+12% vs last month"', + '"Resolved","41.6","\'-2 pts"', + '"Cleanups planned","7","no change"', + '"Cleanup events (month)","4",""', + '"Volunteers","1250",""', + "", + '"Category","Reports","Share %"', + '"Trash","12","70"', + '"Graffiti","5","30"', + ].join("\n"), + ) + expect(await screen.findByText("Analytics exported · civfix-analytics.csv")).toBeInTheDocument() + }) +}) diff --git a/apps/admin/src/features/discovery/discovery-page.test.tsx b/apps/admin/src/features/discovery/discovery-page.test.tsx new file mode 100644 index 0000000..0637ac0 --- /dev/null +++ b/apps/admin/src/features/discovery/discovery-page.test.tsx @@ -0,0 +1,330 @@ +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi } from "vitest" +import type { + GetForwardTemplateDefaultResponse, + JurisdictionDirectoryDTO, + JurisdictionDirectoryResponse, + JurisdictionGeometryResponse, +} from "@civfix/shared" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { DiscoveryPage } from "@/features/discovery/discovery-page" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +// Leaflet cannot draw in jsdom; the boundary map is irrelevant to the list/detail states pinned here. +vi.mock("@/components/map/boundary-map", () => ({ + BoundaryMap: () =>
, +})) + + +const DAY_MS = 24 * 60 * 60 * 1000 + +function jurisdiction(overrides: Partial = {}): JurisdictionDirectoryDTO { + return { + geoid: "0644000", + org: "Los Angeles", + dept: null, + email: null, + form: null, + method: "none", + status: "pending", + coverage: "City limits", + lastRouted: null, + layer: "place", + population: 3_898_747, + reportsWaiting: 4, + perCategoryCounts: { trash: 3, graffiti: 1 }, + contacts: [], + flaggedAt: null, + handle: null, + oldestReportAt: new Date(Date.now() - 3 * DAY_MS).toISOString(), + forwardSubjectTemplate: null, + forwardBodyTemplate: null, + ...overrides, + } satisfies JurisdictionDirectoryDTO +} + +const LA = jurisdiction() +const PASADENA = jurisdiction({ + geoid: "0656000", + org: "Pasadena", + method: "email", + status: "verified", + email: "reports@pasadena.gov", + population: 138_699, + reportsWaiting: 0, + perCategoryCounts: {}, + contacts: [{ category: "trash", email: "trash@pasadena.gov" }], + oldestReportAt: null, + lastRouted: "2026-09-01T12:00:00.000Z", +}) + +function page( + items: JurisdictionDirectoryDTO[], + extra: Partial = {}, +): JurisdictionDirectoryResponse { + return { + items, + nextCursor: null, + total: items.length, + facets: { routed: 12, unrouted: items.length }, + ...extra, + } satisfies JurisdictionDirectoryResponse +} + +const GEOMETRY = { + geoid: "0644000", + layer: "place", + geometry: { type: "Polygon", coordinates: [] }, + bbox: [-118.7, 33.7, -118.1, 34.3], + point: { lat: 34.05, lng: -118.25 }, +} as unknown as JurisdictionGeometryResponse + +function mockDetailQueries(): void { + apiMock.getJurisdictionGeometry.mockResolvedValue(GEOMETRY) + apiMock.getForwardTemplateDefault.mockResolvedValue({ + subjectTemplate: null, + bodyTemplate: null, + updatedAt: null, + } satisfies GetForwardTemplateDefaultResponse) +} + +function listCalls(): Record[] { + return apiMock.listJurisdictions.mock.calls.map((call) => call[0] as Record) +} + +function chip(name: RegExp): HTMLElement { + return screen.getByRole("button", { name }) +} + +describe("DiscoveryPage", () => { + it("shows the loading state while the directory is in flight", async () => { + apiMock.listJurisdictions.mockReturnValue(new Promise(() => {})) + renderWithQuery() + + expect(await screen.findByRole("status")).toHaveTextContent("Loading jurisdictions…") + expect(chip(/^Needs mapping/)).toHaveTextContent("Loading…") + expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent("Loading… jurisdictions") + expect(screen.getByText("No jurisdiction selected")).toBeInTheDocument() + }) + + it("requests the needs-mapping view oldest-first by default, with the sort locked", async () => { + apiMock.listJurisdictions.mockReturnValue(new Promise(() => {})) + renderWithQuery() + + await waitFor(() => expect(apiMock.listJurisdictions).toHaveBeenCalled()) + // The list and the needs-mapping count share one query key, so one request serves both. + expect(listCalls()).toEqual([{ filter: "needs_mapping", sort: "oldest", limit: 50 }]) + expect(screen.getByRole("combobox", { name: "Sort jurisdictions" })).toBeDisabled() + expect(screen.getByRole("combobox", { name: "Sort jurisdictions" })).toHaveValue("oldest") + }) + + it("shows the empty state when nothing matches", async () => { + apiMock.listJurisdictions.mockResolvedValue(page([], { total: 0, facets: { routed: 0, unrouted: 0 } })) + renderWithQuery() + + expect(await screen.findByText("Nothing matches")).toBeInTheDocument() + expect(screen.getByText("Try a different filter or search.")).toBeInTheDocument() + expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent("0 jurisdictions") + expect(screen.getByText("No jurisdiction selected")).toBeInTheDocument() + expect(screen.getByText("Pick a place from the list.")).toBeInTheDocument() + }) + + it("shows the error message with a retry that refetches", async () => { + apiMock.listJurisdictions.mockRejectedValue(new Error("directory is down")) + renderWithQuery() + + const alert = await screen.findByRole("alert") + expect(alert).toHaveTextContent("Could not load this") + expect(alert).toHaveTextContent("directory is down") + expect(chip(/^Needs mapping/)).toHaveTextContent(/\u2014/) + + apiMock.listJurisdictions.mockResolvedValue(page([LA])) + await userEvent.click(within(alert).getByRole("button", { name: "Try again" })) + expect(await screen.findByRole("heading", { level: 2, name: "Los Angeles" })).toBeInTheDocument() + }) + + it("renders rows and auto-selects the first one into the detail pane", async () => { + mockDetailQueries() + apiMock.listJurisdictions.mockResolvedValue(page([LA, PASADENA], { total: 2 })) + renderWithQuery() + + const laRow = await screen.findByRole("button", { name: /^Los Angeles/ }) + expect(laRow).toHaveTextContent("City") + expect(laRow).toHaveTextContent("Needs contact") + expect(laRow).toHaveTextContent("0644000") + expect(laRow).toHaveTextContent("3899k pop") + expect(laRow).toHaveTextContent("4 waiting") + expect(laRow).toHaveTextContent("City limits") + expect(laRow).toHaveTextContent("3d") + + const pasadenaRow = screen.getByRole("button", { name: /^Pasadena/ }) + expect(pasadenaRow).toHaveTextContent("Routed") + expect(pasadenaRow).toHaveTextContent("139k pop") + + expect(chip(/^Needs mapping/)).toHaveTextContent("2") + expect(chip(/^Routed/)).toHaveTextContent("12") + expect(chip(/^All/)).toHaveTextContent("14") + expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent("2 jurisdictions") + + expect(screen.getByRole("heading", { level: 2, name: "Los Angeles" })).toBeInTheDocument() + expect(screen.getByText("City · Jurisdiction · GEOID 0644000")).toBeInTheDocument() + expect(screen.getByText("3,898,747")).toBeInTheDocument() + expect(screen.getByText("US Census ACS")).toBeInTheDocument() + expect(screen.getByText("2 types with no contact")).toBeInTheDocument() + expect(screen.getByText("0 of 7 contacts set")).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Save & route/ })).toBeDisabled() + expect(await screen.findByTestId("boundary-map")).toBeInTheDocument() + expect(apiMock.getJurisdictionGeometry).toHaveBeenCalledWith({ geoid: "0644000" }) + }) + + it("opens the clicked row in the detail pane", async () => { + mockDetailQueries() + apiMock.listJurisdictions.mockResolvedValue(page([LA, PASADENA], { total: 2 })) + renderWithQuery() + + await userEvent.click(await screen.findByRole("button", { name: /^Pasadena/ })) + + expect(screen.getByRole("heading", { level: 2, name: "Pasadena" })).toBeInTheDocument() + expect(screen.getByText("City · Jurisdiction · GEOID 0656000")).toBeInTheDocument() + expect(screen.getByDisplayValue("reports@pasadena.gov")).toBeInTheDocument() + expect(screen.getByDisplayValue("trash@pasadena.gov")).toBeInTheDocument() + expect(screen.getByText("1 of 7 contacts set")).toBeInTheDocument() + expect(screen.getByText("all routed")).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Save & route/ })).toBeEnabled() + await waitFor(() => + expect(apiMock.getJurisdictionGeometry).toHaveBeenCalledWith({ geoid: "0656000" }), + ) + }) + + it("renders the unmapped bucket as a triage row and detail", async () => { + apiMock.listJurisdictions.mockResolvedValue( + page([ + jurisdiction({ + geoid: "__unmapped__", + org: "Unmapped reports", + reportsWaiting: 5, + perCategoryCounts: { hazard: 5 }, + }), + ]), + ) + renderWithQuery() + + const row = await screen.findByRole("button", { name: /^Unmapped reports/ }) + expect(row).toHaveTextContent("Needs mapping") + expect(row).toHaveTextContent("5 waiting") + expect(screen.getByText("Triage · location did not resolve")).toBeInTheDocument() + expect(screen.getByRole("heading", { level: 2, name: "Unmapped reports" })).toBeInTheDocument() + expect(screen.getByText("Waiting reports by type")).toBeInTheDocument() + expect(screen.getByText("5 reports")).toBeInTheDocument() + expect(apiMock.getJurisdictionGeometry).not.toHaveBeenCalled() + }) + + it("loads the next page with the cursor when Load more is clicked", async () => { + mockDetailQueries() + apiMock.listJurisdictions + .mockResolvedValueOnce(page([LA], { nextCursor: "cursor-2", total: 2 })) + .mockResolvedValueOnce(page([PASADENA], { nextCursor: null })) + renderWithQuery() + + await userEvent.click(await screen.findByRole("button", { name: "Load more" })) + + expect(await screen.findByRole("button", { name: /^Pasadena/ })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^Los Angeles/ })).toBeInTheDocument() + expect(listCalls()).toEqual([ + { filter: "needs_mapping", sort: "oldest", limit: 50 }, + { filter: "needs_mapping", sort: "oldest", limit: 50, cursor: "cursor-2" }, + ]) + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) + + it("maps the filter chips, type and sort controls onto the request params", async () => { + mockDetailQueries() + apiMock.listJurisdictions.mockResolvedValue(page([LA])) + renderWithQuery() + await screen.findByRole("button", { name: /^Los Angeles/ }) + + await userEvent.click(chip(/^Routed/)) + await waitFor(() => + expect(listCalls()).toContainEqual({ filter: "routed", sort: "population", limit: 50 }), + ) + const sort = screen.getByRole("combobox", { name: "Sort jurisdictions" }) + expect(sort).toBeEnabled() + expect(sort).toHaveValue("pop") + + await userEvent.selectOptions(sort, "reports") + await waitFor(() => + expect(listCalls()).toContainEqual({ filter: "routed", sort: "reports", limit: 50 }), + ) + + await userEvent.click(chip(/^All/)) + await waitFor(() => + expect(listCalls()).toContainEqual({ filter: "all", sort: "reports", limit: 50 }), + ) + + await userEvent.selectOptions( + screen.getByRole("combobox", { name: "Filter by jurisdiction type" }), + "county", + ) + await waitFor(() => + expect(listCalls()).toContainEqual({ filter: "all", sort: "reports", layer: "county", limit: 50 }), + ) + // The needs-mapping chip count follows the type filter too. + await waitFor(() => + expect(listCalls()).toContainEqual({ + filter: "needs_mapping", + sort: "oldest", + layer: "county", + limit: 50, + }), + ) + + await userEvent.click(chip(/^Needs mapping/)) + expect(screen.getByRole("combobox", { name: "Sort jurisdictions" })).toBeDisabled() + expect(screen.getByRole("combobox", { name: "Sort jurisdictions" })).toHaveValue("oldest") + }) + + it("sends the trimmed search after the debounce", async () => { + mockDetailQueries() + apiMock.listJurisdictions.mockResolvedValue(page([LA])) + renderWithQuery() + await screen.findByRole("button", { name: /^Los Angeles/ }) + + await userEvent.type(screen.getByPlaceholderText("Search place or GEOID…"), " pasa ") + + await waitFor(() => + expect(listCalls()).toContainEqual({ filter: "needs_mapping", sort: "oldest", q: "pasa", limit: 50 }), + ) + }) + + it("deep-links a focusId into the search box and opens that jurisdiction", async () => { + mockDetailQueries() + apiMock.listJurisdictions.mockResolvedValue(page([LA, PASADENA])) + renderWithQuery() + + expect(screen.getByPlaceholderText("Search place or GEOID…")).toHaveValue("0656000") + expect(await screen.findByRole("heading", { level: 2, name: "Pasadena" })).toBeInTheDocument() + await waitFor(() => + expect(listCalls()).toContainEqual({ filter: "needs_mapping", sort: "oldest", q: "0656000", limit: 50 }), + ) + // The first request goes out before the debounce settles, so it carries no search term. + expect(listCalls()[0]).toEqual({ filter: "needs_mapping", sort: "oldest", limit: 50 }) + }) + + it("holds an empty detail pane when the focused jurisdiction is not in the loaded page", async () => { + mockDetailQueries() + apiMock.listJurisdictions.mockResolvedValue(page([LA])) + renderWithQuery() + + expect(await screen.findByRole("button", { name: /^Los Angeles/ })).toBeInTheDocument() + expect(screen.getByText("No jurisdiction selected")).toBeInTheDocument() + expect(screen.queryByRole("heading", { level: 2 })).not.toBeInTheDocument() + }) +}) diff --git a/apps/admin/src/features/events/events-page.test.tsx b/apps/admin/src/features/events/events-page.test.tsx new file mode 100644 index 0000000..0c3a3d4 --- /dev/null +++ b/apps/admin/src/features/events/events-page.test.tsx @@ -0,0 +1,237 @@ +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { + AdminEventDTO, + AdminEventListItemDTO, + AdminEventListResponse, +} from "@civfix/shared" +import { describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { EventsPage } from "@/features/events/events-page" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +// Leaflet needs real layout; the minimap is irrelevant to the list/detail states pinned here. +vi.mock("@/components/map/leaflet-map", () => ({ + LeafletMap: () =>
, +})) + + +function listItem(over: Partial & { id: string; title: string }) { + return { + status: "upcoming", + eventKind: "cleanup", + flagged: false, + place: "Lake Merritt", + attendees: 0, + capacity: null, + bags: 0, + organizer: { id: "u-org", name: "Olive Park", handle: "@olive", joined: "Mar 2026" }, + date: { rel: "in 3 days", abs: "Sep 26, 9:00 AM" }, + coords: [37.8, -122.26], + ...over, + } satisfies AdminEventListItemDTO +} + +const BEACH = listItem({ + id: "aaaa1111-0000-4000-8000-000000000001", + title: "Beach sweep", + place: "Ocean Beach", + attendees: 14, + capacity: 20, +}) +const PARK = listItem({ + id: "bbbb2222-0000-4000-8000-000000000002", + title: "Park tidy", + status: "completed", + flagged: true, + attendees: 6, + bags: 9, + organizer: { id: "u-sam", name: "Sam Lee", handle: "@samlee", joined: "-" }, +}) + +const COUNTS = { all: 7, upcoming: 3, in_progress: 1, completed: 2, flagged: 1 } + +function page(items: AdminEventListItemDTO[], nextCursor: string | null = null) { + return { items, nextCursor, counts: COUNTS } satisfies AdminEventListResponse +} + +function detail(item: AdminEventListItemDTO): AdminEventDTO { + return { + ...item, + desc: `About ${item.title}`, + address: `${item.place} main entrance`, + timeline: [{ who: "Olive", what: "created the event", when: "2d ago", kind: "create" }], + messages: [], + linkedReports: [], + } satisfies AdminEventDTO +} + +function mockDetails(...events: AdminEventListItemDTO[]) { + apiMock.getAdminEvent.mockImplementation(async ({ id }: { id: string }) => { + const e = events.find((x) => x.id === id) + if (!e) throw new Error(`no event ${id}`) + return detail(e) + }) +} + +function listCard() { + return screen.getByRole("heading", { level: 3, name: "Events" }).closest("section") as HTMLElement +} + +function detailCard() { + return document.querySelector(".md-detail-card") as HTMLElement +} + +describe("EventsPage", () => { + it("shows the loading state while the first page is in flight", () => { + apiMock.listAdminEvents.mockReturnValue(new Promise(() => {})) + renderWithQuery() + expect(screen.getByRole("status")).toHaveTextContent("Loading events...") + expect(within(detailCard()).getByText("No event selected")).toBeInTheDocument() + }) + + it("shows the empty copy when no event matches", async () => { + apiMock.listAdminEvents.mockResolvedValue(page([])) + renderWithQuery() + expect(await screen.findByText("Nothing matches")).toBeInTheDocument() + expect(screen.getByText("Try a different filter or search.")).toBeInTheDocument() + expect(within(detailCard()).getByText("Pick an event from the list.")).toBeInTheDocument() + }) + + it("shows the error state with the failure message and a retry", async () => { + apiMock.listAdminEvents.mockRejectedValue(new Error("Events backend unreachable")) + renderWithQuery() + const alert = await screen.findByRole("alert") + expect(alert).toHaveTextContent("Could not load this") + expect(alert).toHaveTextContent("Events backend unreachable") + expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument() + }) + + it("renders rows with key fields and auto-selects the first event into the detail pane", async () => { + apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK])) + mockDetails(BEACH, PARK) + renderWithQuery() + + await screen.findByText("Beach sweep") + const list = listCard() + expect(within(list).getByText("#aaaa1111")).toBeInTheDocument() + expect(within(list).getByText("Ocean Beach")).toBeInTheDocument() + expect(within(list).getByText("14 attending")).toBeInTheDocument() + expect(within(list).getByText("Olive")).toBeInTheDocument() + expect(within(list).getByText("Sam")).toBeInTheDocument() + expect(within(list).getByText("Upcoming")).toBeInTheDocument() + expect(within(list).getAllByText("Sep 26, 9:00 AM")).toHaveLength(2) + expect(within(list).getByText("Park tidy")).toBeInTheDocument() + expect(within(list).getByText("Completed")).toBeInTheDocument() + expect(within(list).getByTitle("Flagged")).toBeInTheDocument() + expect(apiMock.listAdminEvents).toHaveBeenCalledWith({}) + + const card = detailCard() + expect(await within(card).findByRole("heading", { name: "Beach sweep" })).toBeInTheDocument() + expect(within(card).getByText("#aaaa1111 · Cleanup")).toBeInTheDocument() + expect(within(card).getByText("About Beach sweep")).toBeInTheDocument() + expect(within(card).getByText("Ocean Beach main entrance")).toBeInTheDocument() + expect(within(card).getByText("created the event")).toBeInTheDocument() + expect(within(card).getByText("No linked reports")).toBeInTheDocument() + expect(within(card).getByText("Olive Park")).toBeInTheDocument() + expect(within(card).getByPlaceholderText("Post an update to 14 attendees…")).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /Cancel event/ })).toBeEnabled() + expect(apiMock.getAdminEvent).toHaveBeenCalledWith({ id: BEACH.id }) + }) + + it("opens the clicked event in the detail pane", async () => { + apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK])) + mockDetails(BEACH, PARK) + renderWithQuery() + await within(detailCard()).findByRole("heading", { name: "Beach sweep" }) + + await userEvent.click(within(listCard()).getByText("Park tidy")) + + const card = detailCard() + expect(await within(card).findByRole("heading", { name: "Park tidy" })).toBeInTheDocument() + // Both the status badge and the toggled flag button read "Flagged". + expect(within(card).getAllByText("Flagged")).toHaveLength(2) + expect(within(card).getByRole("button", { name: "Flagged" })).toBeInTheDocument() + expect(within(card).getByText("9")).toBeInTheDocument() + expect(within(card).getByText(/bags collected/)).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /Cancel event/ })).toBeDisabled() + expect(apiMock.getAdminEvent).toHaveBeenCalledWith({ id: PARK.id }) + }) + + it("shows the facet counts on the filter chips", async () => { + apiMock.listAdminEvents.mockResolvedValue(page([BEACH])) + mockDetails(BEACH) + renderWithQuery() + await screen.findByText("Beach sweep") + expect(screen.getByRole("button", { name: "All 7" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "In progress 1" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Flagged 1" })).toBeInTheDocument() + }) + + it("sends the chosen filter chip to the api", async () => { + apiMock.listAdminEvents.mockResolvedValue(page([BEACH])) + mockDetails(BEACH) + renderWithQuery() + await screen.findByText("Beach sweep") + + await userEvent.click(screen.getByRole("button", { name: /^In progress/ })) + await waitFor(() => + expect(apiMock.listAdminEvents).toHaveBeenLastCalledWith({ filter: "in_progress" }), + ) + + await userEvent.click(screen.getByRole("button", { name: /^Flagged/ })) + await waitFor(() => + expect(apiMock.listAdminEvents).toHaveBeenLastCalledWith({ filter: "flagged" }), + ) + }) + + it("sends the trimmed search text to the api after the debounce", async () => { + apiMock.listAdminEvents.mockResolvedValue(page([BEACH])) + mockDetails(BEACH) + renderWithQuery() + await screen.findByText("Beach sweep") + + await userEvent.type(screen.getByPlaceholderText("Search title, place, organizer…"), " beach ") + await waitFor(() => expect(apiMock.listAdminEvents).toHaveBeenLastCalledWith({ q: "beach" })) + }) + + it("shows Load more when a cursor is returned and fetches the next page with it", async () => { + apiMock.listAdminEvents.mockImplementation(async (params: { cursor?: string }) => + params.cursor === "ev-2" ? page([PARK]) : page([BEACH], "ev-2"), + ) + mockDetails(BEACH, PARK) + renderWithQuery() + await screen.findByText("Beach sweep") + + await userEvent.click(screen.getByRole("button", { name: "Load more" })) + + expect(await within(listCard()).findByText("Park tidy")).toBeInTheDocument() + expect(apiMock.listAdminEvents).toHaveBeenLastCalledWith({ cursor: "ev-2" }) + expect(within(listCard()).getByText("Beach sweep")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) + + it("opens a deep-linked focusId that is on the first page", async () => { + apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK])) + mockDetails(BEACH, PARK) + renderWithQuery() + expect(await within(detailCard()).findByRole("heading", { name: "Park tidy" })).toBeInTheDocument() + expect(within(detailCard()).queryByRole("heading", { name: "Beach sweep" })).not.toBeInTheDocument() + }) + + it("replaces a deep-linked focusId that is not on the first page with the first row (auto-select override)", async () => { + const OTHER = listItem({ id: "cccc3333-0000-4000-8000-000000000003", title: "Creek haul" }) + apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK])) + mockDetails(BEACH, PARK, OTHER) + renderWithQuery() + expect(await within(detailCard()).findByRole("heading", { name: "Beach sweep" })).toBeInTheDocument() + expect(within(detailCard()).queryByRole("heading", { name: "Creek haul" })).not.toBeInTheDocument() + }) +}) diff --git a/apps/admin/src/features/home/home-page.test.tsx b/apps/admin/src/features/home/home-page.test.tsx new file mode 100644 index 0000000..9256543 --- /dev/null +++ b/apps/admin/src/features/home/home-page.test.tsx @@ -0,0 +1,494 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react" +import type { + AdminEventListResponse, + AdminReportListResponse, + AdminUserListResponse, + DiscoveryListResponse, + HomeSummaryResponse, + InboxListResponse, + MailListResponse, + ModerationListResponse, +} from "@civfix/shared" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { HomePage } from "@/features/home/home-page" + +// SOURCE is computed when src/lib/source.ts is first evaluated, so the build-time commit must be in +// the environment before the page module loads. +const COMMIT = vi.hoisted(() => { + const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01" + process.env.NEXT_PUBLIC_COMMIT_SHA = sha + return sha +}) + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +vi.mock("@/components/map/live-map", () => ({ + LiveMap: () =>
live map
, +})) + +const never = () => new Promise(() => {}) + +const summary = { + discovery: { queue: 12, reportsWaiting: 30, overSla: 4 }, + reports: { flagged: 5, inProgress: 8, completed: 21 }, + events: { upcoming: 3, live: 1, attending: 44 }, + mail: { unread: 1, needsAction: 2 }, + users: { flagged: 6, highRisk: 1, suspended: 0 }, + analytics: { + pinsThisMonth: 42, + resolvedPct: 61, + coveragePct: 77, + cleanups: 9, + eventsThisMonth: 4, + newUsers: 15, + pinsByWeek: [1, 4, 2, 6], + }, + livePins24h: 7, + moderationQueue: 11, + inboxUnread: 3, +} satisfies HomeSummaryResponse + +const actor = { id: "u-9", name: "Dana Reyes", handle: "dana", joined: "2025-01-01" } + +const discoveryPage = { + items: [ + { + id: "d-1", + geoid: "0644000", + place: "Los Angeles", + layer: "place", + category: "trash", + catLabel: "Trash", + pop: 3898747, + reports: 17, + perCategoryCounts: { trash: 17 }, + lastReport: "2026-09-20", + age: "3d", + overSla: true, + priority: "high", + contactState: { routed: [], missing: ["trash"] }, + notes: [], + }, + { + id: "d-2", + geoid: "0600001", + place: "Tiny Town", + layer: "place", + category: "graffiti", + catLabel: "Graffiti", + pop: 0, + reports: 2, + perCategoryCounts: { graffiti: 2 }, + lastReport: "2026-09-21", + age: "1d", + overSla: false, + priority: "low", + contactState: { routed: [], missing: ["graffiti"] }, + notes: [], + }, + ], + nextCursor: "next", +} satisfies DiscoveryListResponse + +const reportsPage = { + items: [ + { + id: "r-1", + category: "hazard", + status: "in_progress", + flagged: false, + title: "Broken streetlight", + place: "Echo Park", + reporter: actor, + confirmations: 2, + submitted: { rel: "2h", abs: "Sep 23, 10:00" }, + coords: [34.07, -118.26], + address: "1 Main St", + hasPhoto: false, + thumbnailUrl: null, + }, + ], + nextCursor: null, + counts: { all: 1, submitted: 0, in_progress: 1, completed: 0, flagged: 0 }, +} satisfies AdminReportListResponse + +const eventsPage = { + items: [ + { + id: "e-1", + status: "upcoming", + eventKind: "cleanup", + flagged: false, + title: "Beach cleanup", + place: "Venice", + attendees: 14, + capacity: 30, + bags: 0, + organizer: actor, + date: { rel: "in 2d", abs: "Sep 25" }, + coords: [33.98, -118.47], + }, + ], + nextCursor: null, + counts: { all: 1, upcoming: 1, in_progress: 0, completed: 0, flagged: 0 }, +} satisfies AdminEventListResponse + +const mailPage = { + items: [ + { + id: "m-1", + dir: "out", + from: "routing@civfix.org", + to: "works@la.gov", + org: "LA Public Works", + subject: "Pothole on Main", + preview: "Hello", + ts: "5m", + unread: false, + status: "sent", + jurisdictionGeoid: "0644000", + reportId: null, + }, + ], + nextCursor: null, +} satisfies MailListResponse + +const inboxPage = { + items: [ + { + id: "in-1", + from: "", + recipient: "hello@civfix.org", + localPart: "hello", + subject: "", + preview: "", + ts: "9m", + status: "unread", + unread: true, + hasAttachments: false, + }, + ], + nextCursor: null, +} satisfies InboxListResponse + +const usersPage = { + items: [ + { + id: "u-1", + name: "Sam Rivera", + handle: "sam", + city: "Pasadena", + joined: "2025-02-02", + avatar: null, + status: "review", + reports: 3, + cleanups: 0, + removals: 1, + strikes: 1, + risk: "high", + lastActive: "1h", + flagged: true, + flagReason: "Spam pattern", + }, + ], + nextCursor: null, +} satisfies AdminUserListResponse + +const moderationPage = { + items: [ + { + id: "mod-1", + flag: "flag", + reporter: "Pat", + category: null, + reason: "Harassment", + age: "20m", + priority: "med", + kind: "user_report", + subjectType: "comment", + subjectId: "c-1", + destinationKind: null, + destinationId: null, + reporterId: null, + }, + ], + nextCursor: null, +} satisfies ModerationListResponse + +function mockAllLoaded() { + apiMock.adminHomeSummary.mockResolvedValue(summary) + apiMock.listDiscovery.mockResolvedValue(discoveryPage) + apiMock.listAdminReports.mockResolvedValue(reportsPage) + apiMock.listAdminEvents.mockResolvedValue(eventsPage) + apiMock.listMail.mockResolvedValue(mailPage) + apiMock.listInbox.mockResolvedValue(inboxPage) + apiMock.listAdminUsers.mockResolvedValue(usersPage) + apiMock.listModeration.mockResolvedValue(moderationPage) +} + +/** The tile a section head button belongs to (the head button and the tile body share one container). */ +function tileOf(headName: RegExp): HTMLElement { + const head = screen.getByRole("button", { name: headName }) + const tile = head.closest("div") + if (!tile) throw new Error("tile container not found") + return tile +} + +beforeEach(() => { + vi.spyOn(window, "scrollTo").mockImplementation(() => {}) + window.history.replaceState(null, "", "/") +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("HomePage", () => { + it("shows the dashboard loading state while the summary is in flight, with the launcher and footer", () => { + apiMock.adminHomeSummary.mockImplementation(never) + apiMock.listDiscovery.mockImplementation(never) + apiMock.listAdminReports.mockImplementation(never) + apiMock.listAdminEvents.mockImplementation(never) + apiMock.listMail.mockImplementation(never) + apiMock.listInbox.mockImplementation(never) + apiMock.listAdminUsers.mockImplementation(never) + apiMock.listModeration.mockImplementation(never) + + renderWithQuery() + + expect(screen.getByRole("status")).toHaveTextContent("Loading dashboard...") + expect(screen.queryByRole("button", { name: /^Reports/ })).not.toBeInTheDocument() + expect(screen.getByRole("heading", { name: "Host platform" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Organizations/ })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Host messaging/ })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Signup pages/ })).toBeInTheDocument() + expect(screen.getByRole("link", { name: /Source code \(AGPL-3\.0\)/ })).toBeInTheDocument() + }) + + it("replaces every section tile with one retryable error when the summary fails", async () => { + mockAllLoaded() + apiMock.adminHomeSummary.mockRejectedValue(new Error("summary exploded")) + + renderWithQuery() + + const alert = await screen.findByRole("alert") + expect(alert).toHaveTextContent("Could not load the dashboard") + expect(alert).toHaveTextContent("summary exploded") + expect(screen.queryByText("Los Angeles")).not.toBeInTheDocument() + expect(screen.getByRole("heading", { name: "Host platform" })).toBeInTheDocument() + + apiMock.adminHomeSummary.mockResolvedValue(summary) + fireEvent.click(within(alert).getByRole("button", { name: "Try again" })) + expect(await screen.findByRole("button", { name: /^Jurisdictions/ })).toBeInTheDocument() + expect(apiMock.adminHomeSummary).toHaveBeenCalledTimes(2) + }) + + it("renders the summary tiles with their lead counts from the summary", async () => { + mockAllLoaded() + renderWithQuery() + + expect( + await screen.findByRole("button", { name: /^Jurisdictions\s*12 jurisdictions in queue$/ }), + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^Reports\s*5 reports flagged$/ })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^Events\s*3 upcoming events$/ })).toBeInTheDocument() + expect( + screen.getByRole("button", { name: /^Mail\s*1 unread outreach message$/ }), + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^Users\s*6 accounts flagged$/ })).toBeInTheDocument() + expect( + screen.getByRole("button", { + name: /^Moderation\s*11 queued \W user reports, held media, clusters and appeals$/, + }), + ).toBeInTheDocument() + + expect( + screen.getByText(/Jurisdictions with reports waiting on routing setup/), + ).toBeInTheDocument() + + const analytics = tileOf(/^Analytics$/) + expect(within(analytics).getByText("42")).toBeInTheDocument() + expect(within(analytics).getByText("pins this month")).toBeInTheDocument() + expect(within(analytics).getByText("61%")).toBeInTheDocument() + expect(within(analytics).getByText("Resolved")).toBeInTheDocument() + expect(within(analytics).getByText("77%")).toBeInTheDocument() + expect(within(analytics).getByText("Coverage")).toBeInTheDocument() + expect(within(analytics).getByText("New users")).toBeInTheDocument() + expect(within(analytics).getByText("15")).toBeInTheDocument() + expect( + within(analytics).getByRole("button", { name: /^9 Cleanups\s*See analytics$/ }), + ).toBeInTheDocument() + expect(within(analytics).queryByText("No data yet")).not.toBeInTheDocument() + }) + + it("renders up to two preview rows per tile and the remaining count", async () => { + mockAllLoaded() + renderWithQuery() + + const discovery = await waitFor(() => { + const tile = tileOf(/^Jurisdictions/) + within(tile).getByText("Los Angeles") + return tile + }) + expect(within(discovery).getByText("17 reports · pop 3899k")).toBeInTheDocument() + expect(within(discovery).getByText("3d")).toBeInTheDocument() + expect(within(discovery).getByText("Tiny Town")).toBeInTheDocument() + expect(within(discovery).getByText("2 reports")).toBeInTheDocument() + expect(within(discovery).getByRole("button", { name: /and 10 more cities\s*Open/ })).toBeInTheDocument() + + const reports = tileOf(/^Reports/) + expect(await within(reports).findByText("Broken streetlight")).toBeInTheDocument() + expect(within(reports).getByText("Echo Park · In progress")).toBeInTheDocument() + expect(within(reports).getByText("2h")).toBeInTheDocument() + + const events = tileOf(/^Events/) + expect(await within(events).findByText("Beach cleanup")).toBeInTheDocument() + expect(within(events).getByText("Venice · 14 attending")).toBeInTheDocument() + expect(within(events).getByText("in 2d")).toBeInTheDocument() + + const mail = tileOf(/^Mail/) + expect(await within(mail).findByText("LA Public Works")).toBeInTheDocument() + expect(within(mail).getByText("Pothole on Main")).toBeInTheDocument() + expect(within(mail).getByText("hello@civfix.org")).toBeInTheDocument() + expect(within(mail).getByText("(no subject)")).toBeInTheDocument() + + const users = tileOf(/^Users/) + expect(await within(users).findByText("Sam Rivera")).toBeInTheDocument() + expect(within(users).getByText("SR")).toBeInTheDocument() + expect(within(users).getByText("Spam pattern")).toBeInTheDocument() + + const moderation = tileOf(/^Moderation/) + expect(await within(moderation).findByText("Reported comment")).toBeInTheDocument() + expect(within(moderation).getByText("Harassment · Pat")).toBeInTheDocument() + + expect(apiMock.listDiscovery).toHaveBeenCalledWith({ limit: 3 }) + expect(apiMock.listAdminReports).toHaveBeenCalledWith({ limit: 3 }) + expect(apiMock.listAdminEvents).toHaveBeenCalledWith({ limit: 3 }) + expect(apiMock.listMail).toHaveBeenCalledWith({ limit: 3 }) + expect(apiMock.listInbox).toHaveBeenCalledWith({ status: "all", limit: 3 }) + expect(apiMock.listAdminUsers).toHaveBeenCalledWith({ limit: 3 }) + expect(apiMock.listModeration).toHaveBeenCalledWith({ limit: 3 }) + }) + + it("shows a per-tile loading state and a per-tile error without taking down the other tiles", async () => { + mockAllLoaded() + apiMock.listAdminReports.mockImplementation(never) + apiMock.listAdminUsers.mockRejectedValue(new Error("users unavailable")) + + renderWithQuery() + + const users = await waitFor(() => { + const tile = tileOf(/^Users/) + within(tile).getByRole("alert") + return tile + }) + expect(within(users).getByRole("alert")).toHaveTextContent("Could not load this") + expect(within(users).getByRole("alert")).toHaveTextContent("users unavailable") + expect(screen.getAllByRole("alert")).toHaveLength(1) + + const reports = tileOf(/^Reports/) + expect(within(reports).getByRole("status")).toHaveTextContent("Loading...") + + expect(await within(tileOf(/^Events/)).findByText("Beach cleanup")).toBeInTheDocument() + + apiMock.listAdminUsers.mockResolvedValue(usersPage) + fireEvent.click(within(users).getByRole("button", { name: "Try again" })) + expect(await within(tileOf(/^Users/)).findByText("Sam Rivera")).toBeInTheDocument() + }) + + it("shows the mail tile error when only the inbox half fails", async () => { + mockAllLoaded() + apiMock.listInbox.mockRejectedValue(new Error("inbox down")) + + renderWithQuery() + + const mail = await waitFor(() => { + const tile = tileOf(/^Mail/) + within(tile).getByRole("alert") + return tile + }) + expect(within(mail).getByRole("alert")).toHaveTextContent("inbox down") + expect(within(mail).queryByText("LA Public Works")).not.toBeInTheDocument() + }) + + it("shows an empty line in every preview tile when the lists come back empty", async () => { + mockAllLoaded() + const empty = { items: [], nextCursor: null } + apiMock.listDiscovery.mockResolvedValue(empty satisfies DiscoveryListResponse) + apiMock.listAdminReports.mockResolvedValue({ ...empty, counts: reportsPage.counts }) + apiMock.listAdminEvents.mockResolvedValue({ ...empty, counts: eventsPage.counts }) + apiMock.listMail.mockResolvedValue(empty satisfies MailListResponse) + apiMock.listInbox.mockResolvedValue(empty satisfies InboxListResponse) + apiMock.listAdminUsers.mockResolvedValue(empty satisfies AdminUserListResponse) + apiMock.listModeration.mockResolvedValue(empty satisfies ModerationListResponse) + + renderWithQuery() + + await waitFor(() => expect(screen.getAllByText("Nothing here right now.")).toHaveLength(6)) + expect(screen.queryByText(/more cities/)).not.toBeInTheDocument() + }) + + it("leads the moderation tile with a label and shows an empty spark when the summary has no counts", async () => { + mockAllLoaded() + const { moderationQueue: _queue, inboxUnread: _unread, ...rest } = summary + apiMock.adminHomeSummary.mockResolvedValue({ + ...rest, + analytics: { ...summary.analytics, pinsByWeek: [0, 0, 0] }, + } satisfies HomeSummaryResponse) + + renderWithQuery() + + expect( + await screen.findByRole("button", { + name: /^Moderation\s*user reports, held media, clusters and appeals$/, + }), + ).toBeInTheDocument() + expect(within(tileOf(/^Analytics$/)).getByText("No data yet")).toBeInTheDocument() + }) + + it("opens the matching section with the row's focus when a preview row is clicked", async () => { + mockAllLoaded() + renderWithQuery() + + fireEvent.click(await screen.findByRole("button", { name: /Los Angeles/ })) + expect(window.location.hash).toBe("#/discovery/0644000") + + fireEvent.click(await screen.findByRole("button", { name: /hello@civfix\.org/ })) + expect(window.location.hash).toBe("#/mail/inbox%3Ain-1") + + fireEvent.click(screen.getByRole("button", { name: /^Reports\s*5 reports flagged$/ })) + expect(window.location.hash).toBe("#/reports") + + fireEvent.click(screen.getByRole("button", { name: /Signup pages/ })) + expect(window.location.hash).toBe("#/pages") + }) + + it("links the footer source to the repository at the build commit", () => { + apiMock.adminHomeSummary.mockImplementation(never) + apiMock.listDiscovery.mockImplementation(never) + apiMock.listAdminReports.mockImplementation(never) + apiMock.listAdminEvents.mockImplementation(never) + apiMock.listMail.mockImplementation(never) + apiMock.listInbox.mockImplementation(never) + apiMock.listAdminUsers.mockImplementation(never) + apiMock.listModeration.mockImplementation(never) + + renderWithQuery() + + const link = screen.getByRole("link", { name: /Source code \(AGPL-3\.0\)/ }) + expect(link).toHaveAttribute( + "href", + `https://github.com/civfix/civfix-admin/tree/${COMMIT.toLowerCase()}`, + ) + expect(link).toHaveAttribute("target", "_blank") + expect(link).toHaveAttribute("rel", "noreferrer noopener") + expect(screen.getByText(COMMIT.toLowerCase().slice(0, 7))).toBeInTheDocument() + }) +}) diff --git a/apps/admin/src/features/hosts/hosts-page.test.tsx b/apps/admin/src/features/hosts/hosts-page.test.tsx new file mode 100644 index 0000000..b604be4 --- /dev/null +++ b/apps/admin/src/features/hosts/hosts-page.test.tsx @@ -0,0 +1,365 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { + AdminBroadcastListItemDTO, + AdminBroadcastListResponse, + AdminHostListItemDTO, + AdminHostListResponse, +} from "@civfix/shared" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { DialogHost } from "@/components/shared/dialog" +import type * as ApiModule from "@/lib/api" +import { HostsPage } from "@/features/hosts/hosts-page" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +afterEach(() => { + vi.useRealTimers() +}) + +function actor(id: string, name: string, handle: string) { + return { id, name, handle, joined: "2025-01-01T00:00:00.000Z" } +} + +function host(overrides: Partial & { id: string; name: string; handle: string }): AdminHostListItemDTO { + const { id, name, handle, ...rest } = overrides + return { + host: actor(id, name, handle), + messagingSuspended: false, + suspendedAt: null, + suspendedBy: null, + windowDays: 30, + broadcastCount: 12, + recipientCount: 340, + sentCount: 330, + failedCount: 0, + suppressedCount: 10, + lastBroadcastAt: "2026-09-20T15:00:00.000Z", + eventsMessaged: 3, + ...rest, + } satisfies AdminHostListItemDTO +} + +const ada = host({ id: "h-ada", name: "Ada Lovelace", handle: "@ada" }) +const bob = host({ + id: "h-bob", + name: "Bob Builder", + handle: "@bob", + messagingSuspended: true, + suspendedAt: "2026-09-18T10:00:00.000Z", + suspendedBy: actor("op-1", "Olive Operator", "@olive"), + broadcastCount: 1, + recipientCount: 9, + sentCount: 5, + failedCount: 4, + suppressedCount: 2, + eventsMessaged: 1, +}) + +function page(items: AdminHostListItemDTO[], nextCursor: string | null = null): AdminHostListResponse { + return { items, nextCursor } satisfies AdminHostListResponse +} + +function broadcast(overrides: Partial & { id: string; cleanupId: string }): AdminBroadcastListItemDTO { + return { + eventTitle: "Park cleanup", + kind: "host_broadcast", + status: "sent", + subjectHash: "abc123", + createdBy: null, + recipientCount: 40, + sentCount: 38, + failedCount: 2, + suppressedCount: 0, + channels: ["email", "push"], + createdAt: "2026-09-19T12:00:00.000Z", + finishedAt: "2026-09-19T12:05:00.000Z", + ...overrides, + } satisfies AdminBroadcastListItemDTO +} + +function broadcasts(items: AdminBroadcastListItemDTO[], nextCursor: string | null = null): AdminBroadcastListResponse { + return { items, nextCursor } satisfies AdminBroadcastListResponse +} + +function listSection() { + return screen.getByRole("heading", { name: "Hosts", level: 3 }).closest("section") as HTMLElement +} + +function detailSection() { + return listSection().nextElementSibling as HTMLElement +} + +describe("HostsPage list states", () => { + it("shows the loading state while the host list is in flight", async () => { + apiMock.adminListHosts.mockReturnValue(new Promise(() => {})) + renderWithQuery() + + expect(screen.getByRole("heading", { name: "Host messaging", level: 1 })).toBeInTheDocument() + expect(await screen.findByText("Loading hosts...")).toBeInTheDocument() + expect(within(detailSection()).getByText("No host selected")).toBeInTheDocument() + }) + + it("shows the empty state when no host matches", async () => { + apiMock.adminListHosts.mockResolvedValue(page([])) + renderWithQuery() + + expect(await screen.findByText("No hosts here")).toBeInTheDocument() + expect( + screen.getByText("No host matches this filter and search in the last 30 days."), + ).toBeInTheDocument() + expect(within(listSection()).getByText("0")).toBeInTheDocument() + expect(within(detailSection()).getByText("No host selected")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) + + it("shows the error message with a retry that refetches", async () => { + apiMock.adminListHosts.mockRejectedValue(new Error("Hosts backend down")) + renderWithQuery() + + const alert = await screen.findByRole("alert") + expect(within(alert).getByText("Could not load this")).toBeInTheDocument() + expect(within(alert).getByText("Hosts backend down")).toBeInTheDocument() + + apiMock.adminListHosts.mockResolvedValue(page([])) + await userEvent.click(within(alert).getByRole("button", { name: "Try again" })) + expect(await screen.findByText("No hosts here")).toBeInTheDocument() + }) + + it("renders rows with their counters and auto-selects the first host", async () => { + apiMock.adminListHosts.mockResolvedValue(page([ada, bob])) + apiMock.adminListBroadcasts.mockResolvedValue(broadcasts([])) + renderWithQuery() + + const list = listSection() + expect(await within(list).findByText("Ada Lovelace")).toBeInTheDocument() + expect(within(list).getByText("@ada")).toBeInTheDocument() + expect(within(list).getByText("12 broadcasts")).toBeInTheDocument() + expect(within(list).getByText("340 recipients")).toBeInTheDocument() + expect(within(list).getByText("3 events")).toBeInTheDocument() + expect(within(list).getByText("Bob Builder")).toBeInTheDocument() + expect(within(list).getByText("1 event")).toBeInTheDocument() + expect(within(list).getByText("Suspended")).toBeInTheDocument() + expect(within(list).getByText("4 failed")).toBeInTheDocument() + expect(within(list).getByText("2")).toBeInTheDocument() + + const detail = detailSection() + expect(await within(detail).findByRole("heading", { name: "Ada Lovelace", level: 2 })).toBeInTheDocument() + expect(within(detail).getByText("Messaging active")).toBeInTheDocument() + expect(within(detail).getByText("last 30 days")).toBeInTheDocument() + expect(within(detail).getByText("10 suppressed")).toBeInTheDocument() + expect(within(detail).getByRole("button", { name: "Suspend messaging" })).toBeInTheDocument() + expect(apiMock.adminListHosts).toHaveBeenCalledWith({ q: undefined, windowDays: 30 }) + }) +}) + +describe("HostsPage detail pane", () => { + it("opens the clicked host with its suspension note and broadcast log", async () => { + apiMock.adminListHosts.mockResolvedValue(page([ada, bob])) + apiMock.adminListBroadcasts.mockImplementation(async (params: { createdBy: string }) => + params.createdBy === "h-bob" + ? broadcasts([ + broadcast({ id: "b-1", cleanupId: "ev-1", eventTitle: "River sweep" }), + broadcast({ id: "b-2", cleanupId: "ev-1", eventTitle: null, status: "failed", channels: [] }), + ]) + : broadcasts([]), + ) + renderWithQuery() + + await userEvent.click(await within(listSection()).findByText("Bob Builder")) + + const detail = detailSection() + expect(await within(detail).findByRole("heading", { name: "Bob Builder", level: 2 })).toBeInTheDocument() + expect(within(detail).getByText("Messaging suspended")).toBeInTheDocument() + expect(within(detail).getByText(/by Olive Operator\. The mandatory reason is in the/)).toBeInTheDocument() + expect(within(detail).getByRole("button", { name: "Restore messaging" })).toBeInTheDocument() + + // The log row links the event and "Events reached" lists it once more, deduplicated by event. + expect(await within(detail).findAllByRole("button", { name: "River sweep" })).toHaveLength(2) + expect(within(detail).getAllByText("Host broadcast")).toHaveLength(2) + expect(within(detail).getByText("Failed", { selector: ".pill" })).toBeInTheDocument() + expect(within(detail).getByText("no channel")).toBeInTheDocument() + expect(within(detail).getByText("email, push")).toBeInTheDocument() + expect(within(detail).getByText(/This log is content-free by design/)).toBeInTheDocument() + + expect(apiMock.adminListBroadcasts).toHaveBeenLastCalledWith({ + createdBy: "h-bob", + kind: "host_broadcast", + from: expect.any(String), + }) + }) + + it("shows the empty broadcast log and no events for a host with nothing logged", async () => { + apiMock.adminListHosts.mockResolvedValue(page([ada])) + apiMock.adminListBroadcasts.mockResolvedValue(broadcasts([])) + renderWithQuery() + + const detail = detailSection() + expect(await within(detail).findByText("No broadcasts logged")).toBeInTheDocument() + expect(within(detail).getByText("No events in the loaded log")).toBeInTheDocument() + }) + + it("shows a broadcast log error inside the detail pane", async () => { + apiMock.adminListHosts.mockResolvedValue(page([ada])) + apiMock.adminListBroadcasts.mockRejectedValue(new Error("Log unavailable")) + renderWithQuery() + + const alert = await within(detailSection()).findByRole("alert") + expect(within(alert).getByText("Could not load this host's broadcasts")).toBeInTheDocument() + expect(within(alert).getByText("Log unavailable")).toBeInTheDocument() + }) + + it("pages the broadcast log with its cursor", async () => { + apiMock.adminListHosts.mockResolvedValue(page([ada])) + apiMock.adminListBroadcasts + .mockResolvedValueOnce(broadcasts([broadcast({ id: "b-1", cleanupId: "ev-1", eventTitle: "First event" })], "log-2")) + .mockResolvedValueOnce(broadcasts([broadcast({ id: "b-2", cleanupId: "ev-2", eventTitle: "Second event" })])) + renderWithQuery() + + const detail = detailSection() + await userEvent.click(await within(detail).findByRole("button", { name: "Load more broadcasts" })) + + expect(await within(detail).findAllByRole("button", { name: "Second event" })).toHaveLength(2) + expect(apiMock.adminListBroadcasts).toHaveBeenLastCalledWith({ + createdBy: "h-ada", + kind: "host_broadcast", + from: expect.any(String), + cursor: "log-2", + }) + expect(within(detail).queryByRole("button", { name: "Load more broadcasts" })).not.toBeInTheDocument() + }) + + it("navigates to the event from both the log row and the events reached list", async () => { + apiMock.adminListHosts.mockResolvedValue(page([ada])) + apiMock.adminListBroadcasts.mockResolvedValue( + broadcasts([broadcast({ id: "b-1", cleanupId: "ev-9", eventTitle: "Beach day" })]), + ) + renderWithQuery() + + const links = await within(detailSection()).findAllByRole("button", { name: "Beach day" }) + expect(links).toHaveLength(2) + for (const link of links) { + window.history.pushState(null, "", "#/hosts") + await userEvent.click(link) + expect(window.location.hash).toBe("#/events/ev-9") + } + }) + + it("suspends messaging with the typed reason", async () => { + apiMock.adminListHosts.mockResolvedValue(page([ada])) + apiMock.adminListBroadcasts.mockResolvedValue(broadcasts([])) + apiMock.adminSetHostMessagingSuspended.mockResolvedValue({}) + renderWithQuery( + <> + + + , + ) + + await userEvent.click(await screen.findByRole("button", { name: "Suspend messaging" })) + const dialog = await screen.findByRole("dialog") + expect(within(dialog).getByText("Suspend messaging for Ada Lovelace?")).toBeInTheDocument() + expect(within(dialog).getByRole("button", { name: "Suspend messaging" })).toBeDisabled() + + await userEvent.type(within(dialog).getByRole("textbox"), " Spamming attendees ") + await userEvent.click(within(dialog).getByRole("button", { name: "Suspend messaging" })) + + await waitFor(() => + expect(apiMock.adminSetHostMessagingSuspended).toHaveBeenCalledWith({ + id: "h-ada", + suspended: true, + reason: "Spamming attendees", + }), + ) + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) +}) + +describe("HostsPage filters, search and paging", () => { + it("maps the filter chips to the suspended query param", async () => { + apiMock.adminListHosts.mockResolvedValue(page([])) + renderWithQuery() + await screen.findByText("No hosts here") + + await userEvent.click(screen.getByRole("button", { name: "Suspended" })) + await waitFor(() => + expect(apiMock.adminListHosts).toHaveBeenLastCalledWith({ suspended: true, q: undefined, windowDays: 30 }), + ) + + await userEvent.click(screen.getByRole("button", { name: "Active" })) + await waitFor(() => + expect(apiMock.adminListHosts).toHaveBeenLastCalledWith({ suspended: false, q: undefined, windowDays: 30 }), + ) + + await userEvent.click(screen.getByRole("button", { name: "All" })) + await waitFor(() => + expect(apiMock.adminListHosts).toHaveBeenLastCalledWith({ q: undefined, windowDays: 30 }), + ) + }) + + it("debounces the search into a trimmed q param", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + apiMock.adminListHosts.mockResolvedValue(page([])) + renderWithQuery() + await screen.findByText("No hosts here") + + fireEvent.change(screen.getByPlaceholderText(/Search host name or handle/), { + target: { value: " ada " }, + }) + vi.advanceTimersByTime(100) + expect(apiMock.adminListHosts).not.toHaveBeenCalledWith(expect.objectContaining({ q: "ada" })) + + vi.advanceTimersByTime(200) + await waitFor(() => + expect(apiMock.adminListHosts).toHaveBeenLastCalledWith({ q: "ada", windowDays: 30 }), + ) + }) + + it("loads the next page of hosts with the cursor", async () => { + apiMock.adminListHosts + .mockResolvedValueOnce(page([ada], "cursor-2")) + .mockResolvedValueOnce(page([bob])) + apiMock.adminListBroadcasts.mockResolvedValue(broadcasts([])) + renderWithQuery() + + await userEvent.click(await within(listSection()).findByRole("button", { name: "Load more" })) + + expect(await within(listSection()).findByText("Bob Builder")).toBeInTheDocument() + expect(apiMock.adminListHosts).toHaveBeenLastCalledWith({ q: undefined, windowDays: 30, cursor: "cursor-2" }) + expect(within(listSection()).queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + expect(within(listSection()).getByText("2")).toBeInTheDocument() + }) +}) + +describe("HostsPage deep link", () => { + it("opens the focused host instead of the first row", async () => { + apiMock.adminListHosts.mockResolvedValue(page([ada, bob])) + apiMock.adminListBroadcasts.mockResolvedValue(broadcasts([])) + renderWithQuery() + + expect( + await within(detailSection()).findByRole("heading", { name: "Bob Builder", level: 2 }), + ).toBeInTheDocument() + }) + + it("explains when the focused host is not in the loaded window and keeps holding it", async () => { + apiMock.adminListHosts + .mockResolvedValueOnce(page([ada], "cursor-2")) + .mockResolvedValueOnce(page([bob])) + apiMock.adminListBroadcasts.mockResolvedValue(broadcasts([])) + renderWithQuery() + + const detail = detailSection() + expect(await within(detail).findByText("That host is not in this window")).toBeInTheDocument() + expect(within(detail).queryByRole("heading", { level: 2 })).not.toBeInTheDocument() + + await userEvent.click(within(listSection()).getByRole("button", { name: "Load more" })) + expect(await within(detail).findByRole("heading", { name: "Bob Builder", level: 2 })).toBeInTheDocument() + }) +}) diff --git a/apps/admin/src/features/mail/mail-page.test.tsx b/apps/admin/src/features/mail/mail-page.test.tsx new file mode 100644 index 0000000..60094ea --- /dev/null +++ b/apps/admin/src/features/mail/mail-page.test.tsx @@ -0,0 +1,458 @@ +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { + GetForwardTemplateDefaultResponse, + InboundEmailDTO, + InboundEmailListItemDTO, + InboxListResponse, + MailListResponse, + MailStatsResponse, + MailThreadDTO, + MailThreadListItemDTO, +} from "@civfix/shared" +import { describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { MailPage } from "@/features/mail/mail-page" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + + +const TS = "2026-09-20T15:00:00.000Z" + +function thread(over: Partial & { id: string; subject: string }) { + return { + dir: "out", + from: "outreach@civfix.org", + to: "works@oaklandca.gov", + org: "Oakland Public Works", + preview: "Following up on the report", + ts: TS, + unread: false, + status: "sent", + jurisdictionGeoid: null, + reportId: null, + ...over, + } satisfies MailThreadListItemDTO +} + +const POTHOLE = thread({ + id: "t-1", + subject: "Pothole on 5th Ave", + preview: "Can your crew take a look?", + status: "needs_action", + dir: "in", + from: "clerk@oaklandca.gov", + unread: true, +}) +const GRAFFITI = thread({ + id: "t-2", + subject: "Graffiti at Lake Merritt", + org: "Parks Department", + status: "replied", +}) + +function mailPage(items: MailThreadListItemDTO[], nextCursor: string | null = null) { + return { items, nextCursor } satisfies MailListResponse +} + +function threadDetail(item: MailThreadListItemDTO): MailThreadDTO { + return { + ...item, + messages: [ + { + id: `${item.id}-m1`, + who: "civfix outreach", + from: "outreach@civfix.org", + to: item.to, + dir: "out", + body: `Body of ${item.subject}`, + ts: TS, + attachments: [], + delivery: "sent", + }, + ], + } satisfies MailThreadDTO +} + +function mockThreads(...items: MailThreadListItemDTO[]) { + apiMock.getMailThread.mockImplementation(async ({ id }: { id: string }) => { + const t = items.find((x) => x.id === id) + if (!t) throw new Error(`no thread ${id}`) + return threadDetail(t) + }) +} + +function inboxItem(over: Partial & { id: string; subject: string }) { + return { + from: "resident@example.com", + recipient: "hello@civfix.org", + localPart: "hello", + preview: "Quick question", + ts: TS, + status: "unread", + unread: true, + hasAttachments: false, + ...over, + } satisfies InboundEmailListItemDTO +} + +const QUESTION = inboxItem({ id: "i-1", subject: "Question about my report", localPart: "support" }) +const PRESS = inboxItem({ + id: "i-2", + subject: "Press inquiry", + from: "reporter@news.example", + status: "read", + unread: false, +}) + +function inboxPage(items: InboundEmailListItemDTO[], nextCursor: string | null = null) { + return { items, nextCursor } satisfies InboxListResponse +} + +function inboxDetail(item: InboundEmailListItemDTO): InboundEmailDTO { + return { + ...item, + bodyText: `Body of ${item.subject}`, + bodyHtml: null, + messageId: null, + attachments: [], + } satisfies InboundEmailDTO +} + +function mockInboxMessages(...items: InboundEmailListItemDTO[]) { + apiMock.getInboxMessage.mockImplementation(async ({ id }: { id: string }) => { + const m = items.find((x) => x.id === id) + if (!m) throw new Error(`no message ${id}`) + return inboxDetail(m) + }) +} + +const STATS = { unread: 2, threads: 17, sent: 40, bounced: 3, failed: 1 } satisfies MailStatsResponse + +// The page always loads the stats strip, the forward template and both folder lists, so every test +// resolves the ones it is not about. +function mockChrome({ inbox = inboxPage([]) }: { inbox?: InboxListResponse } = {}) { + apiMock.getMailStats.mockResolvedValue(STATS) + apiMock.getForwardTemplateDefault.mockResolvedValue({ + subjectTemplate: null, + bodyTemplate: null, + updatedAt: null, + } satisfies GetForwardTemplateDefaultResponse) + apiMock.listInbox.mockResolvedValue(inbox) +} + +function listCard() { + return document.querySelector(".md-list") as HTMLElement +} + +function detailCard() { + return document.querySelector(".md-detail-card") as HTMLElement +} + +describe("MailPage outreach", () => { + it("starts on the Outreach folder with the stats strip", async () => { + mockChrome() + apiMock.listMail.mockResolvedValue(mailPage([])) + renderWithQuery() + + expect(screen.getByRole("radio", { name: /Outreach/ })).toHaveAttribute("aria-checked", "true") + expect(await screen.findByText("Sent · 7d")).toBeInTheDocument() + expect(screen.getByText("40")).toBeInTheDocument() + expect(screen.getByText("Bounced")).toBeInTheDocument() + expect(screen.getByText("Failed")).toBeInTheDocument() + expect(screen.getByRole("radio", { name: /Outreach/ })).toHaveTextContent("17") + expect(screen.getByRole("button", { name: "All 17" })).toBeInTheDocument() + }) + + it("shows the loading state while the first page is in flight", async () => { + mockChrome() + apiMock.listMail.mockReturnValue(new Promise(() => {})) + renderWithQuery() + expect(within(listCard()).getByRole("status")).toHaveTextContent("Loading mail...") + expect(within(detailCard()).getByText("No message selected")).toBeInTheDocument() + }) + + it("shows the empty copy when the folder has no threads", async () => { + mockChrome() + apiMock.listMail.mockResolvedValue(mailPage([])) + renderWithQuery() + expect(await within(listCard()).findByText("Empty")).toBeInTheDocument() + expect(within(listCard()).getByText("No messages here.")).toBeInTheDocument() + expect(within(detailCard()).getByText("No message selected")).toBeInTheDocument() + }) + + it("shows the search empty copy when a search matches nothing", async () => { + mockChrome() + apiMock.listMail.mockResolvedValue(mailPage([])) + renderWithQuery() + await within(listCard()).findByText("Empty") + + await userEvent.type(screen.getByPlaceholderText("Search org, subject, sender…"), "zzz") + + expect(await within(listCard()).findByText("Nothing matches")).toBeInTheDocument() + expect(within(listCard()).getByText("Try a different search.")).toBeInTheDocument() + expect(apiMock.listMail).toHaveBeenLastCalledWith({ q: "zzz" }) + }) + + it("shows the error state with the failure message and a retry", async () => { + mockChrome() + apiMock.listMail.mockRejectedValue(new Error("Mail backend unreachable")) + renderWithQuery() + const alert = await within(listCard()).findByRole("alert") + expect(alert).toHaveTextContent("Could not load this") + expect(alert).toHaveTextContent("Mail backend unreachable") + expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument() + }) + + it("renders rows with key fields and auto-selects the first thread into the reader", async () => { + mockChrome() + apiMock.listMail.mockResolvedValue(mailPage([POTHOLE, GRAFFITI])) + mockThreads(POTHOLE, GRAFFITI) + renderWithQuery() + + await within(listCard()).findByText("Pothole on 5th Ave") + const list = listCard() + expect(within(list).getByRole("heading", { name: "All" })).toBeInTheDocument() + expect(within(list).getByText("Oakland Public Works")).toBeInTheDocument() + expect(within(list).getByText("Can your crew take a look?")).toBeInTheDocument() + expect(within(list).getByText("Needs action")).toBeInTheDocument() + expect(within(list).getByText("Graffiti at Lake Merritt")).toBeInTheDocument() + expect(within(list).getByText("Parks Department")).toBeInTheDocument() + expect(within(list).getByText("Replied")).toBeInTheDocument() + expect(apiMock.listMail).toHaveBeenCalledWith({}) + + const card = detailCard() + expect(await within(card).findByText("Body of Pothole on 5th Ave")).toBeInTheDocument() + expect(within(card).getByText("Pothole on 5th Ave")).toBeInTheDocument() + expect(within(card).getByText("civfix outreach")).toBeInTheDocument() + // With no inbound message the correspondent falls back to the outbound recipient, shown in the + // header and on the reply line. + expect(within(card).getAllByText("works@oaklandca.gov")).toHaveLength(2) + expect(within(card).getByPlaceholderText("Reply to Oakland Public Works…")).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /Mark replied/ })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /Reply/ })).toBeDisabled() + expect(apiMock.getMailThread).toHaveBeenCalledWith({ id: "t-1" }) + }) + + it("opens the clicked thread in the reader", async () => { + mockChrome() + apiMock.listMail.mockResolvedValue(mailPage([POTHOLE, GRAFFITI])) + mockThreads(POTHOLE, GRAFFITI) + renderWithQuery() + await within(detailCard()).findByText("Body of Pothole on 5th Ave") + + await userEvent.click(within(listCard()).getByText("Graffiti at Lake Merritt")) + + const card = detailCard() + expect(await within(card).findByText("Body of Graffiti at Lake Merritt")).toBeInTheDocument() + expect(within(card).getByPlaceholderText("Reply to Parks Department…")).toBeInTheDocument() + expect(apiMock.getMailThread).toHaveBeenCalledWith({ id: "t-2" }) + }) + + it("marks an unread thread read when it is clicked", async () => { + mockChrome() + apiMock.listMail.mockResolvedValue(mailPage([GRAFFITI, POTHOLE])) + apiMock.markMailRead.mockResolvedValue({ ok: true }) + mockThreads(POTHOLE, GRAFFITI) + renderWithQuery() + await within(detailCard()).findByText("Body of Graffiti at Lake Merritt") + + await userEvent.click(within(listCard()).getByText("Pothole on 5th Ave")) + + await waitFor(() => expect(apiMock.markMailRead).toHaveBeenCalledWith({ id: "t-1" })) + }) + + it("sends the chosen filter chip to the api and titles the list with it", async () => { + mockChrome() + apiMock.listMail.mockResolvedValue(mailPage([GRAFFITI])) + mockThreads(GRAFFITI) + renderWithQuery() + await within(listCard()).findByText("Graffiti at Lake Merritt") + + await userEvent.click(screen.getByRole("button", { name: "Inbound" })) + await waitFor(() => expect(apiMock.listMail).toHaveBeenLastCalledWith({ dir: "in" })) + expect(within(listCard()).getByRole("heading", { name: "Inbound" })).toBeInTheDocument() + + await userEvent.click(screen.getByRole("button", { name: "Outbound" })) + await waitFor(() => expect(apiMock.listMail).toHaveBeenLastCalledWith({ dir: "out" })) + + await userEvent.click(screen.getByRole("button", { name: "Needs attention" })) + await waitFor(() => expect(apiMock.listMail).toHaveBeenLastCalledWith({ filter: "attn" })) + expect(within(listCard()).getByRole("heading", { name: "Needs attention" })).toBeInTheDocument() + }) + + it("shows Load more when a cursor is returned and fetches the next page with it", async () => { + mockChrome() + apiMock.listMail.mockImplementation(async (params: { cursor?: string }) => + params.cursor === "mail-2" ? mailPage([GRAFFITI]) : mailPage([POTHOLE], "mail-2"), + ) + mockThreads(POTHOLE, GRAFFITI) + renderWithQuery() + await within(listCard()).findByText("Pothole on 5th Ave") + + await userEvent.click(screen.getByRole("button", { name: "Load more" })) + + expect(await within(listCard()).findByText("Graffiti at Lake Merritt")).toBeInTheDocument() + expect(apiMock.listMail).toHaveBeenLastCalledWith({ cursor: "mail-2" }) + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) + + it("opens a deep-linked thread focusId that is on the first page", async () => { + mockChrome() + apiMock.listMail.mockResolvedValue(mailPage([POTHOLE, GRAFFITI])) + mockThreads(POTHOLE, GRAFFITI) + renderWithQuery() + expect(await within(detailCard()).findByText("Body of Graffiti at Lake Merritt")).toBeInTheDocument() + expect(within(detailCard()).queryByText("Body of Pothole on 5th Ave")).not.toBeInTheDocument() + }) + + it("replaces a deep-linked thread focusId that is not on the first page with the first row (auto-select override)", async () => { + const OLD = thread({ id: "t-9", subject: "Old thread" }) + mockChrome() + apiMock.listMail.mockResolvedValue(mailPage([POTHOLE, GRAFFITI])) + mockThreads(POTHOLE, GRAFFITI, OLD) + renderWithQuery() + expect(await within(detailCard()).findByText("Body of Pothole on 5th Ave")).toBeInTheDocument() + expect(within(detailCard()).queryByText("Body of Old thread")).not.toBeInTheDocument() + }) +}) + +describe("MailPage inbox", () => { + async function openInbox() { + await userEvent.click(screen.getByRole("radio", { name: /Inbox/ })) + } + + it("switches to the Inbox folder with its own chips and no stats strip", async () => { + mockChrome({ inbox: inboxPage([]) }) + apiMock.listMail.mockResolvedValue(mailPage([])) + renderWithQuery() + await screen.findByText("Sent · 7d") + await openInbox() + + expect(screen.getByRole("radio", { name: /Inbox/ })).toHaveAttribute("aria-checked", "true") + expect(screen.queryByText("Sent · 7d")).not.toBeInTheDocument() + expect(screen.getByRole("button", { name: "Unread" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Archived" })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Needs attention" })).not.toBeInTheDocument() + expect(screen.getByPlaceholderText("Search sender, subject…")).toBeInTheDocument() + }) + + it("shows the loading state while the inbox is in flight", async () => { + mockChrome() + apiMock.listInbox.mockReturnValue(new Promise(() => {})) + apiMock.listMail.mockResolvedValue(mailPage([])) + renderWithQuery() + await openInbox() + expect(within(listCard()).getByRole("status")).toHaveTextContent("Loading inbox...") + }) + + it("shows the empty copy when the inbox is empty", async () => { + mockChrome({ inbox: inboxPage([]) }) + apiMock.listMail.mockResolvedValue(mailPage([])) + renderWithQuery() + await openInbox() + expect(await within(listCard()).findByText("Empty")).toBeInTheDocument() + expect(within(listCard()).getByText("No messages here.")).toBeInTheDocument() + expect(within(detailCard()).getByText("No message selected")).toBeInTheDocument() + }) + + it("shows the error state with the failure message and a retry", async () => { + mockChrome() + apiMock.listInbox.mockRejectedValue(new Error("Inbox backend unreachable")) + apiMock.listMail.mockResolvedValue(mailPage([])) + renderWithQuery() + await openInbox() + const alert = await within(listCard()).findByRole("alert") + expect(alert).toHaveTextContent("Could not load this") + expect(alert).toHaveTextContent("Inbox backend unreachable") + expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument() + }) + + it("renders inbox rows and auto-selects the first message into the reader", async () => { + mockChrome({ inbox: inboxPage([QUESTION, PRESS]) }) + apiMock.listMail.mockResolvedValue(mailPage([])) + mockInboxMessages(QUESTION, PRESS) + renderWithQuery() + await openInbox() + + await within(listCard()).findByText("Question about my report") + const list = listCard() + expect(within(list).getByText("resident@example.com")).toBeInTheDocument() + expect(within(list).getByText("support")).toBeInTheDocument() + expect(within(list).getByText("Unread")).toBeInTheDocument() + expect(within(list).getByText("Press inquiry")).toBeInTheDocument() + expect(within(list).getByText("reporter@news.example")).toBeInTheDocument() + expect(within(list).getByText("Read")).toBeInTheDocument() + + const card = detailCard() + expect(await within(card).findByText("Body of Question about my report")).toBeInTheDocument() + expect(within(card).getByText("to hello@civfix.org")).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /Mark read/ })).toBeEnabled() + expect(within(card).getByRole("button", { name: /Archive/ })).toBeEnabled() + expect(apiMock.getInboxMessage).toHaveBeenCalledWith({ id: "i-1" }) + }) + + it("opens the clicked message in the reader", async () => { + mockChrome({ inbox: inboxPage([QUESTION, PRESS]) }) + apiMock.listMail.mockResolvedValue(mailPage([])) + mockInboxMessages(QUESTION, PRESS) + renderWithQuery() + await openInbox() + await within(detailCard()).findByText("Body of Question about my report") + + await userEvent.click(within(listCard()).getByText("Press inquiry")) + + const card = detailCard() + expect(await within(card).findByText("Body of Press inquiry")).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /Mark read/ })).toBeDisabled() + }) + + it("sends the chosen filter chip to the api", async () => { + mockChrome({ inbox: inboxPage([PRESS]) }) + apiMock.listMail.mockResolvedValue(mailPage([])) + mockInboxMessages(PRESS) + renderWithQuery() + await openInbox() + await within(listCard()).findByText("Press inquiry") + + await userEvent.click(screen.getByRole("button", { name: "Unread" })) + await waitFor(() => expect(apiMock.listInbox).toHaveBeenLastCalledWith({ status: "unread" })) + + await userEvent.click(screen.getByRole("button", { name: "Archived" })) + await waitFor(() => expect(apiMock.listInbox).toHaveBeenLastCalledWith({ status: "archived" })) + }) + + it("shows Load more when a cursor is returned and fetches the next page with it", async () => { + mockChrome() + apiMock.listInbox.mockImplementation(async (params: { cursor?: string }) => + params.cursor === "in-2" ? inboxPage([PRESS]) : inboxPage([QUESTION], "in-2"), + ) + apiMock.listMail.mockResolvedValue(mailPage([])) + mockInboxMessages(QUESTION, PRESS) + renderWithQuery() + await openInbox() + await within(listCard()).findByText("Question about my report") + + await userEvent.click(screen.getByRole("button", { name: "Load more" })) + + expect(await within(listCard()).findByText("Press inquiry")).toBeInTheDocument() + expect(apiMock.listInbox).toHaveBeenLastCalledWith({ status: "all", cursor: "in-2" }) + }) + + it("opens an inbox: deep link in the Inbox folder", async () => { + mockChrome({ inbox: inboxPage([QUESTION, PRESS]) }) + apiMock.listMail.mockResolvedValue(mailPage([])) + mockInboxMessages(QUESTION, PRESS) + renderWithQuery() + + expect(screen.getByRole("radio", { name: /Inbox/ })).toHaveAttribute("aria-checked", "true") + expect(await within(detailCard()).findByText("Body of Press inquiry")).toBeInTheDocument() + expect(within(detailCard()).queryByText("Body of Question about my report")).not.toBeInTheDocument() + }) +}) diff --git a/apps/admin/src/features/moderation/moderation-page.test.tsx b/apps/admin/src/features/moderation/moderation-page.test.tsx new file mode 100644 index 0000000..fbdb173 --- /dev/null +++ b/apps/admin/src/features/moderation/moderation-page.test.tsx @@ -0,0 +1,418 @@ +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { + GovClaimDTO, + GovClaimListResponse, + ModerationItemDTO, + ModerationListItemDTO, + ModerationListResponse, +} from "@civfix/shared" +import { describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { ModerationPage } from "@/features/moderation/moderation-page" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + + +function modItem(over: Partial & { id: string; flag: string }) { + return { + reporter: "Dana Cho", + category: null, + reason: "Spam", + age: "5m", + priority: "low", + kind: "image", + subjectId: "subj-1", + destinationKind: null, + destinationId: null, + reporterId: null, + ...over, + } satisfies ModerationListItemDTO +} + +const COMMENT_REPORT = modItem({ + id: "mod-1", + flag: "MOD-101", + kind: "user_report", + subjectType: "comment", + reason: "Harassment", + reporter: "Dana Cho", + reporterId: "u-dana", + priority: "high", + age: "12m", +}) +const APPEAL = modItem({ + id: "mod-2", + flag: "MOD-202", + kind: "appeal", + reason: "Wrongly removed", + reporter: "Eli Park", + priority: "med", + age: "1h", +}) + +function modPage(items: ModerationListItemDTO[], nextCursor: string | null = null) { + return { items, nextCursor } satisfies ModerationListResponse +} + +function modDetail(item: ModerationListItemDTO): ModerationItemDTO { + return { + ...item, + desc: `Details for ${item.flag}`, + autoAction: null, + place: "Temescal", + signals: [{ label: "Prior flags", val: "3", tone: "warn" }], + user: { + id: "u-subject", + handle: "@subject", + name: "Subject Person", + joined: "Jan 2026", + priorReports: 4, + priorRemovals: 1, + strikes: 2, + device: "iOS 19", + }, + similar: [], + media: [], + } satisfies ModerationItemDTO +} + +function mockModDetails(...items: ModerationListItemDTO[]) { + apiMock.getModerationItem.mockImplementation(async ({ id }: { id: string }) => { + const m = items.find((x) => x.id === id) + if (!m) throw new Error(`no item ${id}`) + return modDetail(m) + }) +} + +function claim(over: Partial & { id: string; name: string }) { + return { + title: "Public Works Director", + org: "City of Oakland", + jurisdictionGeoid: "0653000", + method: "email", + status: "pending", + age: "2d", + contactEmail: "applicant@oaklandca.gov", + verified: ["linkedin"], + pending: ["directory", "callback"], + checks: { + linkedin: { status: "verified", evidence: "https://linkedin.example/in/applicant", note: null }, + directory: { status: "pending", evidence: null, note: null }, + callback: { status: "pending", evidence: null, note: null }, + }, + ...over, + } satisfies GovClaimDTO +} + +const RIVERA = claim({ id: "gc-1", name: "Maya Rivera" }) +const NGUYEN = claim({ + id: "gc-2", + name: "Tom Nguyen", + org: "Alameda County", + title: "Clerk", + status: "approved", + contactEmail: "tom@acgov.org", + method: "cold_outreach", +}) + +function claimPage(items: GovClaimDTO[], nextCursor: string | null = null) { + return { items, nextCursor } satisfies GovClaimListResponse +} + +function mockClaimDetails(...claims: GovClaimDTO[]) { + apiMock.getGovClaim.mockImplementation(async ({ id }: { id: string }) => { + const c = claims.find((x) => x.id === id) + if (!c) throw new Error(`no claim ${id}`) + return c + }) +} + +function listCard(heading: string) { + return screen.getByRole("heading", { level: 3, name: heading }).closest("section") as HTMLElement +} + +function detailCard() { + return document.querySelector(".md-detail-card") as HTMLElement +} + +async function openGovClaims() { + await userEvent.click(screen.getByRole("radio", { name: "Gov claims" })) +} + +describe("ModerationPage queue", () => { + it("starts on the Queue section", () => { + apiMock.listModeration.mockReturnValue(new Promise(() => {})) + renderWithQuery() + expect(screen.getByRole("radio", { name: "Queue" })).toHaveAttribute("aria-checked", "true") + expect(screen.getByRole("radio", { name: "Gov claims" })).toHaveAttribute("aria-checked", "false") + }) + + it("shows the loading state while the first page is in flight", () => { + apiMock.listModeration.mockReturnValue(new Promise(() => {})) + renderWithQuery() + expect(screen.getByRole("status")).toHaveTextContent("Loading queue...") + expect(within(detailCard()).getByText("No item selected")).toBeInTheDocument() + }) + + it("shows the empty copy when the queue is clear", async () => { + apiMock.listModeration.mockResolvedValue(modPage([])) + renderWithQuery() + expect(await screen.findByText("Queue is clear")).toBeInTheDocument() + expect(screen.getByText("Nothing needs review right now.")).toBeInTheDocument() + expect(within(detailCard()).getByText("Pick an item from the queue.")).toBeInTheDocument() + }) + + it("shows the error state with the failure message and a retry", async () => { + apiMock.listModeration.mockRejectedValue(new Error("Moderation backend unreachable")) + renderWithQuery() + const alert = await screen.findByRole("alert") + expect(alert).toHaveTextContent("Could not load this") + expect(alert).toHaveTextContent("Moderation backend unreachable") + expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument() + }) + + it("renders rows with key fields and auto-selects the first item into the detail pane", async () => { + apiMock.listModeration.mockResolvedValue(modPage([COMMENT_REPORT, APPEAL])) + mockModDetails(COMMENT_REPORT, APPEAL) + renderWithQuery() + + await screen.findByText("MOD-101") + const list = listCard("Queue") + expect(within(list).getByText("Reported comment")).toBeInTheDocument() + expect(within(list).getByText("Harassment")).toBeInTheDocument() + expect(within(list).getByText("Dana Cho")).toBeInTheDocument() + expect(within(list).getByText("High")).toBeInTheDocument() + expect(within(list).getByText("12m")).toBeInTheDocument() + expect(within(list).getByText("Appeal")).toBeInTheDocument() + expect(within(list).getByText("MOD-202")).toBeInTheDocument() + expect(within(list).getByText("Med")).toBeInTheDocument() + expect(apiMock.listModeration).toHaveBeenCalledWith({}) + + const card = detailCard() + expect(await within(card).findByRole("heading", { name: "MOD-101" })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Dana Cho" })).toBeInTheDocument() + expect(within(card).getByText("Details for MOD-101")).toBeInTheDocument() + expect(within(card).getByText(/High priority/)).toBeInTheDocument() + expect(within(card).getByText("Prior flags")).toBeInTheDocument() + expect(within(card).getByText("Subject Person")).toBeInTheDocument() + expect(within(card).getByText("iOS 19")).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Keep" })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Hold" })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Remove" })).toBeInTheDocument() + expect(apiMock.getModerationItem).toHaveBeenCalledWith({ id: "mod-1" }) + }) + + it("opens the clicked item in the detail pane", async () => { + apiMock.listModeration.mockResolvedValue(modPage([COMMENT_REPORT, APPEAL])) + mockModDetails(COMMENT_REPORT, APPEAL) + renderWithQuery() + await within(detailCard()).findByRole("heading", { name: "MOD-101" }) + + await userEvent.click(within(listCard("Queue")).getByText("MOD-202")) + + const card = detailCard() + expect(await within(card).findByRole("heading", { name: "MOD-202" })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Overturn" })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Uphold" })).toBeInTheDocument() + expect(within(card).queryByRole("button", { name: "Keep" })).not.toBeInTheDocument() + }) + + it("sends the chosen filter chip to the api", async () => { + apiMock.listModeration.mockResolvedValue(modPage([COMMENT_REPORT])) + mockModDetails(COMMENT_REPORT) + renderWithQuery() + await screen.findByText("MOD-101") + + await userEvent.click(screen.getByRole("button", { name: "User reports" })) + await waitFor(() => + expect(apiMock.listModeration).toHaveBeenLastCalledWith({ filter: "user_report" }), + ) + + await userEvent.click(screen.getByRole("button", { name: "High" })) + await waitFor(() => expect(apiMock.listModeration).toHaveBeenLastCalledWith({ filter: "high" })) + + await userEvent.click(screen.getByRole("button", { name: "All" })) + await waitFor(() => expect(apiMock.listModeration).toHaveBeenLastCalledWith({})) + }) + + it("sends the trimmed search text to the api after the debounce", async () => { + apiMock.listModeration.mockResolvedValue(modPage([COMMENT_REPORT])) + mockModDetails(COMMENT_REPORT) + renderWithQuery() + await screen.findByText("MOD-101") + + await userEvent.type(screen.getByPlaceholderText("Search flag, reporter, reason…"), " spam ") + await waitFor(() => expect(apiMock.listModeration).toHaveBeenLastCalledWith({ q: "spam" })) + }) + + it("shows Load more when a cursor is returned and fetches the next page with it", async () => { + apiMock.listModeration.mockImplementation(async (params: { cursor?: string }) => + params.cursor === "m-2" ? modPage([APPEAL]) : modPage([COMMENT_REPORT], "m-2"), + ) + mockModDetails(COMMENT_REPORT, APPEAL) + renderWithQuery() + await screen.findByText("MOD-101") + + await userEvent.click(screen.getByRole("button", { name: "Load more" })) + + expect(await within(listCard("Queue")).findByText("MOD-202")).toBeInTheDocument() + expect(apiMock.listModeration).toHaveBeenLastCalledWith({ cursor: "m-2" }) + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) + + it("opens a deep-linked focusId that is on the first page", async () => { + apiMock.listModeration.mockResolvedValue(modPage([COMMENT_REPORT, APPEAL])) + mockModDetails(COMMENT_REPORT, APPEAL) + renderWithQuery() + expect(await within(detailCard()).findByRole("heading", { name: "MOD-202" })).toBeInTheDocument() + expect(within(detailCard()).queryByRole("heading", { name: "MOD-101" })).not.toBeInTheDocument() + }) + + it("replaces a deep-linked focusId that is not on the first page with the first row (auto-select override)", async () => { + const OTHER = modItem({ id: "mod-9", flag: "MOD-909" }) + apiMock.listModeration.mockResolvedValue(modPage([COMMENT_REPORT, APPEAL])) + mockModDetails(COMMENT_REPORT, APPEAL, OTHER) + renderWithQuery() + expect(await within(detailCard()).findByRole("heading", { name: "MOD-101" })).toBeInTheDocument() + expect(within(detailCard()).queryByRole("heading", { name: "MOD-909" })).not.toBeInTheDocument() + }) +}) + +describe("ModerationPage gov claims", () => { + it("switches to the Gov claims section and asks for pending claims by default", async () => { + apiMock.listModeration.mockResolvedValue(modPage([])) + apiMock.listGovClaims.mockReturnValue(new Promise(() => {})) + renderWithQuery() + await openGovClaims() + + expect(screen.getByRole("radio", { name: "Gov claims" })).toHaveAttribute("aria-checked", "true") + expect(screen.getByText(/Government staff asking for access/)).toBeInTheDocument() + expect(screen.getByRole("status")).toHaveTextContent("Loading claims...") + expect(within(detailCard()).getByText("No claim selected")).toBeInTheDocument() + expect(apiMock.listGovClaims).toHaveBeenCalledWith({ filter: "pending" }) + }) + + it("shows the empty copy when no claim is waiting", async () => { + apiMock.listModeration.mockResolvedValue(modPage([])) + apiMock.listGovClaims.mockResolvedValue(claimPage([])) + renderWithQuery() + await openGovClaims() + + expect(await screen.findByText("No claims here")).toBeInTheDocument() + expect(screen.getByText("Nobody is waiting on government access right now.")).toBeInTheDocument() + expect(within(detailCard()).getByText("Pick a claim from the queue.")).toBeInTheDocument() + }) + + it("shows the error state with the failure message and a retry", async () => { + apiMock.listModeration.mockResolvedValue(modPage([])) + apiMock.listGovClaims.mockRejectedValue(new Error("Claims backend unreachable")) + renderWithQuery() + await openGovClaims() + + const alert = await screen.findByRole("alert") + expect(alert).toHaveTextContent("Could not load this") + expect(alert).toHaveTextContent("Claims backend unreachable") + expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument() + }) + + it("renders claim rows and auto-selects the first claim into the detail pane", async () => { + apiMock.listModeration.mockResolvedValue(modPage([])) + apiMock.listGovClaims.mockResolvedValue(claimPage([RIVERA, NGUYEN])) + mockClaimDetails(RIVERA, NGUYEN) + renderWithQuery() + await openGovClaims() + + await screen.findByText("Maya Rivera") + const list = listCard("Gov claims") + expect(within(list).getByText("City of Oakland")).toBeInTheDocument() + expect(within(list).getByText("Public Works Director")).toBeInTheDocument() + expect(within(list).getAllByText("1 of 3 checks verified")).toHaveLength(2) + expect(within(list).getByText("Tom Nguyen")).toBeInTheDocument() + expect(within(list).getByText("Approved")).toBeInTheDocument() + + const card = detailCard() + expect(await within(card).findByRole("heading", { name: "Maya Rivera" })).toBeInTheDocument() + expect(within(card).getAllByText("applicant@oaklandca.gov")).toHaveLength(2) + expect(within(card).getByText(/Gov provisioning · Emailed us/)).toBeInTheDocument() + expect(within(card).getByText("LinkedIn")).toBeInTheDocument() + expect(within(card).getByRole("link", { name: "https://linkedin.example/in/applicant" })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Mark pending" })).toBeInTheDocument() + expect(within(card).getAllByRole("button", { name: "Mark verified" })).toHaveLength(2) + expect(within(card).getByRole("button", { name: "Approve" })).toBeEnabled() + expect(within(card).getByRole("button", { name: "Reject" })).toBeEnabled() + expect(apiMock.getGovClaim).toHaveBeenCalledWith({ id: "gc-1" }) + }) + + it("opens the clicked claim, which shows a decided claim as locked", async () => { + apiMock.listModeration.mockResolvedValue(modPage([])) + apiMock.listGovClaims.mockResolvedValue(claimPage([RIVERA, NGUYEN])) + mockClaimDetails(RIVERA, NGUYEN) + renderWithQuery() + await openGovClaims() + await within(detailCard()).findByRole("heading", { name: "Maya Rivera" }) + + await userEvent.click(within(listCard("Gov claims")).getByText("Tom Nguyen")) + + const card = detailCard() + expect(await within(card).findByRole("heading", { name: "Tom Nguyen" })).toBeInTheDocument() + expect(within(card).getByText(/Gov provisioning · Cold outreach/)).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Approve" })).toBeDisabled() + expect(within(card).getByRole("button", { name: "Reject" })).toBeDisabled() + expect(within(card).getByText(/This claim was already approved\. Decisions are final/)).toBeInTheDocument() + }) + + it("sends the chosen filter chip to the api", async () => { + apiMock.listModeration.mockResolvedValue(modPage([])) + apiMock.listGovClaims.mockResolvedValue(claimPage([RIVERA])) + mockClaimDetails(RIVERA) + renderWithQuery() + await openGovClaims() + await screen.findByText("City of Oakland") + + await userEvent.click(screen.getByRole("button", { name: "Approved" })) + await waitFor(() => + expect(apiMock.listGovClaims).toHaveBeenLastCalledWith({ filter: "approved" }), + ) + + await userEvent.click(screen.getByRole("button", { name: "All" })) + await waitFor(() => expect(apiMock.listGovClaims).toHaveBeenLastCalledWith({})) + }) + + it("sends the trimmed search text alongside the filter after the debounce", async () => { + apiMock.listModeration.mockResolvedValue(modPage([])) + apiMock.listGovClaims.mockResolvedValue(claimPage([RIVERA])) + mockClaimDetails(RIVERA) + renderWithQuery() + await openGovClaims() + await screen.findByText("City of Oakland") + + await userEvent.type(screen.getByPlaceholderText("Search name or organization…"), " maya ") + await waitFor(() => + expect(apiMock.listGovClaims).toHaveBeenLastCalledWith({ filter: "pending", q: "maya" }), + ) + }) + + it("shows Load more when a cursor is returned and fetches the next page with it", async () => { + apiMock.listModeration.mockResolvedValue(modPage([])) + apiMock.listGovClaims.mockImplementation(async (params: { cursor?: string }) => + params.cursor === "gc-next" ? claimPage([NGUYEN]) : claimPage([RIVERA], "gc-next"), + ) + mockClaimDetails(RIVERA, NGUYEN) + renderWithQuery() + await openGovClaims() + await screen.findByText("City of Oakland") + + await userEvent.click(screen.getByRole("button", { name: "Load more" })) + + expect(await within(listCard("Gov claims")).findByText("Tom Nguyen")).toBeInTheDocument() + expect(apiMock.listGovClaims).toHaveBeenLastCalledWith({ filter: "pending", cursor: "gc-next" }) + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) +}) diff --git a/apps/admin/src/features/orgs/orgs-page.test.tsx b/apps/admin/src/features/orgs/orgs-page.test.tsx new file mode 100644 index 0000000..8fe8a60 --- /dev/null +++ b/apps/admin/src/features/orgs/orgs-page.test.tsx @@ -0,0 +1,474 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react" +import type { + AdminOrgDTO, + AdminOrgEventListResponse, + AdminOrgListResponse, + AdminOrgMemberListResponse, + GetAdminOrgResponse, +} from "@civfix/shared" +import { describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { OrgsPage } from "@/features/orgs/orgs-page" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + + +const OWNER = { id: "u-owner", name: "Rosa Park", handle: "@rosa", joined: "2025-01-01" } + +function makeOrg(overrides: Partial = {}): AdminOrgDTO { + return { + id: "org-1", + slug: "river-keepers", + name: "River Keepers", + description: "We clean the river.", + websiteUrl: "https://riverkeepers.org", + logoUrl: null, + verifiedStatus: "verified", + verifiedKind: "nonprofit", + verifiedAt: "2026-02-01T10:00:00.000Z", + createdAt: "2026-01-01T10:00:00.000Z", + deletedAt: null, + memberCount: 3, + eventCount: 1, + owner: OWNER, + verification: null, + donationUrl: null, + suspendedAt: null, + suspendedReason: null, + updatedAt: null, + socialLinks: null, + logoMediaId: null, + ...overrides, + } satisfies AdminOrgDTO +} + +const RIVER = makeOrg() +const PARK = makeOrg({ + id: "org-2", + slug: "park-friends", + name: "Park Friends", + verifiedStatus: "pending", + verifiedKind: null, + memberCount: 1, + eventCount: 0, + owner: null, +}) + +const COUNTS = { all: 5, verified: 3, pending: 1, suspended: 0 } + +function listPage( + items: AdminOrgDTO[], + extra: Partial = {}, +): AdminOrgListResponse { + return { items, nextCursor: null, counts: COUNTS, ...extra } satisfies AdminOrgListResponse +} + +function mockOrgDetails(...orgs: AdminOrgDTO[]) { + apiMock.adminGetOrg.mockImplementation(({ id }: { id: string }) => { + const org = orgs.find((o) => o.id === id) + return org + ? Promise.resolve(org satisfies GetAdminOrgResponse) + : Promise.reject(new Error(`no org ${id}`)) + }) +} + +async function detailHeading(name: string) { + return screen.findByRole("heading", { level: 2, name }) +} + +describe("OrgsPage list states", () => { + it("shows a loading state while the list is in flight and no detail selected", async () => { + apiMock.adminListOrgs.mockReturnValue(new Promise(() => {})) + renderWithQuery() + + expect(await screen.findByRole("status")).toHaveTextContent("Loading organizations...") + expect(screen.getByText("No organization selected")).toBeInTheDocument() + expect(apiMock.adminListOrgs).toHaveBeenCalledWith({}) + expect(apiMock.adminGetOrg).not.toHaveBeenCalled() + }) + + it("shows the empty state for the All filter", async () => { + apiMock.adminListOrgs.mockResolvedValue( + listPage([], { counts: { all: 0, verified: 0, pending: 0, suspended: 0 } }), + ) + renderWithQuery() + + expect(await screen.findByText("No organizations")).toBeInTheDocument() + expect(screen.getByText("No organization matches this filter or search.")).toBeInTheDocument() + expect(screen.getByText("No organization selected")).toBeInTheDocument() + expect(screen.getByText("0 awaiting review")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) + + it("shows the error message with a retry that refetches", async () => { + apiMock.adminListOrgs.mockRejectedValue(new Error("Backend is down")) + renderWithQuery() + + const alert = await screen.findByRole("alert") + expect(alert).toHaveTextContent("Could not load this") + expect(alert).toHaveTextContent("Backend is down") + expect(screen.getByText("No organization selected")).toBeInTheDocument() + + apiMock.adminListOrgs.mockResolvedValue(listPage([])) + fireEvent.click(within(alert).getByRole("button", { name: "Try again" })) + expect(await screen.findByText("No organizations")).toBeInTheDocument() + }) + + it("renders rows with key fields, chip counts and auto-selects the first row", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER, PARK])) + mockOrgDetails(RIVER, PARK) + renderWithQuery() + + const riverRow = (await screen.findByText("/river-keepers")).closest(".qrow") as HTMLElement + expect(riverRow).toHaveTextContent("River Keepers") + expect(riverRow).toHaveTextContent("3 members") + expect(riverRow).toHaveTextContent("1 event") + expect(riverRow).toHaveTextContent("Rosa Park") + expect(riverRow).toHaveTextContent("Verified") + + const parkRow = screen.getByText("/park-friends").closest(".qrow") as HTMLElement + expect(parkRow).toHaveTextContent("Park Friends") + expect(parkRow).toHaveTextContent("1 member") + expect(parkRow).toHaveTextContent("0 events") + expect(parkRow).toHaveTextContent("Pending review") + + expect(screen.getByText("2 of 5")).toBeInTheDocument() + expect(screen.getByText("1 awaiting review")).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^All\s*5$/ })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^Pending review\s*1$/ })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^Verified\s*3$/ })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^Rejected$/ })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^Unverified$/ })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^Suspended\s*0$/ })).toBeInTheDocument() + + await detailHeading("River Keepers") + expect(apiMock.adminGetOrg).toHaveBeenCalledWith({ id: "org-1" }) + expect(screen.getByText("/river-keepers · Nonprofit")).toBeInTheDocument() + expect(screen.getByRole("tab", { name: "Profile" })).toHaveAttribute("aria-selected", "true") + expect(screen.getByRole("link", { name: "civfix.org/orgs/river-keepers" })).toHaveAttribute( + "href", + "https://civfix.org/orgs/river-keepers", + ) + expect(screen.getByRole("link", { name: /View public page/ })).toHaveAttribute( + "href", + "https://civfix.org/orgs/river-keepers", + ) + expect(screen.getByText("We clean the river.")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Suspend" })).toBeEnabled() + }) + + it("selecting a row loads and shows that organization's detail", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER, PARK])) + mockOrgDetails(RIVER, PARK) + renderWithQuery() + + await detailHeading("River Keepers") + fireEvent.click(screen.getByText("/park-friends")) + + await detailHeading("Park Friends") + expect(apiMock.adminGetOrg).toHaveBeenCalledWith({ id: "org-2" }) + expect(screen.getAllByText("/park-friends")).toHaveLength(2) + expect(screen.getByRole("tab", { name: /^Verification/ })).toHaveTextContent("!") + }) + + it("shows the detail error when the selected organization fails to load", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER])) + apiMock.adminGetOrg.mockRejectedValue(new Error("Detail exploded")) + renderWithQuery() + + expect(await screen.findByText("Could not load this organization")).toBeInTheDocument() + expect(screen.getByText("Detail exploded")).toBeInTheDocument() + }) + + it("shows the detail loading state while the selected organization is in flight", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER])) + apiMock.adminGetOrg.mockReturnValue(new Promise(() => {})) + renderWithQuery() + + expect(await screen.findByText("Loading organization...")).toBeInTheDocument() + }) + + it("shows the suspended pill and banner for a suspended organization", async () => { + const suspended = makeOrg({ + suspendedAt: "2026-03-01T10:00:00.000Z", + suspendedReason: "Broadcast abuse", + }) + apiMock.adminListOrgs.mockResolvedValue(listPage([suspended])) + mockOrgDetails(suspended) + renderWithQuery() + + await detailHeading("River Keepers") + expect(screen.getAllByText("Suspended").length).toBeGreaterThanOrEqual(2) + expect(screen.getByText(/Broadcast abuse/)).toBeInTheDocument() + expect(screen.getByText(/Writes under\s+this organization are refused until it is restored\./)).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Restore" })).toBeEnabled() + }) +}) + +describe("OrgsPage paging, filters and search", () => { + it("Load more fetches the next page with the cursor and appends its rows", async () => { + apiMock.adminListOrgs.mockImplementation((params: { cursor?: string }) => + Promise.resolve( + params.cursor === "cur-2" + ? listPage([PARK], { counts: undefined }) + : listPage([RIVER], { nextCursor: "cur-2" }), + ), + ) + mockOrgDetails(RIVER, PARK) + renderWithQuery() + + fireEvent.click(await screen.findByRole("button", { name: "Load more" })) + + expect(await screen.findByText("/park-friends")).toBeInTheDocument() + expect(apiMock.adminListOrgs).toHaveBeenCalledWith({ cursor: "cur-2" }) + expect(screen.getByText("/river-keepers")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + expect(screen.getByText("2 of 5")).toBeInTheDocument() + }) + + it("the Pending review chip queries the verification queue and opens on the Verification tab", async () => { + apiMock.adminListOrgs.mockImplementation((params: { verified?: string }) => + Promise.resolve(params.verified === "pending" ? listPage([PARK]) : listPage([RIVER])), + ) + mockOrgDetails(RIVER, PARK) + renderWithQuery() + + await detailHeading("River Keepers") + fireEvent.click(screen.getByRole("button", { name: /^Pending review/ })) + + expect(await screen.findByRole("heading", { level: 3, name: "Verification queue" })).toBeInTheDocument() + await waitFor(() => expect(apiMock.adminListOrgs).toHaveBeenCalledWith({ verified: "pending" })) + expect(await screen.findByText("/park-friends")).toBeInTheDocument() + expect(screen.getByRole("tab", { name: /^Verification/ })).toHaveAttribute( + "aria-selected", + "true", + ) + }) + + it("shows the clear-queue empty state on an empty Pending review filter", async () => { + apiMock.adminListOrgs.mockImplementation((params: { verified?: string }) => + Promise.resolve(params.verified === "pending" ? listPage([]) : listPage([RIVER])), + ) + mockOrgDetails(RIVER) + renderWithQuery() + + await detailHeading("River Keepers") + fireEvent.click(screen.getByRole("button", { name: /^Pending review/ })) + + expect(await screen.findByText("Queue is clear")).toBeInTheDocument() + expect(screen.getByText("No verification application is waiting for a decision.")).toBeInTheDocument() + }) + + it("maps each chip to its list params", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([])) + renderWithQuery() + await screen.findByText("No organizations") + + fireEvent.click(screen.getByRole("button", { name: /^Suspended/ })) + await waitFor(() => expect(apiMock.adminListOrgs).toHaveBeenCalledWith({ suspended: true })) + + fireEvent.click(screen.getByRole("button", { name: /^Rejected/ })) + await waitFor(() => expect(apiMock.adminListOrgs).toHaveBeenCalledWith({ verified: "rejected" })) + + fireEvent.click(screen.getByRole("button", { name: /^Verified/ })) + await waitFor(() => expect(apiMock.adminListOrgs).toHaveBeenCalledWith({ verified: "verified" })) + }) + + it("debounces the search box into a trimmed q param", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([])) + renderWithQuery() + await screen.findByText("No organizations") + + fireEvent.change(screen.getByPlaceholderText("Search name or slug…"), { + target: { value: " river " }, + }) + expect(apiMock.adminListOrgs).not.toHaveBeenCalledWith({ q: "river" }) + await waitFor(() => expect(apiMock.adminListOrgs).toHaveBeenCalledWith({ q: "river" })) + }) +}) + +describe("OrgsPage deep links and detail tabs", () => { + it("a focusId selects that organization instead of the first row", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER, PARK])) + mockOrgDetails(RIVER, PARK) + renderWithQuery() + + await detailHeading("Park Friends") + expect(apiMock.adminGetOrg).toHaveBeenCalledWith({ id: "org-2" }) + expect(apiMock.adminGetOrg).not.toHaveBeenCalledWith({ id: "org-1" }) + expect(screen.getByRole("tab", { name: "Profile" })).toHaveAttribute("aria-selected", "true") + }) + + it("a focusId outside the first page still loads its detail on its own", async () => { + const far = makeOrg({ id: "org-far", slug: "far-away", name: "Far Away Club" }) + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER], { nextCursor: "cur-2" })) + mockOrgDetails(RIVER, far) + renderWithQuery() + + await detailHeading("Far Away Club") + expect(apiMock.adminGetOrg).toHaveBeenCalledWith({ id: "org-far" }) + expect(apiMock.adminGetOrg).not.toHaveBeenCalledWith({ id: "org-1" }) + expect(screen.queryByText("/far-away")).not.toBeInTheDocument() + expect(screen.getByText("/river-keepers").closest(".qrow")).not.toHaveClass("selected") + }) + + it("an unknown tab in the focusId falls back to the Profile tab", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER])) + mockOrgDetails(RIVER) + renderWithQuery() + + await detailHeading("River Keepers") + expect(screen.getByRole("tab", { name: "Profile" })).toHaveAttribute("aria-selected", "true") + }) + + it("id/members opens the Members tab with the roster", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER])) + mockOrgDetails(RIVER) + apiMock.adminListOrgMembers.mockResolvedValue({ + items: [ + { user: OWNER, role: "owner", joinedAt: "2026-01-02T10:00:00.000Z" }, + { + user: { id: "u-2", name: "Sam Lee", handle: "@sam", joined: "2025-02-01" }, + role: "member", + joinedAt: "2026-01-03T10:00:00.000Z", + }, + ], + nextCursor: null, + } satisfies AdminOrgMemberListResponse) + renderWithQuery() + + await detailHeading("River Keepers") + expect(screen.getByRole("tab", { name: /^Members/ })).toHaveAttribute("aria-selected", "true") + expect(await screen.findByText("Sam Lee")).toBeInTheDocument() + expect(apiMock.adminListOrgMembers).toHaveBeenCalledWith({ id: "org-1" }) + expect(screen.getByText("Transfer ownership before removing")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Actions for Sam Lee" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Add member/ })).toBeEnabled() + }) + + it("id/members shows the roster error state", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER])) + mockOrgDetails(RIVER) + apiMock.adminListOrgMembers.mockRejectedValue(new Error("Roster down")) + renderWithQuery() + + expect(await screen.findByText("Could not load members")).toBeInTheDocument() + expect(screen.getByText("Roster down")).toBeInTheDocument() + }) + + it("id/verification opens the Verification tab with the application and decision actions", async () => { + const applied = makeOrg({ + id: "org-3", + slug: "tree-crew", + name: "Tree Crew", + verifiedStatus: "pending", + verifiedKind: null, + verification: { + organizationId: "org-3", + slug: "tree-crew", + name: "Tree Crew", + status: "pending", + kind: "community", + einLast4: "1234", + documentMediaIds: [], + note: "We plant trees on weekends.", + submittedBy: OWNER, + submittedAt: "2026-03-01T10:00:00.000Z", + reviewedBy: null, + reviewedAt: null, + rejectionReason: null, + }, + }) + apiMock.adminListOrgs.mockResolvedValue(listPage([applied])) + mockOrgDetails(applied) + renderWithQuery() + + await detailHeading("Tree Crew") + expect(screen.getByRole("tab", { name: /^Verification/ })).toHaveAttribute( + "aria-selected", + "true", + ) + expect(screen.getByText("We plant trees on weekends.")).toBeInTheDocument() + expect(screen.getByText("Community group")).toBeInTheDocument() + expect(screen.getByText(/1234$/)).toBeInTheDocument() + expect(screen.getByText("No evidence uploaded")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Verify as nonprofit" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Verify as government" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Verify as community group" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled() + }) + + it("id/verification for an org that never applied says so", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER])) + mockOrgDetails(RIVER) + renderWithQuery() + + await detailHeading("River Keepers") + expect(screen.getByText("This organization has never applied for verification.")).toBeInTheDocument() + expect(screen.getByText(/This application was already decided\./)).toBeInTheDocument() + }) + + it("id/events opens the Events tab on upcoming events and lists them", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER])) + mockOrgDetails(RIVER) + apiMock.adminListOrgEvents.mockResolvedValue({ + items: [ + { + id: "ev-1", + status: "upcoming", + eventKind: "cleanup", + flagged: false, + title: "Riverbank sweep", + place: "Echo Park", + attendees: 4, + capacity: 20, + bags: 0, + organizer: OWNER, + date: { rel: "in 2 days", abs: "Oct 1" }, + coords: [34.07, -118.26], + }, + ], + nextCursor: null, + } satisfies AdminOrgEventListResponse) + renderWithQuery() + + await detailHeading("River Keepers") + expect(screen.getByRole("tab", { name: /^Events/ })).toHaveAttribute("aria-selected", "true") + expect(await screen.findByText("Riverbank sweep")).toBeInTheDocument() + expect(apiMock.adminListOrgEvents).toHaveBeenCalledWith({ id: "org-1", when: "upcoming" }) + expect(screen.getByText("4/20 attending")).toBeInTheDocument() + expect(screen.getByText("1 hosted in total")).toBeInTheDocument() + }) + + it("id/events shows the empty upcoming state and switches the when filter", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER])) + mockOrgDetails(RIVER) + apiMock.adminListOrgEvents.mockResolvedValue({ items: [], nextCursor: null }) + renderWithQuery() + + expect(await screen.findByText("Nothing scheduled")).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Past" })) + expect(await screen.findByText("No events here")).toBeInTheDocument() + expect(apiMock.adminListOrgEvents).toHaveBeenCalledWith({ id: "org-1", when: "past" }) + }) + + it("clicking a tab switches the detail body", async () => { + apiMock.adminListOrgs.mockResolvedValue(listPage([RIVER])) + mockOrgDetails(RIVER) + apiMock.adminListOrgMembers.mockResolvedValue({ items: [], nextCursor: null }) + renderWithQuery() + + await detailHeading("River Keepers") + fireEvent.click(screen.getByRole("tab", { name: /^Members/ })) + + expect(await screen.findByText("No members")).toBeInTheDocument() + expect(screen.getByRole("tab", { name: /^Members/ })).toHaveAttribute("aria-selected", "true") + expect(screen.getByRole("tab", { name: "Profile" })).toHaveAttribute("aria-selected", "false") + }) +}) diff --git a/apps/admin/src/features/pages/pages-page.test.tsx b/apps/admin/src/features/pages/pages-page.test.tsx new file mode 100644 index 0000000..0fb49d8 --- /dev/null +++ b/apps/admin/src/features/pages/pages-page.test.tsx @@ -0,0 +1,362 @@ +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi } from "vitest" +import type { + AdminEventPageListItemDTO, + AdminEventPageListResponse, + AdminGetEventPageResponse, +} from "@civfix/shared" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { PagesPage } from "@/features/pages/pages-page" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + + +const ECHO_ID = "11111111-1111-4111-8111-111111111111" +const RIVER_ID = "22222222-2222-4222-8222-222222222222" +const LAKE_ID = "33333333-3333-4333-8333-333333333333" +const ORPHAN_ID = "44444444-4444-4444-8444-444444444444" + +const echoPage = { + cleanupId: ECHO_ID, + slug: "echo-park-cleanup", + title: "Echo Park Cleanup", + status: "published", + visibility: "public", + organizer: { id: "u1", name: "Rosa Marquez", handle: "rosa", joined: "2026-01-02T00:00:00.000Z" }, + orgName: "Friends of Echo Park", + viewCount: 1234, + publishedAt: "2026-09-01T17:00:00.000Z", + flaggedAt: null, + flagReason: null, + flaggedBy: null, +} satisfies AdminEventPageListItemDTO + +const riverPage = { + cleanupId: RIVER_ID, + slug: "river-day", + title: "LA River Day", + status: "unpublished", + visibility: "unlisted", + organizer: null, + orgName: null, + viewCount: 42, + publishedAt: null, + flaggedAt: "2026-09-10T12:00:00.000Z", + flagReason: "Fundraising link points at a personal account", + flaggedBy: { id: "op1", name: "Operator Ann", handle: "ann", joined: "2025-05-05T00:00:00.000Z" }, +} satisfies AdminEventPageListItemDTO + +const lakePage = { + cleanupId: LAKE_ID, + slug: null, + title: "Silver Lake Sweep", + status: "draft", + visibility: "private", + organizer: null, + orgName: "Silver Lake Neighbors", + viewCount: 0, + publishedAt: null, + flaggedAt: null, + flagReason: null, + flaggedBy: null, +} satisfies AdminEventPageListItemDTO + +function listPage( + items: AdminEventPageListItemDTO[], + nextCursor: string | null = null, +): AdminEventPageListResponse { + return { items, nextCursor } +} + +function eventPage( + cleanupId: string, + overrides: Partial = {}, +): AdminGetEventPageResponse { + return { + cleanupId, + slug: "echo-park-cleanup", + status: "published", + theme: { accent: "bloom" }, + blocks: [ + { + id: "b1", + kind: "about", + title: "About the day", + body: "Bring gloves and water.", + }, + ], + seo: { title: "Echo Park Cleanup", description: "Monthly lakeside sweep", noindex: false }, + visibility: "public", + publishedAt: "2026-09-01T17:00:00.000Z", + flaggedAt: null, + flagReason: null, + viewCount: 1234, + ...overrides, + } +} + +function listPane(): HTMLElement { + const section = screen.getByRole("heading", { level: 3, name: "Pages" }).closest("section") + if (!section) throw new Error("list heading is not inside a section") + return section +} + +function detailPane(title: string): HTMLElement { + const heading = screen.getByRole("heading", { level: 2, name: title }) + const section = heading.closest("section") + if (!section) throw new Error("detail heading is not inside a section") + return section +} + +describe("PagesPage list states", () => { + it("shows the loading state while the list is in flight", async () => { + apiMock.adminListEventPages.mockReturnValue(new Promise(() => {})) + renderWithQuery() + + expect(await screen.findByText("Loading pages...")).toBeInTheDocument() + expect(screen.getByRole("heading", { level: 1, name: "Signup pages" })).toBeInTheDocument() + expect(screen.getByText("No page selected")).toBeInTheDocument() + }) + + it("shows the empty state when no page matches", async () => { + apiMock.adminListEventPages.mockResolvedValue(listPage([])) + renderWithQuery() + + expect(await screen.findByText("No pages loaded")).toBeInTheDocument() + expect( + screen.getByText("Nothing on the loaded pages matches this filter or search."), + ).toBeInTheDocument() + expect(screen.getByText("No page selected")).toBeInTheDocument() + expect(screen.getByText("Pick a page from the list.")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) + + it("shows the error message with a retry that refetches", async () => { + apiMock.adminListEventPages.mockRejectedValueOnce(new Error("pages backend unavailable")) + renderWithQuery() + + const alert = await screen.findByRole("alert") + expect(within(alert).getByText("Could not load this")).toBeInTheDocument() + expect(within(alert).getByText("pages backend unavailable")).toBeInTheDocument() + + apiMock.adminListEventPages.mockResolvedValue(listPage([echoPage])) + await userEvent.click(within(alert).getByRole("button", { name: "Try again" })) + + expect(await screen.findByRole("heading", { level: 2, name: "Echo Park Cleanup" })).toBeInTheDocument() + expect(apiMock.adminListEventPages).toHaveBeenCalledTimes(2) + }) + + it("renders rows with their key fields and opens the first page in the detail pane", async () => { + apiMock.adminListEventPages.mockResolvedValue(listPage([echoPage, riverPage, lakePage])) + apiMock.adminGetEventPage.mockImplementation(({ id }: { id: string }) => + Promise.resolve(eventPage(id)), + ) + renderWithQuery() + + expect(await screen.findByText("/echo-park-cleanup")).toBeInTheDocument() + const list = listPane() + expect(within(list).getByText("/river-day")).toBeInTheDocument() + expect(within(list).getByText("no slug")).toBeInTheDocument() + expect(within(list).getByText("1,234 views")).toBeInTheDocument() + expect(within(list).getByText("42 views")).toBeInTheDocument() + expect(within(list).getByText("Public")).toBeInTheDocument() + expect(within(list).getByText("Unlisted")).toBeInTheDocument() + expect(within(list).getByText("Private")).toBeInTheDocument() + expect(within(list).getByText("Rosa Marquez")).toBeInTheDocument() + expect(within(list).getByText("Friends of Echo Park")).toBeInTheDocument() + expect(within(list).getByText("Silver Lake Neighbors")).toBeInTheDocument() + expect(within(list).getByText("Published")).toBeInTheDocument() + expect(within(list).getByText("Unpublished")).toBeInTheDocument() + expect(within(list).getByText("Draft")).toBeInTheDocument() + expect(within(list).getByText("3")).toBeInTheDocument() + + const detail = detailPane("Echo Park Cleanup") + expect(within(detail).getByText("/e/echo-park-cleanup")).toBeInTheDocument() + expect(within(detail).getByText("Public")).toBeInTheDocument() + expect(within(detail).getByText("1,234")).toBeInTheDocument() + expect(within(detail).getByText("Friends of Echo Park")).toBeInTheDocument() + expect(within(detail).getByText("Rosa Marquez")).toBeInTheDocument() + expect(within(detail).getByRole("button", { name: "Flag" })).toBeEnabled() + expect(within(detail).getByRole("button", { name: "Unpublish" })).toBeEnabled() + expect(within(detail).getByRole("button", { name: /Open the event/ })).toBeInTheDocument() + + expect(await within(detail).findByText("Monthly lakeside sweep")).toBeInTheDocument() + expect(within(detail).getByText("About the day")).toBeInTheDocument() + expect(within(detail).getByText("Bring gloves and water.")).toBeInTheDocument() + expect(within(detail).getByText(/\/echo-park-cleanup · theme bloom · indexable/)).toBeInTheDocument() + expect(apiMock.adminGetEventPage).toHaveBeenCalledWith({ id: ECHO_ID }) + }) + + it("selecting a row swaps the detail pane to that page", async () => { + apiMock.adminListEventPages.mockResolvedValue(listPage([echoPage, riverPage, lakePage])) + apiMock.adminGetEventPage.mockImplementation(({ id }: { id: string }) => + Promise.resolve(eventPage(id, { slug: "river-day", seo: { title: null, noindex: true } })), + ) + renderWithQuery() + + await userEvent.click(await screen.findByText("LA River Day")) + + const detail = detailPane("LA River Day") + expect(screen.queryByRole("heading", { level: 2, name: "Echo Park Cleanup" })).not.toBeInTheDocument() + expect(within(detail).getByText("/e/river-day")).toBeInTheDocument() + // Once as the header badge, once as the flag row label. + expect(within(detail).getAllByText("Flagged")).toHaveLength(2) + expect(within(detail).getByText(/Operator Ann/)).toBeInTheDocument() + expect( + within(detail).getByText("Fundraising link points at a personal account"), + ).toBeInTheDocument() + expect(within(detail).getByRole("button", { name: "Clear flag" })).toBeEnabled() + expect(within(detail).getByRole("button", { name: "Unpublish" })).toBeDisabled() + expect(await within(detail).findByText(/\/river-day · theme bloom · noindex/)).toBeInTheDocument() + }) + + it("shows the draft crumb for a page without a slug", async () => { + apiMock.adminListEventPages.mockResolvedValue(listPage([echoPage, lakePage])) + apiMock.adminGetEventPage.mockImplementation(({ id }: { id: string }) => + Promise.resolve(eventPage(id, { slug: null, status: "draft", blocks: [] })), + ) + renderWithQuery() + + await userEvent.click(await screen.findByText("Silver Lake Sweep")) + + const detail = detailPane("Silver Lake Sweep") + expect(within(detail).getByText("unpublished draft")).toBeInTheDocument() + expect(await within(detail).findByText("No blocks")).toBeInTheDocument() + expect(within(detail).getByText(/no public slug · theme bloom · indexable/)).toBeInTheDocument() + }) +}) + +describe("PagesPage paging, filters and search", () => { + it("requests published pages first and loads the next page with the cursor", async () => { + apiMock.adminListEventPages + .mockResolvedValueOnce(listPage([echoPage], "cursor-2")) + .mockResolvedValueOnce(listPage([riverPage])) + apiMock.adminGetEventPage.mockImplementation(({ id }: { id: string }) => + Promise.resolve(eventPage(id)), + ) + renderWithQuery() + + await screen.findByText("/echo-park-cleanup") + expect(apiMock.adminListEventPages).toHaveBeenNthCalledWith(1, { status: "published" }) + + await userEvent.click(screen.getByRole("button", { name: "Load more" })) + + expect(await screen.findByText("/river-day")).toBeInTheDocument() + expect(apiMock.adminListEventPages).toHaveBeenNthCalledWith(2, { + status: "published", + cursor: "cursor-2", + }) + expect(screen.getByText("/echo-park-cleanup")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + expect(within(listPane()).getByText("2")).toBeInTheDocument() + }) + + it("maps each filter chip onto the list params", async () => { + apiMock.adminListEventPages.mockResolvedValue(listPage([])) + renderWithQuery() + await screen.findByText("No pages loaded") + + await userEvent.click(screen.getByRole("button", { name: "All" })) + await waitFor(() => expect(apiMock.adminListEventPages).toHaveBeenLastCalledWith({})) + + await userEvent.click(screen.getByRole("button", { name: "Unpublished" })) + await waitFor(() => + expect(apiMock.adminListEventPages).toHaveBeenLastCalledWith({ status: "unpublished" }), + ) + + await userEvent.click(screen.getByRole("button", { name: "Draft" })) + await waitFor(() => + expect(apiMock.adminListEventPages).toHaveBeenLastCalledWith({ status: "draft" }), + ) + + await userEvent.click(screen.getByRole("button", { name: "Flagged" })) + await waitFor(() => + expect(apiMock.adminListEventPages).toHaveBeenLastCalledWith({ flagged: true }), + ) + }) + + it("sends the trimmed search after the debounce, keeping the active filter", async () => { + apiMock.adminListEventPages.mockResolvedValue(listPage([])) + renderWithQuery() + await screen.findByText("No pages loaded") + + await userEvent.type(screen.getByPlaceholderText(/Search slug or title/), " echo ") + + await waitFor(() => + expect(apiMock.adminListEventPages).toHaveBeenLastCalledWith({ + status: "published", + q: "echo", + }), + ) + // Debounced: the keystrokes before the pause never reach the API. + expect(apiMock.adminListEventPages).not.toHaveBeenCalledWith({ status: "published", q: "e" }) + }) +}) + +describe("PagesPage deep links", () => { + it("opens the focused page when it is in the first page of results", async () => { + apiMock.adminListEventPages.mockResolvedValue(listPage([echoPage, riverPage])) + apiMock.adminGetEventPage.mockImplementation(({ id }: { id: string }) => + Promise.resolve(eventPage(id, { slug: "river-day" })), + ) + renderWithQuery() + + expect(await screen.findByRole("heading", { level: 2, name: "LA River Day" })).toBeInTheDocument() + expect(screen.queryByRole("heading", { level: 2, name: "Echo Park Cleanup" })).not.toBeInTheDocument() + }) + + it("fetches the focused page on its own when it is not in the loaded list", async () => { + apiMock.adminListEventPages.mockResolvedValue(listPage([echoPage])) + apiMock.adminGetEventPage.mockImplementation(({ id }: { id: string }) => + Promise.resolve( + eventPage(id, { + slug: "orphan-sweep", + status: "unpublished", + visibility: "unlisted", + seo: { title: "Orphan Sweep", noindex: false }, + viewCount: 7, + flaggedAt: "2026-09-12T08:00:00.000Z", + flagReason: "Copied a city logo", + }), + ), + ) + renderWithQuery() + + const heading = await screen.findByRole("heading", { level: 2, name: "Orphan Sweep" }) + expect(heading).toBeInTheDocument() + expect(apiMock.adminGetEventPage).toHaveBeenCalledWith({ id: ORPHAN_ID }) + + const detail = detailPane("Orphan Sweep") + expect(within(detail).getByText("/e/orphan-sweep")).toBeInTheDocument() + expect(within(detail).getByText("Unlisted")).toBeInTheDocument() + expect(within(detail).getByText("Copied a city logo")).toBeInTheDocument() + expect(within(detail).getByRole("button", { name: "Unpublish" })).toBeDisabled() + expect(screen.queryByRole("heading", { level: 2, name: "Echo Park Cleanup" })).not.toBeInTheDocument() + expect(screen.getByText("/echo-park-cleanup")).toBeInTheDocument() + }) + + it("shows the linked-page error when the focused page cannot be fetched", async () => { + apiMock.adminListEventPages.mockResolvedValue(listPage([echoPage])) + apiMock.adminGetEventPage.mockRejectedValue(new Error("page not found")) + renderWithQuery() + + expect(await screen.findByText("Could not load the linked page")).toBeInTheDocument() + expect(screen.getByText("page not found")).toBeInTheDocument() + expect(screen.queryByRole("heading", { level: 2 })).not.toBeInTheDocument() + }) + + it("shows the linked-page loader while the focused page is in flight", async () => { + apiMock.adminListEventPages.mockResolvedValue(listPage([echoPage])) + apiMock.adminGetEventPage.mockReturnValue(new Promise(() => {})) + renderWithQuery() + + expect(await screen.findByText("Loading the linked page...")).toBeInTheDocument() + }) +}) diff --git a/apps/admin/src/features/reports/reports-page.test.tsx b/apps/admin/src/features/reports/reports-page.test.tsx new file mode 100644 index 0000000..fe093da --- /dev/null +++ b/apps/admin/src/features/reports/reports-page.test.tsx @@ -0,0 +1,297 @@ +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { + AdminReportDTO, + AdminReportListItemDTO, + AdminReportListResponse, + ChatHistoryResponse, +} from "@civfix/shared" +import { describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { ReportsPage } from "@/features/reports/reports-page" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +// Leaflet needs real layout; the minimap is irrelevant to the list/detail states pinned here. +vi.mock("@/components/map/leaflet-map", () => ({ + LeafletMap: () =>
, +})) + + +function listItem(over: Partial & { id: string; title: string }) { + return { + category: "trash", + status: "published", + flagged: false, + place: "Temescal", + reporter: { id: "u-rita", name: "Rita Gomez", handle: "@rita", joined: "Feb 2026" }, + confirmations: 0, + submitted: { rel: "3h", abs: "Sep 23, 2026, 9:00 AM" }, + coords: [37.83, -122.26], + address: "4900 Telegraph Ave", + hasPhoto: false, + thumbnailUrl: null, + ...over, + } satisfies AdminReportListItemDTO +} + +const COUCH = listItem({ + id: "r1a2b3c4-0000-4000-8000-000000000001", + title: "Couch dumped on sidewalk", + confirmations: 2, +}) +const TAG = listItem({ + id: "r5d6e7f8-0000-4000-8000-000000000002", + title: "Tag on the underpass", + category: "graffiti", + status: "in_progress", + flagged: true, + place: "West Oakland", + reporter: { id: null, name: "Sam Ortiz", handle: "@sam", joined: "-" }, + submitted: { rel: "1d", abs: "Sep 22, 2026, 9:00 AM" }, +}) + +const COUNTS = { all: 30, submitted: 9, in_progress: 4, completed: 12, flagged: 2, needsVerification: 5 } + +function page(items: AdminReportListItemDTO[], nextCursor: string | null = null) { + return { items, nextCursor, counts: COUNTS } satisfies AdminReportListResponse +} + +function detail(item: AdminReportListItemDTO): AdminReportDTO { + return { + ...item, + desc: `Description of ${item.title}`, + timeline: [{ who: "Rita", what: "submitted the report", when: "3h ago", kind: "submit" }], + city: { + dept: "Oakland Public Works", + place: item.place, + contact: "works@oaklandca.gov", + routed: false, + }, + media: [], + linkedEvents: [], + geoid: "0653000", + outreach: { status: "not_sent", threadId: null, routedTo: null, routedAt: null }, + referenceCode: `CF-${item.id.slice(0, 4).toUpperCase()}`, + verificationVerdict: null, + verifiedAt: null, + reporterReportVerified: false, + } satisfies AdminReportDTO +} + +const NO_CHAT = { items: [], nextCursor: null } satisfies ChatHistoryResponse + +function mockDetails(...items: AdminReportListItemDTO[]) { + apiMock.getAdminReport.mockImplementation(async ({ id }: { id: string }) => { + const r = items.find((x) => x.id === id) + if (!r) throw new Error(`no report ${id}`) + return detail(r) + }) + apiMock.adminReportMessages.mockResolvedValue(NO_CHAT) +} + +function listCard() { + return screen.getByRole("heading", { level: 3, name: "Reports" }).closest("section") as HTMLElement +} + +function detailCard() { + return document.querySelector(".md-detail-card") as HTMLElement +} + +describe("ReportsPage", () => { + it("shows the loading state while the first page is in flight", () => { + apiMock.listAdminReports.mockReturnValue(new Promise(() => {})) + renderWithQuery() + expect(screen.getByRole("status")).toHaveTextContent("Loading reports...") + expect(within(detailCard()).getByText("No report selected")).toBeInTheDocument() + }) + + it("asks for the Needs verification facet by default", () => { + apiMock.listAdminReports.mockReturnValue(new Promise(() => {})) + renderWithQuery() + expect(apiMock.listAdminReports).toHaveBeenCalledWith({ filter: "needs_verification" }) + }) + + it("shows the empty copy when no report matches", async () => { + apiMock.listAdminReports.mockResolvedValue(page([])) + renderWithQuery() + expect(await screen.findByText("Nothing matches")).toBeInTheDocument() + expect(screen.getByText("Try a different filter or search.")).toBeInTheDocument() + expect(within(detailCard()).getByText("Pick a report from the list.")).toBeInTheDocument() + }) + + it("shows the error state with the failure message and a retry", async () => { + apiMock.listAdminReports.mockRejectedValue(new Error("Reports backend unreachable")) + renderWithQuery() + const alert = await screen.findByRole("alert") + expect(alert).toHaveTextContent("Could not load this") + expect(alert).toHaveTextContent("Reports backend unreachable") + expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument() + }) + + it("renders rows with key fields and auto-selects the first report into the detail pane", async () => { + apiMock.listAdminReports.mockResolvedValue(page([COUCH, TAG])) + mockDetails(COUCH, TAG) + renderWithQuery() + + await screen.findByText("Couch dumped on sidewalk") + const list = listCard() + expect(within(list).getByText("#r1a2b3c4")).toBeInTheDocument() + expect(within(list).getByText("Temescal")).toBeInTheDocument() + expect(within(list).getByRole("button", { name: "Rita" })).toBeInTheDocument() + expect(within(list).getByText("2 confirms")).toBeInTheDocument() + expect(within(list).getByText("Needs verification")).toBeInTheDocument() + expect(within(list).getByText("3h")).toBeInTheDocument() + expect(within(list).getByRole("img", { name: "Trash" })).toBeInTheDocument() + expect(within(list).getByText("Tag on the underpass")).toBeInTheDocument() + expect(within(list).getByText("Sam")).toBeInTheDocument() + expect(within(list).queryByRole("button", { name: "Sam" })).not.toBeInTheDocument() + expect(within(list).getByText("In progress")).toBeInTheDocument() + expect(within(list).getByTitle("Flagged")).toBeInTheDocument() + // The card head shows the server's facet total, not the number of rows loaded. + expect(within(list).getByText("5")).toBeInTheDocument() + + const card = detailCard() + expect( + await within(card).findByRole("heading", { name: "Couch dumped on sidewalk" }), + ).toBeInTheDocument() + expect(within(card).getByText("CF-R1A2")).toBeInTheDocument() + expect(within(card).getByText("Description of Couch dumped on sidewalk")).toBeInTheDocument() + expect(within(card).getByText("4900 Telegraph Ave")).toBeInTheDocument() + expect(within(card).getByText("2 neighbors confirmed")).toBeInTheDocument() + expect(within(card).getByText("submitted the report")).toBeInTheDocument() + expect(within(card).getByText("Rita Gomez")).toBeInTheDocument() + expect(within(card).getAllByText("Oakland Public Works")).toHaveLength(2) + expect(within(card).getByText("works@oaklandca.gov")).toBeInTheDocument() + expect(within(card).getByText("Not yet reviewed")).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /Verify and send to city/ })).toBeEnabled() + expect(within(card).getByRole("button", { name: "Acknowledged" })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Under review" })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /Remove report/ })).toBeInTheDocument() + expect(await within(card).findByText("No messages yet")).toBeInTheDocument() + expect(apiMock.getAdminReport).toHaveBeenCalledWith({ id: COUCH.id }) + expect(apiMock.adminReportMessages).toHaveBeenCalledWith({ id: COUCH.id, limit: 50 }) + }) + + it("opens the clicked report in the detail pane", async () => { + apiMock.listAdminReports.mockResolvedValue(page([COUCH, TAG])) + mockDetails(COUCH, TAG) + renderWithQuery() + await within(detailCard()).findByRole("heading", { name: "Couch dumped on sidewalk" }) + + await userEvent.click(within(listCard()).getByText("Tag on the underpass")) + + const card = detailCard() + expect(await within(card).findByRole("heading", { name: "Tag on the underpass" })).toBeInTheDocument() + // Both the status badge and the toggled flag button read "Flagged". + expect(within(card).getAllByText("Flagged")).toHaveLength(2) + expect(within(card).getByRole("button", { name: "Flagged" })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Resolved" })).toBeInTheDocument() + expect(within(card).queryByRole("button", { name: /View full account/ })).not.toBeInTheDocument() + expect(apiMock.getAdminReport).toHaveBeenCalledWith({ id: TAG.id }) + }) + + it("shows the facet counts on the filter chips", async () => { + apiMock.listAdminReports.mockResolvedValue(page([COUCH])) + mockDetails(COUCH) + renderWithQuery() + await screen.findByText("Couch dumped on sidewalk") + expect(screen.getByRole("button", { name: "Needs verification 5" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "In progress 4" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Completed 12" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Flagged 2" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "All 30" })).toBeInTheDocument() + }) + + it("sends the chosen filter chip to the api", async () => { + apiMock.listAdminReports.mockResolvedValue(page([COUCH])) + mockDetails(COUCH) + renderWithQuery() + await screen.findByText("Couch dumped on sidewalk") + + await userEvent.click(screen.getByRole("button", { name: /^Completed/ })) + await waitFor(() => + expect(apiMock.listAdminReports).toHaveBeenLastCalledWith({ filter: "completed" }), + ) + expect(within(listCard()).getByText("12")).toBeInTheDocument() + + await userEvent.click(screen.getByRole("button", { name: /^Flagged/ })) + await waitFor(() => + expect(apiMock.listAdminReports).toHaveBeenLastCalledWith({ filter: "flagged" }), + ) + + await userEvent.click(screen.getByRole("button", { name: /^All/ })) + await waitFor(() => expect(apiMock.listAdminReports).toHaveBeenLastCalledWith({})) + }) + + it("sends the trimmed search text alongside the filter after the debounce", async () => { + apiMock.listAdminReports.mockResolvedValue(page([COUCH])) + mockDetails(COUCH) + renderWithQuery() + await screen.findByText("Couch dumped on sidewalk") + + await userEvent.type(screen.getByPlaceholderText("Search title, place, reporter…"), " couch ") + await waitFor(() => + expect(apiMock.listAdminReports).toHaveBeenLastCalledWith({ + filter: "needs_verification", + q: "couch", + }), + ) + }) + + it("shows Load more when a cursor is returned and fetches the next page with it", async () => { + apiMock.listAdminReports.mockImplementation(async (params: { cursor?: string }) => + params.cursor === "rep-2" ? page([TAG]) : page([COUCH], "rep-2"), + ) + mockDetails(COUCH, TAG) + renderWithQuery() + await screen.findByText("Couch dumped on sidewalk") + + await userEvent.click(screen.getByRole("button", { name: "Load more" })) + + expect(await within(listCard()).findByText("Tag on the underpass")).toBeInTheDocument() + expect(apiMock.listAdminReports).toHaveBeenLastCalledWith({ + filter: "needs_verification", + cursor: "rep-2", + }) + expect(within(listCard()).getByText("Couch dumped on sidewalk")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) + + it("opens a deep-linked focusId that is on the first page", async () => { + apiMock.listAdminReports.mockResolvedValue(page([COUCH, TAG])) + mockDetails(COUCH, TAG) + renderWithQuery() + expect(await within(detailCard()).findByRole("heading", { name: "Tag on the underpass" })).toBeInTheDocument() + expect(within(detailCard()).queryByRole("heading", { name: "Couch dumped on sidewalk" })).not.toBeInTheDocument() + expect(within(listCard()).queryByText("Linked report")).not.toBeInTheDocument() + }) + + it("keeps a deep-linked focusId that is not on the first page and pins it above the list as a linked report", async () => { + const OLD = listItem({ + id: "r9f9f9f9-0000-4000-8000-000000000009", + title: "Old broken hydrant", + category: "water", + status: "resolved", + }) + apiMock.listAdminReports.mockResolvedValue(page([COUCH, TAG])) + mockDetails(COUCH, TAG, OLD) + renderWithQuery() + + const card = detailCard() + expect(await within(card).findByRole("heading", { name: "Old broken hydrant" })).toBeInTheDocument() + expect(within(card).queryByRole("heading", { name: "Couch dumped on sidewalk" })).not.toBeInTheDocument() + + const list = listCard() + expect(await within(list).findByText("Linked report")).toBeInTheDocument() + expect(within(list).getByText("Old broken hydrant")).toBeInTheDocument() + expect(within(list).getByText("Couch dumped on sidewalk")).toBeInTheDocument() + expect(apiMock.getAdminReport).toHaveBeenCalledWith({ id: OLD.id }) + }) +}) diff --git a/apps/admin/src/features/users/users-page.test.tsx b/apps/admin/src/features/users/users-page.test.tsx new file mode 100644 index 0000000..019a4f6 --- /dev/null +++ b/apps/admin/src/features/users/users-page.test.tsx @@ -0,0 +1,245 @@ +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { + AdminUserDTO, + AdminUserListItemDTO, + AdminUserListResponse, + UserReportsResponse, +} from "@civfix/shared" +import { describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" +import { UsersPage } from "@/features/users/users-page" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + + +function listItem(over: Partial & { id: string; name: string }) { + return { + handle: "@handle", + city: "Oakland", + joined: "2026-01-05T00:00:00.000Z", + avatar: ["#111111", "#222222"], + avatarUrl: null, + status: "active", + reports: 0, + cleanups: 0, + removals: 0, + strikes: 0, + risk: "low", + lastActive: "2h ago", + flagged: false, + flagReason: null, + deletedAt: null, + ...over, + } satisfies AdminUserListItemDTO +} + +const ANA = listItem({ + id: "u-ana", + name: "Ana Ruiz", + handle: "@anaruiz", + city: "Fresno", + reports: 3, + cleanups: 2, +}) +const BEN = listItem({ + id: "u-ben", + name: "Ben Okafor", + handle: "@benok", + status: "suspended", + flagged: true, + reports: 1, + cleanups: 0, +}) + +function page(items: AdminUserListItemDTO[], nextCursor: string | null = null) { + return { + items, + nextCursor, + counts: { all: 12, active: 9, suspended: 1, flagged: 2, deleted: 0, banned: 0 }, + } satisfies AdminUserListResponse +} + +function detail(item: AdminUserListItemDTO): AdminUserDTO { + return { + ...item, + role: "citizen", + messages: 4, + reportVerified: false, + organizations: [], + } satisfies AdminUserDTO +} + +const NO_REPORTS = { items: [], nextCursor: null } satisfies UserReportsResponse + +function mockDetails(...users: AdminUserListItemDTO[]) { + apiMock.getAdminUser.mockImplementation(async ({ id }: { id: string }) => { + const u = users.find((x) => x.id === id) + if (!u) throw new Error(`no user ${id}`) + return detail(u) + }) + apiMock.getUserReports.mockResolvedValue(NO_REPORTS) +} + +function accountsList() { + return screen.getByRole("heading", { name: "Accounts" }).closest("section") as HTMLElement +} + +function detailCard() { + return document.querySelector(".md-detail-card") as HTMLElement +} + +describe("UsersPage", () => { + it("shows the loading state while the first page is in flight", () => { + apiMock.listAdminUsers.mockReturnValue(new Promise(() => {})) + renderWithQuery() + expect(screen.getByRole("status")).toHaveTextContent("Loading accounts...") + expect(within(detailCard()).getByText("No user selected")).toBeInTheDocument() + }) + + it("shows the empty copy when no account matches", async () => { + apiMock.listAdminUsers.mockResolvedValue({ items: [], nextCursor: null }) + renderWithQuery() + expect(await screen.findByText("Nothing matches")).toBeInTheDocument() + expect(screen.getByText("Try a different filter or search.")).toBeInTheDocument() + expect(within(detailCard()).getByText("Pick an account from the list.")).toBeInTheDocument() + }) + + it("shows the error state with the failure message and a retry", async () => { + apiMock.listAdminUsers.mockRejectedValue(new Error("Users backend unreachable")) + renderWithQuery() + const alert = await screen.findByRole("alert") + expect(alert).toHaveTextContent("Could not load this") + expect(alert).toHaveTextContent("Users backend unreachable") + expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument() + }) + + it("renders rows with key fields and auto-selects the first account into the detail pane", async () => { + apiMock.listAdminUsers.mockResolvedValue(page([ANA, BEN])) + mockDetails(ANA, BEN) + renderWithQuery() + + await screen.findByText("Ana Ruiz") + const listCard = accountsList() + expect(within(listCard).getByText("Ana Ruiz")).toBeInTheDocument() + expect(within(listCard).getByText("@anaruiz")).toBeInTheDocument() + expect(within(listCard).getByText("Fresno")).toBeInTheDocument() + expect(within(listCard).getByText("3 reports")).toBeInTheDocument() + expect(within(listCard).getByText("2 cleanups")).toBeInTheDocument() + expect(within(listCard).getByText("Ben Okafor")).toBeInTheDocument() + expect(within(listCard).getByText("Suspended")).toBeInTheDocument() + expect(within(listCard).getByTitle("Flagged")).toBeInTheDocument() + + expect(apiMock.listAdminUsers).toHaveBeenCalledWith({}) + expect(await within(detailCard()).findByRole("heading", { name: "Ana Ruiz" })).toBeInTheDocument() + expect(within(detailCard()).getByText("u-ana")).toBeInTheDocument() + expect(within(detailCard()).getByText(/Risk: Low/)).toBeInTheDocument() + expect(within(detailCard()).getByRole("button", { name: /Ban account/ })).toBeInTheDocument() + expect(await within(detailCard()).findByText("No reports yet")).toBeInTheDocument() + expect(apiMock.getAdminUser).toHaveBeenCalledWith({ id: "u-ana" }) + expect(apiMock.getUserReports).toHaveBeenCalledWith({ id: "u-ana" }) + }) + + it("opens the clicked account in the detail pane", async () => { + apiMock.listAdminUsers.mockResolvedValue(page([ANA, BEN])) + mockDetails(ANA, BEN) + renderWithQuery() + await within(detailCard()).findByRole("heading", { name: "Ana Ruiz" }) + + await userEvent.click(within(accountsList()).getByText("Ben Okafor")) + + const card = detailCard() + expect(await within(card).findByRole("heading", { name: "Ben Okafor" })).toBeInTheDocument() + // Both the status badge and the toggled flag button read "Flagged". + expect(within(card).getAllByText("Flagged")).toHaveLength(2) + expect(within(card).getByRole("button", { name: "Flagged" })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /Reactivate/ })).toBeInTheDocument() + expect(apiMock.getAdminUser).toHaveBeenCalledWith({ id: "u-ben" }) + }) + + it("shows the facet counts on the filter chips", async () => { + apiMock.listAdminUsers.mockResolvedValue(page([ANA])) + mockDetails(ANA) + renderWithQuery() + await screen.findByText("Ana Ruiz") + expect(screen.getByRole("button", { name: "All 12" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Flagged 2" })).toBeInTheDocument() + }) + + it("sends the chosen filter chip to the api", async () => { + apiMock.listAdminUsers.mockResolvedValue(page([ANA])) + mockDetails(ANA) + renderWithQuery() + await screen.findByText("Ana Ruiz") + + await userEvent.click(screen.getByRole("button", { name: /^Flagged/ })) + await waitFor(() => + expect(apiMock.listAdminUsers).toHaveBeenLastCalledWith({ filter: "flagged" }), + ) + + await userEvent.click(screen.getByRole("button", { name: /^Deleted/ })) + await waitFor(() => + expect(apiMock.listAdminUsers).toHaveBeenLastCalledWith({ filter: "deleted" }), + ) + + await userEvent.click(screen.getByRole("button", { name: /^All/ })) + await waitFor(() => expect(apiMock.listAdminUsers).toHaveBeenLastCalledWith({})) + }) + + it("sends the trimmed search text to the api after the debounce", async () => { + apiMock.listAdminUsers.mockResolvedValue(page([ANA])) + mockDetails(ANA) + renderWithQuery() + await screen.findByText("Ana Ruiz") + + await userEvent.type(screen.getByPlaceholderText("Search name, handle, city…"), " ana ") + await waitFor(() => expect(apiMock.listAdminUsers).toHaveBeenLastCalledWith({ q: "ana" })) + }) + + it("shows Load more when a cursor is returned and fetches the next page with it", async () => { + apiMock.listAdminUsers.mockImplementation(async (params: { cursor?: string }) => + params.cursor === "cur-2" ? page([BEN]) : page([ANA], "cur-2"), + ) + mockDetails(ANA, BEN) + renderWithQuery() + await screen.findByText("Ana Ruiz") + + await userEvent.click(screen.getByRole("button", { name: "Load more" })) + + expect(await within(accountsList()).findByText("Ben Okafor")).toBeInTheDocument() + expect(apiMock.listAdminUsers).toHaveBeenLastCalledWith({ cursor: "cur-2" }) + expect(within(accountsList()).getByText("Ana Ruiz")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) + + it("hides Load more when there is no cursor", async () => { + apiMock.listAdminUsers.mockResolvedValue(page([ANA])) + mockDetails(ANA) + renderWithQuery() + await screen.findByText("Ana Ruiz") + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() + }) + + it("opens a deep-linked focusId that is on the first page", async () => { + apiMock.listAdminUsers.mockResolvedValue(page([ANA, BEN])) + mockDetails(ANA, BEN) + renderWithQuery() + expect(await within(detailCard()).findByRole("heading", { name: "Ben Okafor" })).toBeInTheDocument() + expect(within(detailCard()).queryByRole("heading", { name: "Ana Ruiz" })).not.toBeInTheDocument() + }) + + it("replaces a deep-linked focusId that is not on the first page with the first row (auto-select override)", async () => { + const CARA = listItem({ id: "u-cara", name: "Cara Lind" }) + apiMock.listAdminUsers.mockResolvedValue(page([ANA, BEN])) + mockDetails(ANA, BEN, CARA) + renderWithQuery() + expect(await within(detailCard()).findByRole("heading", { name: "Ana Ruiz" })).toBeInTheDocument() + expect(within(detailCard()).queryByRole("heading", { name: "Cara Lind" })).not.toBeInTheDocument() + }) +}) From 433e553c6978662a3611c75dc8731a4b0f14b516 Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 03:43:42 +0000 Subject: [PATCH 12/17] pin test locale and timezone; pane locators and fake-timer helper --- apps/admin/src/test/fake-timers.ts | 17 +++++++++++++++++ apps/admin/src/test/panes.ts | 22 ++++++++++++++++++++++ apps/admin/vitest.config.ts | 7 +++++++ 3 files changed, 46 insertions(+) create mode 100644 apps/admin/src/test/fake-timers.ts create mode 100644 apps/admin/src/test/panes.ts diff --git a/apps/admin/src/test/fake-timers.ts b/apps/admin/src/test/fake-timers.ts new file mode 100644 index 0000000..86a16c5 --- /dev/null +++ b/apps/admin/src/test/fake-timers.ts @@ -0,0 +1,17 @@ +import userEvent, { type UserEvent } from "@testing-library/user-event" +import { onTestFinished, vi } from "vitest" + +/** + * Switches the current test to fake timers and returns a user-event instance that advances them. + * Testing Library only detects Jest's fake timers: its async wrapper drains with a `setTimeout(0)` + * that never fires under vitest's fakes unless a `jest` global can advance the clock. + */ +export function startFakeTimersWithUser(): UserEvent { + vi.useFakeTimers() + vi.stubGlobal("jest", { advanceTimersByTime: (ms: number) => vi.advanceTimersByTime(ms) }) + onTestFinished(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + return userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) +} diff --git a/apps/admin/src/test/panes.ts b/apps/admin/src/test/panes.ts new file mode 100644 index 0000000..c14f538 --- /dev/null +++ b/apps/admin/src/test/panes.ts @@ -0,0 +1,22 @@ +// The master-detail panes and queue rows carry no role or accessible name, so their class hooks are +// the only locators; keeping them here means a CSS rename edits one place. +const DETAIL_PANE = ".md-detail-card" +const LIST_PANE = ".md-list" +const QUEUE_ROW = ".qrow" + +function found(el: HTMLElement | null, what: string): HTMLElement { + if (!el) throw new Error(`${what} is not rendered`) + return el +} + +export function detailCard(): HTMLElement { + return found(document.querySelector(DETAIL_PANE), "detail pane") +} + +export function listCard(): HTMLElement { + return found(document.querySelector(LIST_PANE), "list pane") +} + +export function queueRowOf(el: Element): HTMLElement { + return found(el.closest(QUEUE_ROW), "queue row") +} diff --git a/apps/admin/vitest.config.ts b/apps/admin/vitest.config.ts index 8eba1e1..34e1c46 100644 --- a/apps/admin/vitest.config.ts +++ b/apps/admin/vitest.config.ts @@ -1,6 +1,12 @@ import { fileURLToPath } from "node:url" import { defineConfig } from "vitest/config" +// ICU reads the locale once at process start, so it must be in the env before the forked workers spawn; +// worker threads would share this process's already-initialized ICU and ignore it. +process.env.TZ = "UTC" +process.env.LANG = "en_US.UTF-8" +process.env.LC_ALL = "en_US.UTF-8" + export default defineConfig({ resolve: { alias: { @@ -10,6 +16,7 @@ export default defineConfig({ // tsconfig keeps `jsx: preserve` for Next; tests need the automatic runtime. esbuild: { jsx: "automatic" }, test: { + pool: "forks", projects: [ { extends: true, From 4c261a1cb2468f9874497877469291d26b4c5d6e Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 03:43:42 +0000 Subject: [PATCH 13/17] characterize list and infinite cache collisions by rendering the real hooks --- .../src/lib/query-key-collisions.test.tsx | 127 ++++++++++++++++++ apps/admin/src/lib/query.test.ts | 24 +--- 2 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 apps/admin/src/lib/query-key-collisions.test.tsx diff --git a/apps/admin/src/lib/query-key-collisions.test.tsx b/apps/admin/src/lib/query-key-collisions.test.tsx new file mode 100644 index 0000000..9cadb47 --- /dev/null +++ b/apps/admin/src/lib/query-key-collisions.test.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from "react" +import { QueryClientProvider, type QueryClient } from "@tanstack/react-query" +import { renderHook, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import { queryKeys } from "@/lib/query" +import { apiMock } from "@/test/api-mock" +import { makeTestQueryClient } from "@/test/render" +import { useEventList, useEventListInfinite } from "@/features/events/use-events" +import { useInboxList, useInboxListInfinite } from "@/features/inbox/use-inbox" +import { useMailList, useMailListInfinite } from "@/features/mail/use-mail" +import { useModerationList, useModerationListInfinite } from "@/features/moderation/use-moderation" +import { useReportList, useReportListInfinite } from "@/features/reports/use-reports" +import { useUserList, useUserListInfinite } from "@/features/users/use-users" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +const PARAMS = { q: "park", limit: 25 } +const EMPTY_PAGE = { items: [], nextCursor: null } + +type ListMethod = + | "listAdminEvents" + | "listInbox" + | "listMail" + | "listModeration" + | "listAdminReports" + | "listAdminUsers" + +async function mountBoth(method: ListMethod, useBoth: () => unknown): Promise { + apiMock[method].mockResolvedValue(EMPTY_PAGE) + const client = makeTestQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + renderHook(useBoth, { wrapper }) + await waitFor(() => { + expect(apiMock[method]).toHaveBeenCalled() + expect(client.isFetching()).toBe(0) + }) + return client +} + +describe("list and infinite hooks mounted with equal params", () => { + it.each([ + [ + "events", + "listAdminEvents", + () => { + useEventList(PARAMS) + useEventListInfinite(PARAMS) + }, + queryKeys.events.list(PARAMS), + ], + [ + "inbox", + "listInbox", + () => { + useInboxList(PARAMS) + useInboxListInfinite(PARAMS) + }, + queryKeys.inbox.list(PARAMS), + ], + [ + "mail", + "listMail", + () => { + useMailList(PARAMS) + useMailListInfinite(PARAMS) + }, + queryKeys.mail.list(PARAMS), + ], + [ + "moderation", + "listModeration", + () => { + useModerationList(PARAMS) + useModerationListInfinite(PARAMS) + }, + queryKeys.moderation.list(PARAMS), + ], + ] as const)( + "%s list hook shares one cache entry with its infinite hook (current behavior)", + async (_name, method, useBoth, key) => { + const client = await mountBoth(method, useBoth) + expect(client.getQueryCache().findAll({ queryKey: key, exact: true })).toHaveLength(1) + expect(client.getQueryCache().findAll({ queryKey: key.slice(0, 2) })).toHaveLength(1) + }, + ) + + it.each([ + [ + "reports", + "listAdminReports", + () => { + useReportList(PARAMS) + useReportListInfinite(PARAMS) + }, + queryKeys.reports.all, + queryKeys.reports.page(PARAMS), + queryKeys.reports.list(PARAMS), + ], + [ + "users", + "listAdminUsers", + () => { + useUserList(PARAMS) + useUserListInfinite(PARAMS) + }, + queryKeys.users.all, + queryKeys.users.page(PARAMS), + queryKeys.users.list(PARAMS), + ], + ] as const)( + "%s list hook and infinite hook keep separate cache entries", + async (_name, method, useBoth, all, pageKey, listKey) => { + const client = await mountBoth(method, useBoth) + const cache = client.getQueryCache() + expect(cache.findAll({ queryKey: all })).toHaveLength(2) + expect(cache.findAll({ queryKey: pageKey, exact: true })).toHaveLength(1) + expect(cache.findAll({ queryKey: listKey, exact: true })).toHaveLength(1) + }, + ) +}) diff --git a/apps/admin/src/lib/query.test.ts b/apps/admin/src/lib/query.test.ts index 18041fd..b167122 100644 --- a/apps/admin/src/lib/query.test.ts +++ b/apps/admin/src/lib/query.test.ts @@ -91,7 +91,7 @@ describe("makeQueryClient mutation defaults", () => { expect(useUiStore.getState().toast?.text).toBe("Something went wrong. Please try again.") }) - it("toasts the INTERNAL \"Unknown error\" message for a non-error rejection", async () => { + it("toasts \"Unknown error\" rather than the fallback for a non-error rejection (current behavior)", async () => { await failMutation(makeQueryClient(), "boom") expect(useUiStore.getState().toast?.text).toBe("Unknown error") }) @@ -274,32 +274,16 @@ describe("queryKeys id + params factories", () => { }) }) -describe("list vs infinite key sharing", () => { +describe("page vs list keys", () => { const params = { status: "open" } - // The list and infinite hooks for these domains both call `.list(params)`, so a flat page - // and an InfiniteData payload land in one cache entry when both hooks mount with equal params. - it.each([ - ["events", queryKeys.events.list], - ["inbox", queryKeys.inbox.list], - ["mail", queryKeys.mail.list], - ["moderation", queryKeys.moderation.list], - ] as const)( - "%s list hook shares a key with its infinite hook (known collision)", - (_name, listFactory) => { - const flatKey = listFactory(params) - const infiniteKey = listFactory(params) - expect(flatKey).toEqual(infiniteKey) - }, - ) - - it("reports list hook uses reports.page, apart from the infinite reports.list", () => { + it("reports.page and reports.list are distinct keys", () => { expect(queryKeys.reports.page(params)).not.toEqual(queryKeys.reports.list(params)) expect(queryKeys.reports.page(params)).toEqual(["admin", "reports", "page", params]) expect(queryKeys.reports.list(params)).toEqual(["admin", "reports", "list", params]) }) - it("users list hook uses users.page, apart from the infinite users.list", () => { + it("users.page and users.list are distinct keys", () => { expect(queryKeys.users.page(params)).not.toEqual(queryKeys.users.list(params)) expect(queryKeys.users.page(params)).toEqual(["admin", "users", "page", params]) expect(queryKeys.users.list(params)).toEqual(["admin", "users", "list", params]) From 1b84906bc3103e439f1502d5858de812401482e8 Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 03:43:42 +0000 Subject: [PATCH 14/17] test: assert home tile keyboard behavior instead of react internals --- .../features/home/home-accessibility.test.tsx | 153 ++++++++---------- 1 file changed, 71 insertions(+), 82 deletions(-) diff --git a/apps/admin/src/features/home/home-accessibility.test.tsx b/apps/admin/src/features/home/home-accessibility.test.tsx index c8acff6..19fe766 100644 --- a/apps/admin/src/features/home/home-accessibility.test.tsx +++ b/apps/admin/src/features/home/home-accessibility.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from "@testing-library/react" +import { waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import type { AdminEventListItemDTO, @@ -228,13 +228,12 @@ function tilesOf(container: HTMLElement): HTMLElement[] { return [...container.querySelectorAll(".stile")] } -/** - * React keeps each host element's current props on an expando property. Reading it is the only way a - * rendered DOM can show that no custom key handler was attached to a control. - */ -function reactPropsOf(el: Element): Record { - const key = Object.keys(el).find((k) => k.startsWith("__reactProps$")) - return key ? ((el as unknown as Record>)[key] ?? {}) : {} +const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]" +const HEAD_AND_FOOT = 2 +const TILE_CONTROLS = (PREVIEW_TILES + 1) * HEAD_AND_FOOT + PREVIEW_TILES * ROWS_PER_TILE + +function controlsOf(tile: HTMLElement): HTMLElement[] { + return [...tile.querySelectorAll(FOCUSABLE)] } beforeEach(() => { @@ -246,102 +245,92 @@ afterEach(() => { vi.restoreAllMocks() }) -describe("home tile accessibility structure", () => { - it("renders every section tile with a native head and foot button as sibling controls", async () => { +describe("home tile accessibility", () => { + it("makes every focusable element in a tile a named native type=button with no focusable or div inside", async () => { const container = await renderLoadedHome() const tiles = tilesOf(container) expect(tiles).toHaveLength(PREVIEW_TILES + 1) - for (const tile of tiles) { - const children = [...tile.children] - const head = children[0] - const foot = children[children.length - 1] - - expect(head?.tagName).toBe("BUTTON") - expect(head).toHaveAttribute("type", "button") - expect(head).toHaveClass("stile-head") - - expect(foot?.tagName).toBe("BUTTON") - expect(foot).toHaveAttribute("type", "button") - expect(foot).toHaveClass("stile-foot", "opens") - - expect(tile.tagName).not.toBe("BUTTON") - expect(tile).not.toHaveAttribute("role") - expect(tile.querySelectorAll(".stile-head")).toHaveLength(1) - expect(tile.querySelectorAll(".stile-foot")).toHaveLength(1) + const controls = tiles.flatMap(controlsOf) + expect(controls).toHaveLength(TILE_CONTROLS) + for (const control of controls) { + expect(control.tagName).toBe("BUTTON") + expect(control).toHaveAttribute("type", "button") + expect(control).toHaveAccessibleName() + expect(control.querySelector(FOCUSABLE)).toBeNull() + expect(control.querySelector("div")).toBeNull() } }) - it("renders every preview row as a native type=button with the slr class", async () => { + it("sets no role=button and no tabindex on a tile or anything inside it", async () => { const container = await renderLoadedHome() - const rows = [...container.querySelectorAll(".slr")] - expect(rows).toHaveLength(PREVIEW_TILES * ROWS_PER_TILE) - for (const row of rows) { - expect(row.tagName).toBe("BUTTON") - expect(row).toHaveAttribute("type", "button") - expect(row).toHaveClass("slr") - expect(row.closest(".stile-list")).not.toBeNull() - expect(row.parentElement?.closest("button")).toBeNull() - } - }) - - it("uses only native buttons as the tile controls", async () => { - const container = await renderLoadedHome() - - for (const tile of tilesOf(container)) { - for (const button of tile.querySelectorAll("button")) { - expect( - ["stile-head", "stile-foot", "slr"].some((cls) => button.classList.contains(cls)), - ).toBe(true) + const tiles = tilesOf(container) + expect(tiles).toHaveLength(PREVIEW_TILES + 1) + for (const tile of tiles) { + for (const el of [tile, ...tile.querySelectorAll("*")]) { + expect(el).not.toHaveAttribute("role", "button") + expect(el).not.toHaveAttribute("tabindex") } } }) - it("sets no role=button, tabindex or custom key handler anywhere inside a tile", async () => { + it("visits each tile control exactly once, in DOM order, when tabbing through the page", async () => { const container = await renderLoadedHome() + const kb = userEvent.setup() - for (const tile of tilesOf(container)) { - // Guards against a vacuous pass if React ever stops exposing props on the element. - expect(reactPropsOf(tile.querySelector(".stile-head")!)).toHaveProperty("onClick") - expect(tile.querySelectorAll('[role="button"]')).toHaveLength(0) - expect(tile.querySelectorAll("[tabindex]")).toHaveLength(0) - for (const el of [tile, ...tile.querySelectorAll("*")]) { - const props = reactPropsOf(el) - expect(props).not.toHaveProperty("onKeyDown") - expect(props).not.toHaveProperty("onKeyUp") - expect(props).not.toHaveProperty("onKeyPress") - expect(props).not.toHaveProperty("tabIndex") - expect(props).not.toHaveProperty("role") - } + const visited: Element[] = [] + for (let i = 0; i < 500; i++) { + await kb.tab() + const active = document.activeElement + if (!active || active === document.body || visited.includes(active)) break + visited.push(active) } - }) - - it("puts no div and no nested interactive element inside any tile button", async () => { - const container = await renderLoadedHome() - const buttons = tilesOf(container).flatMap((tile) => [...tile.querySelectorAll("button")]) - expect(buttons).toHaveLength((PREVIEW_TILES + 1) * 2 + PREVIEW_TILES * ROWS_PER_TILE) - for (const button of buttons) { - expect(button.querySelector("div")).toBeNull() - expect(button.querySelector("button, a, input, select, textarea, [tabindex], [role]")).toBeNull() + const tiles = tilesOf(container) + expect(tiles).toHaveLength(PREVIEW_TILES + 1) + for (const tile of tiles) { + const expected = controlsOf(tile) + expect(expected.length).toBeGreaterThanOrEqual(HEAD_AND_FOOT) + expect(visited.filter((el) => tile.contains(el))).toEqual(expected) } }) - it("activates tile controls from the keyboard through native button behavior", async () => { + it("activates every head, foot and row control exactly once on Enter and on Space", async () => { const container = await renderLoadedHome() const kb = userEvent.setup() + const scrollTo = vi.mocked(window.scrollTo) + const pushState = vi.spyOn(window.history, "pushState") + + async function press(control: HTMLElement, key: string): Promise { + window.history.replaceState(null, "", "/") + scrollTo.mockClear() + pushState.mockClear() + control.focus() + expect(control).toHaveFocus() + await kb.keyboard(key) + expect(scrollTo).toHaveBeenCalledTimes(1) + expect(pushState).toHaveBeenCalledTimes(1) + return window.location.hash + } - const row = screen.getByRole("button", { name: /Place 1/ }) - row.focus() - await kb.keyboard("{Enter}") - expect(window.location.hash).toBe("#/discovery/060001") - - const reportsHead = tilesOf(container) - .map((tile) => tile.querySelector(".stile-head")) - .find((head) => head?.textContent?.startsWith("Reports")) - reportsHead?.focus() - await kb.keyboard(" ") - expect(window.location.hash).toBe("#/reports") + let activated = 0 + for (const tile of tilesOf(container)) { + const controls = controlsOf(tile) + const head = controls[0]! + const foot = controls[controls.length - 1]! + const tileHash = await press(head, "{Enter}") + expect(tileHash).toMatch(/^#\/[a-z]+$/) + + for (const control of controls) { + const onEnter = await press(control, "{Enter}") + const onSpace = await press(control, " ") + expect(onSpace).toBe(onEnter) + if (control === head || control === foot) expect(onEnter).toBe(tileHash) + else expect(onEnter.startsWith(`${tileHash}/`)).toBe(true) + activated++ + } + } + expect(activated).toBe(TILE_CONTROLS) }) }) From 6c6e41feee9ac734b12a40755d2fbd17ed400ccd Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 03:43:42 +0000 Subject: [PATCH 15/17] flippable current-behavior names, deterministic debounce tests, restored stubs --- .../src/components/map/leaflet-map.test.tsx | 2 +- .../shared/modal-accessibility.test.tsx | 8 ++-- .../analytics/analytics-page.test.tsx | 13 +++++- .../discovery/discovery-page.test.tsx | 4 +- .../src/features/events/events-page.test.tsx | 8 +--- .../src/features/home/home-page.test.tsx | 3 +- .../src/features/hosts/hosts-page.test.tsx | 30 ++++++------- .../src/features/mail/mail-page.test.tsx | 12 +---- .../moderation/moderation-page.test.tsx | 8 +--- .../src/features/orgs/orgs-page.test.tsx | 8 ++-- .../src/features/pages/pages-page.test.tsx | 26 ++++++----- .../features/reports/reports-page.test.tsx | 6 +-- .../src/features/users/users-page.test.tsx | 8 +--- apps/admin/src/hooks/use-admin-auth.test.tsx | 4 +- apps/admin/src/lib/dates.test.ts | 45 ++++++------------- apps/admin/src/store/ui-store.test.ts | 18 +++++--- 16 files changed, 88 insertions(+), 115 deletions(-) diff --git a/apps/admin/src/components/map/leaflet-map.test.tsx b/apps/admin/src/components/map/leaflet-map.test.tsx index 78dc33e..f414941 100644 --- a/apps/admin/src/components/map/leaflet-map.test.tsx +++ b/apps/admin/src/components/map/leaflet-map.test.tsx @@ -93,7 +93,7 @@ describe("LeafletMap", () => { expect(tip).toBeNull() }) - it("renders markup in a pin title (current behavior: tooltip string is parsed as HTML)", () => { + it("parses a pin title in the tooltip as HTML markup (current behavior)", () => { const pin: MapPin = { id: "h", lat: 34, lng: -118, tip: "x", place: "y" } const { container } = render() diff --git a/apps/admin/src/components/shared/modal-accessibility.test.tsx b/apps/admin/src/components/shared/modal-accessibility.test.tsx index 0cda6ff..b6e3461 100644 --- a/apps/admin/src/components/shared/modal-accessibility.test.tsx +++ b/apps/admin/src/components/shared/modal-accessibility.test.tsx @@ -55,7 +55,6 @@ describe("LightboxHost", () => { const frame = screen.getByRole("dialog", { name: "Pothole on Main St" }) expect(frame).toHaveAttribute("aria-modal", "true") expect(frame).not.toHaveAttribute("aria-hidden") - expect(frame).toHaveClass("lightbox") expect(frame.matches(ESCAPE_OWNER_SELECTOR)).toBe(true) expect(hasEscapeOwner(document)).toBe(true) }) @@ -101,11 +100,9 @@ describe("LightboxHost", () => { await user.click(opener) const img = screen.getByRole("img", { name: "Pothole on Main St" }) - expect(img).toHaveClass("pending") expect(screen.getByRole("status")).toHaveTextContent("Loading photo...") fireEvent.load(img) - expect(img).not.toHaveClass("pending") expect(screen.queryByRole("status")).toBeNull() }) @@ -130,7 +127,8 @@ describe("LightboxHost", () => { await user.click(refresh) expect(refreshes).toBe(1) expect(screen.queryByRole("button", { name: "Refresh photo" })).toBeNull() - expect(screen.getByRole("img", { name: "Pothole on Main St" })).toHaveClass("pending") + expect(screen.getByRole("img", { name: "Pothole on Main St" })).toBeInTheDocument() + expect(screen.getByRole("status")).toHaveTextContent("Loading photo...") }) }) @@ -225,7 +223,7 @@ describe("DialogHost", () => { // The textarea's autoFocus runs in React's commit phase, before useModalFocus's effect records the // element to restore, so the hook captures the textarea itself and has nothing live to return to. - it("drops focus to the body when a prompt closes (current behavior: prompt does not restore focus to the opener)", async () => { + it("drops focus to the body instead of restoring the opener when a prompt closes (current behavior)", async () => { const { user, opener } = renderDialog(() => { void promptDialog({ title: "Reason" }) }) diff --git a/apps/admin/src/features/analytics/analytics-page.test.tsx b/apps/admin/src/features/analytics/analytics-page.test.tsx index 601b6ee..8b5cc20 100644 --- a/apps/admin/src/features/analytics/analytics-page.test.tsx +++ b/apps/admin/src/features/analytics/analytics-page.test.tsx @@ -28,6 +28,7 @@ vi.mock("@/lib/api", async (importOriginal) => { afterEach(() => { useUiStore.setState({ toast: null }) vi.restoreAllMocks() + vi.unstubAllGlobals() }) const ANALYTICS_METHODS = [ @@ -447,8 +448,15 @@ describe("AnalyticsPage", () => { blobs.push(blob) return "blob:analytics" }) - Object.defineProperty(URL, "createObjectURL", { configurable: true, writable: true, value: createObjectURL }) - Object.defineProperty(URL, "revokeObjectURL", { configurable: true, writable: true, value: vi.fn() }) + const revokeObjectURL = vi.fn() + // jsdom implements neither static, so there is nothing on the real URL for vi.spyOn to wrap. + vi.stubGlobal( + "URL", + class extends URL { + static override createObjectURL = createObjectURL + static override revokeObjectURL = revokeObjectURL + }, + ) const downloads: string[] = [] vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this.download) @@ -481,5 +489,6 @@ describe("AnalyticsPage", () => { ].join("\n"), ) expect(await screen.findByText("Analytics exported · civfix-analytics.csv")).toBeInTheDocument() + await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith("blob:analytics")) }) }) diff --git a/apps/admin/src/features/discovery/discovery-page.test.tsx b/apps/admin/src/features/discovery/discovery-page.test.tsx index 0637ac0..770e3f0 100644 --- a/apps/admin/src/features/discovery/discovery-page.test.tsx +++ b/apps/admin/src/features/discovery/discovery-page.test.tsx @@ -23,7 +23,6 @@ vi.mock("@/components/map/boundary-map", () => ({ BoundaryMap: () =>
, })) - const DAY_MS = 24 * 60 * 60 * 1000 function jurisdiction(overrides: Partial = {}): JurisdictionDirectoryDTO { @@ -276,7 +275,6 @@ describe("DiscoveryPage", () => { await waitFor(() => expect(listCalls()).toContainEqual({ filter: "all", sort: "reports", layer: "county", limit: 50 }), ) - // The needs-mapping chip count follows the type filter too. await waitFor(() => expect(listCalls()).toContainEqual({ filter: "needs_mapping", @@ -318,7 +316,7 @@ describe("DiscoveryPage", () => { expect(listCalls()[0]).toEqual({ filter: "needs_mapping", sort: "oldest", limit: 50 }) }) - it("holds an empty detail pane when the focused jurisdiction is not in the loaded page", async () => { + it("holds an empty detail pane when the focused jurisdiction is not in the loaded page (current behavior)", async () => { mockDetailQueries() apiMock.listJurisdictions.mockResolvedValue(page([LA])) renderWithQuery() diff --git a/apps/admin/src/features/events/events-page.test.tsx b/apps/admin/src/features/events/events-page.test.tsx index 0c3a3d4..a1adf5f 100644 --- a/apps/admin/src/features/events/events-page.test.tsx +++ b/apps/admin/src/features/events/events-page.test.tsx @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" import { renderWithQuery } from "@/test/render" +import { detailCard } from "@/test/panes" import { EventsPage } from "@/features/events/events-page" vi.mock("@/lib/api", async (importOriginal) => { @@ -22,7 +23,6 @@ vi.mock("@/components/map/leaflet-map", () => ({ LeafletMap: () =>
, })) - function listItem(over: Partial & { id: string; title: string }) { return { status: "upcoming", @@ -85,10 +85,6 @@ function listCard() { return screen.getByRole("heading", { level: 3, name: "Events" }).closest("section") as HTMLElement } -function detailCard() { - return document.querySelector(".md-detail-card") as HTMLElement -} - describe("EventsPage", () => { it("shows the loading state while the first page is in flight", () => { apiMock.listAdminEvents.mockReturnValue(new Promise(() => {})) @@ -226,7 +222,7 @@ describe("EventsPage", () => { expect(within(detailCard()).queryByRole("heading", { name: "Beach sweep" })).not.toBeInTheDocument() }) - it("replaces a deep-linked focusId that is not on the first page with the first row (auto-select override)", async () => { + it("replaces a deep-linked focusId that is not on the first page with the first row (current behavior)", async () => { const OTHER = listItem({ id: "cccc3333-0000-4000-8000-000000000003", title: "Creek haul" }) apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK])) mockDetails(BEACH, PARK, OTHER) diff --git a/apps/admin/src/features/home/home-page.test.tsx b/apps/admin/src/features/home/home-page.test.tsx index 9256543..a572cf3 100644 --- a/apps/admin/src/features/home/home-page.test.tsx +++ b/apps/admin/src/features/home/home-page.test.tsx @@ -20,7 +20,7 @@ import { HomePage } from "@/features/home/home-page" // the environment before the page module loads. const COMMIT = vi.hoisted(() => { const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01" - process.env.NEXT_PUBLIC_COMMIT_SHA = sha + vi.stubEnv("NEXT_PUBLIC_COMMIT_SHA", sha) return sha }) @@ -248,6 +248,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllEnvs() }) describe("HomePage", () => { diff --git a/apps/admin/src/features/hosts/hosts-page.test.tsx b/apps/admin/src/features/hosts/hosts-page.test.tsx index b604be4..82d17c4 100644 --- a/apps/admin/src/features/hosts/hosts-page.test.tsx +++ b/apps/admin/src/features/hosts/hosts-page.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor, within } from "@testing-library/react" +import { act, screen, waitFor, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import type { AdminBroadcastListItemDTO, @@ -6,12 +6,13 @@ import type { AdminHostListItemDTO, AdminHostListResponse, } from "@civfix/shared" -import { afterEach, describe, expect, it, vi } from "vitest" +import { describe, expect, it, onTestFinished, vi } from "vitest" import { DialogHost } from "@/components/shared/dialog" import type * as ApiModule from "@/lib/api" import { HostsPage } from "@/features/hosts/hosts-page" import { apiMock } from "@/test/api-mock" +import { startFakeTimersWithUser } from "@/test/fake-timers" import { renderWithQuery } from "@/test/render" vi.mock("@/lib/api", async (importOriginal) => { @@ -19,10 +20,6 @@ vi.mock("@/lib/api", async (importOriginal) => { return { ...(await importOriginal()), api: apiMock } }) -afterEach(() => { - vi.useRealTimers() -}) - function actor(id: string, name: string, handle: string) { return { id, name, handle, joined: "2025-01-01T00:00:00.000Z" } } @@ -240,6 +237,8 @@ describe("HostsPage detail pane", () => { apiMock.adminListBroadcasts.mockResolvedValue( broadcasts([broadcast({ id: "b-1", cleanupId: "ev-9", eventTitle: "Beach day" })]), ) + const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}) + onTestFinished(() => scrollTo.mockRestore()) renderWithQuery() const links = await within(detailSection()).findAllByRole("button", { name: "Beach day" }) @@ -249,6 +248,7 @@ describe("HostsPage detail pane", () => { await userEvent.click(link) expect(window.location.hash).toBe("#/events/ev-9") } + expect(scrollTo).toHaveBeenCalledTimes(2) }) it("suspends messaging with the typed reason", async () => { @@ -304,21 +304,21 @@ describe("HostsPage filters, search and paging", () => { }) it("debounces the search into a trimmed q param", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) apiMock.adminListHosts.mockResolvedValue(page([])) renderWithQuery() await screen.findByText("No hosts here") + const user = startFakeTimersWithUser() - fireEvent.change(screen.getByPlaceholderText(/Search host name or handle/), { - target: { value: " ada " }, + await user.type(screen.getByPlaceholderText(/Search host name or handle/), " ada ") + await act(async () => { + vi.advanceTimersByTime(249) }) - vi.advanceTimersByTime(100) - expect(apiMock.adminListHosts).not.toHaveBeenCalledWith(expect.objectContaining({ q: "ada" })) + expect(apiMock.adminListHosts).not.toHaveBeenCalledWith(expect.objectContaining({ q: expect.any(String) })) - vi.advanceTimersByTime(200) - await waitFor(() => - expect(apiMock.adminListHosts).toHaveBeenLastCalledWith({ q: "ada", windowDays: 30 }), - ) + await act(async () => { + vi.advanceTimersByTime(1) + }) + expect(apiMock.adminListHosts).toHaveBeenLastCalledWith({ q: "ada", windowDays: 30 }) }) it("loads the next page of hosts with the cursor", async () => { diff --git a/apps/admin/src/features/mail/mail-page.test.tsx b/apps/admin/src/features/mail/mail-page.test.tsx index 60094ea..baf4285 100644 --- a/apps/admin/src/features/mail/mail-page.test.tsx +++ b/apps/admin/src/features/mail/mail-page.test.tsx @@ -15,6 +15,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" import { renderWithQuery } from "@/test/render" +import { detailCard, listCard } from "@/test/panes" import { MailPage } from "@/features/mail/mail-page" vi.mock("@/lib/api", async (importOriginal) => { @@ -22,7 +23,6 @@ vi.mock("@/lib/api", async (importOriginal) => { return { ...(await importOriginal()), api: apiMock } }) - const TS = "2026-09-20T15:00:00.000Z" function thread(over: Partial & { id: string; subject: string }) { @@ -147,14 +147,6 @@ function mockChrome({ inbox = inboxPage([]) }: { inbox?: InboxListResponse } = { apiMock.listInbox.mockResolvedValue(inbox) } -function listCard() { - return document.querySelector(".md-list") as HTMLElement -} - -function detailCard() { - return document.querySelector(".md-detail-card") as HTMLElement -} - describe("MailPage outreach", () => { it("starts on the Outreach folder with the stats strip", async () => { mockChrome() @@ -312,7 +304,7 @@ describe("MailPage outreach", () => { expect(within(detailCard()).queryByText("Body of Pothole on 5th Ave")).not.toBeInTheDocument() }) - it("replaces a deep-linked thread focusId that is not on the first page with the first row (auto-select override)", async () => { + it("replaces a deep-linked thread focusId that is not on the first page with the first row (current behavior)", async () => { const OLD = thread({ id: "t-9", subject: "Old thread" }) mockChrome() apiMock.listMail.mockResolvedValue(mailPage([POTHOLE, GRAFFITI])) diff --git a/apps/admin/src/features/moderation/moderation-page.test.tsx b/apps/admin/src/features/moderation/moderation-page.test.tsx index fbdb173..fb30e92 100644 --- a/apps/admin/src/features/moderation/moderation-page.test.tsx +++ b/apps/admin/src/features/moderation/moderation-page.test.tsx @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" import { renderWithQuery } from "@/test/render" +import { detailCard } from "@/test/panes" import { ModerationPage } from "@/features/moderation/moderation-page" vi.mock("@/lib/api", async (importOriginal) => { @@ -19,7 +20,6 @@ vi.mock("@/lib/api", async (importOriginal) => { return { ...(await importOriginal()), api: apiMock } }) - function modItem(over: Partial & { id: string; flag: string }) { return { reporter: "Dana Cho", @@ -138,10 +138,6 @@ function listCard(heading: string) { return screen.getByRole("heading", { level: 3, name: heading }).closest("section") as HTMLElement } -function detailCard() { - return document.querySelector(".md-detail-card") as HTMLElement -} - async function openGovClaims() { await userEvent.click(screen.getByRole("radio", { name: "Gov claims" })) } @@ -275,7 +271,7 @@ describe("ModerationPage queue", () => { expect(within(detailCard()).queryByRole("heading", { name: "MOD-101" })).not.toBeInTheDocument() }) - it("replaces a deep-linked focusId that is not on the first page with the first row (auto-select override)", async () => { + it("replaces a deep-linked focusId that is not on the first page with the first row (current behavior)", async () => { const OTHER = modItem({ id: "mod-9", flag: "MOD-909" }) apiMock.listModeration.mockResolvedValue(modPage([COMMENT_REPORT, APPEAL])) mockModDetails(COMMENT_REPORT, APPEAL, OTHER) diff --git a/apps/admin/src/features/orgs/orgs-page.test.tsx b/apps/admin/src/features/orgs/orgs-page.test.tsx index 8fe8a60..c3ba19a 100644 --- a/apps/admin/src/features/orgs/orgs-page.test.tsx +++ b/apps/admin/src/features/orgs/orgs-page.test.tsx @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" import { renderWithQuery } from "@/test/render" +import { queueRowOf } from "@/test/panes" import { OrgsPage } from "@/features/orgs/orgs-page" vi.mock("@/lib/api", async (importOriginal) => { @@ -18,7 +19,6 @@ vi.mock("@/lib/api", async (importOriginal) => { return { ...(await importOriginal()), api: apiMock } }) - const OWNER = { id: "u-owner", name: "Rosa Park", handle: "@rosa", joined: "2025-01-01" } function makeOrg(overrides: Partial = {}): AdminOrgDTO { @@ -125,14 +125,14 @@ describe("OrgsPage list states", () => { mockOrgDetails(RIVER, PARK) renderWithQuery() - const riverRow = (await screen.findByText("/river-keepers")).closest(".qrow") as HTMLElement + const riverRow = queueRowOf(await screen.findByText("/river-keepers")) expect(riverRow).toHaveTextContent("River Keepers") expect(riverRow).toHaveTextContent("3 members") expect(riverRow).toHaveTextContent("1 event") expect(riverRow).toHaveTextContent("Rosa Park") expect(riverRow).toHaveTextContent("Verified") - const parkRow = screen.getByText("/park-friends").closest(".qrow") as HTMLElement + const parkRow = queueRowOf(screen.getByText("/park-friends")) expect(parkRow).toHaveTextContent("Park Friends") expect(parkRow).toHaveTextContent("1 member") expect(parkRow).toHaveTextContent("0 events") @@ -315,7 +315,7 @@ describe("OrgsPage deep links and detail tabs", () => { expect(apiMock.adminGetOrg).toHaveBeenCalledWith({ id: "org-far" }) expect(apiMock.adminGetOrg).not.toHaveBeenCalledWith({ id: "org-1" }) expect(screen.queryByText("/far-away")).not.toBeInTheDocument() - expect(screen.getByText("/river-keepers").closest(".qrow")).not.toHaveClass("selected") + expect(queueRowOf(screen.getByText("/river-keepers"))).not.toHaveClass("selected") }) it("an unknown tab in the focusId falls back to the Profile tab", async () => { diff --git a/apps/admin/src/features/pages/pages-page.test.tsx b/apps/admin/src/features/pages/pages-page.test.tsx index 0fb49d8..0a6fa60 100644 --- a/apps/admin/src/features/pages/pages-page.test.tsx +++ b/apps/admin/src/features/pages/pages-page.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from "@testing-library/react" +import { act, screen, waitFor, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { describe, expect, it, vi } from "vitest" import type { @@ -9,6 +9,7 @@ import type { import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" +import { startFakeTimersWithUser } from "@/test/fake-timers" import { renderWithQuery } from "@/test/render" import { PagesPage } from "@/features/pages/pages-page" @@ -17,7 +18,6 @@ vi.mock("@/lib/api", async (importOriginal) => { return { ...(await importOriginal()), api: apiMock } }) - const ECHO_ID = "11111111-1111-4111-8111-111111111111" const RIVER_ID = "22222222-2222-4222-8222-222222222222" const LAKE_ID = "33333333-3333-4333-8333-333333333333" @@ -286,17 +286,21 @@ describe("PagesPage paging, filters and search", () => { apiMock.adminListEventPages.mockResolvedValue(listPage([])) renderWithQuery() await screen.findByText("No pages loaded") + const user = startFakeTimersWithUser() - await userEvent.type(screen.getByPlaceholderText(/Search slug or title/), " echo ") - - await waitFor(() => - expect(apiMock.adminListEventPages).toHaveBeenLastCalledWith({ - status: "published", - q: "echo", - }), + await user.type(screen.getByPlaceholderText(/Search slug or title/), " echo ") + await act(async () => { + vi.advanceTimersByTime(249) + }) + expect(apiMock.adminListEventPages).not.toHaveBeenCalledWith( + expect.objectContaining({ q: expect.any(String) }), ) - // Debounced: the keystrokes before the pause never reach the API. - expect(apiMock.adminListEventPages).not.toHaveBeenCalledWith({ status: "published", q: "e" }) + + await act(async () => { + vi.advanceTimersByTime(1) + }) + expect(apiMock.adminListEventPages).toHaveBeenLastCalledWith({ status: "published", q: "echo" }) + expect(apiMock.adminListEventPages).toHaveBeenCalledWith(expect.objectContaining({ q: expect.any(String) })) }) }) diff --git a/apps/admin/src/features/reports/reports-page.test.tsx b/apps/admin/src/features/reports/reports-page.test.tsx index fe093da..19a42b2 100644 --- a/apps/admin/src/features/reports/reports-page.test.tsx +++ b/apps/admin/src/features/reports/reports-page.test.tsx @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" import { renderWithQuery } from "@/test/render" +import { detailCard } from "@/test/panes" import { ReportsPage } from "@/features/reports/reports-page" vi.mock("@/lib/api", async (importOriginal) => { @@ -23,7 +24,6 @@ vi.mock("@/components/map/leaflet-map", () => ({ LeafletMap: () =>
, })) - function listItem(over: Partial & { id: string; title: string }) { return { category: "trash", @@ -100,10 +100,6 @@ function listCard() { return screen.getByRole("heading", { level: 3, name: "Reports" }).closest("section") as HTMLElement } -function detailCard() { - return document.querySelector(".md-detail-card") as HTMLElement -} - describe("ReportsPage", () => { it("shows the loading state while the first page is in flight", () => { apiMock.listAdminReports.mockReturnValue(new Promise(() => {})) diff --git a/apps/admin/src/features/users/users-page.test.tsx b/apps/admin/src/features/users/users-page.test.tsx index 019a4f6..37fc7cd 100644 --- a/apps/admin/src/features/users/users-page.test.tsx +++ b/apps/admin/src/features/users/users-page.test.tsx @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" import { renderWithQuery } from "@/test/render" +import { detailCard } from "@/test/panes" import { UsersPage } from "@/features/users/users-page" vi.mock("@/lib/api", async (importOriginal) => { @@ -18,7 +19,6 @@ vi.mock("@/lib/api", async (importOriginal) => { return { ...(await importOriginal()), api: apiMock } }) - function listItem(over: Partial & { id: string; name: string }) { return { handle: "@handle", @@ -91,10 +91,6 @@ function accountsList() { return screen.getByRole("heading", { name: "Accounts" }).closest("section") as HTMLElement } -function detailCard() { - return document.querySelector(".md-detail-card") as HTMLElement -} - describe("UsersPage", () => { it("shows the loading state while the first page is in flight", () => { apiMock.listAdminUsers.mockReturnValue(new Promise(() => {})) @@ -234,7 +230,7 @@ describe("UsersPage", () => { expect(within(detailCard()).queryByRole("heading", { name: "Ana Ruiz" })).not.toBeInTheDocument() }) - it("replaces a deep-linked focusId that is not on the first page with the first row (auto-select override)", async () => { + it("replaces a deep-linked focusId that is not on the first page with the first row (current behavior)", async () => { const CARA = listItem({ id: "u-cara", name: "Cara Lind" }) apiMock.listAdminUsers.mockResolvedValue(page([ANA, BEN])) mockDetails(ANA, BEN, CARA) diff --git a/apps/admin/src/hooks/use-admin-auth.test.tsx b/apps/admin/src/hooks/use-admin-auth.test.tsx index 3dbe29a..86c9f5c 100644 --- a/apps/admin/src/hooks/use-admin-auth.test.tsx +++ b/apps/admin/src/hooks/use-admin-auth.test.tsx @@ -12,10 +12,10 @@ import { useAuthStore } from "@/store/auth-store" import { useAdminLogout, useOperatorBootstrap, useOperatorSession } from "./use-admin-auth" // The real client binds window.fetch when @/lib/api first evaluates, so the stub must exist before -// that import for getApiClient() to route through it. +// that import for getApiClient() to route through it; the bound mock outlives the afterEach unstub. const { fetchMock } = vi.hoisted(() => { const fetchMock = vi.fn() - globalThis.fetch = fetchMock + vi.stubGlobal("fetch", fetchMock) return { fetchMock } }) diff --git a/apps/admin/src/lib/dates.test.ts b/apps/admin/src/lib/dates.test.ts index bc5e4a3..bf49a78 100644 --- a/apps/admin/src/lib/dates.test.ts +++ b/apps/admin/src/lib/dates.test.ts @@ -4,33 +4,18 @@ import { formatDate, formatDateTime } from "./dates" const PLACEHOLDER = "\u2014" -// Expected strings are built with the same Intl arguments the code uses so the runner's locale and -// time zone cannot flake the assertions. -function expectedDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) -} - -function expectedDateTime(iso: string): string { - return new Date(iso).toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }) -} - describe("formatDate", () => { it.each([null, undefined, ""])("renders the placeholder for %j", (value) => { expect(formatDate(value)).toBe(PLACEHOLDER) }) - it.each(["2026-09-23T14:05:00.000Z", "2026-01-01", "2024-02-29T23:59:59+05:30"])( - "formats %s as a short local date", - (iso) => { - expect(formatDate(iso)).toBe(expectedDate(iso)) - }, - ) + it.each([ + ["2026-09-23T14:05:00.000Z", "Sep 23, 2026"], + ["2026-01-01", "Jan 1, 2026"], + ["2024-02-29T23:59:59+05:30", "Feb 29, 2024"], + ])("formats %s as a short local date", (iso, expected) => { + expect(formatDate(iso)).toBe(expected) + }) it.each(["not a date", "2026-13-45", " "])("returns an unparseable string %j unchanged", (value) => { expect(formatDate(value)).toBe(value) @@ -42,16 +27,12 @@ describe("formatDateTime", () => { expect(formatDateTime(value)).toBe(PLACEHOLDER) }) - it.each(["2026-09-23T14:05:00.000Z", "2026-01-01T00:00:00Z", "2024-02-29T23:59:59+05:30"])( - "formats %s as a short local date and time", - (iso) => { - expect(formatDateTime(iso)).toBe(expectedDateTime(iso)) - }, - ) - - it("is longer than the date-only form for the same instant", () => { - const iso = "2026-09-23T14:05:00.000Z" - expect(formatDateTime(iso).length).toBeGreaterThan(formatDate(iso).length) + it.each([ + ["2026-09-23T14:05:00.000Z", "Sep 23, 2026, 2:05 PM"], + ["2026-01-01T00:00:00Z", "Jan 1, 2026, 12:00 AM"], + ["2024-02-29T23:59:59+05:30", "Feb 29, 2024, 6:29 PM"], + ])("formats %s as a short local date and time", (iso, expected) => { + expect(formatDateTime(iso)).toBe(expected) }) it.each(["not a date", "2026-13-45", " "])("returns an unparseable string %j unchanged", (value) => { diff --git a/apps/admin/src/store/ui-store.test.ts b/apps/admin/src/store/ui-store.test.ts index 7ebd7c1..af791de 100644 --- a/apps/admin/src/store/ui-store.test.ts +++ b/apps/admin/src/store/ui-store.test.ts @@ -257,12 +257,15 @@ describe("toast", () => { expect(useUiStore.getState().toast).toBeNull() }) - it("shows a toast with an id that increases on every call", async () => { + it("gives every toast a new id, even for the same text", async () => { const { useUiStore } = await loadWithHash("") useUiStore.getState().showToast("Saved") - expect(useUiStore.getState().toast).toEqual({ id: 1, text: "Saved" }) + const first = useUiStore.getState().toast + expect(first?.text).toBe("Saved") useUiStore.getState().showToast("Saved") - expect(useUiStore.getState().toast).toEqual({ id: 2, text: "Saved" }) + const second = useUiStore.getState().toast + expect(second?.text).toBe("Saved") + expect(second?.id).not.toBe(first?.id) }) it("replaces the current toast rather than queueing", async () => { @@ -279,12 +282,15 @@ describe("toast", () => { expect(useUiStore.getState().toast).toBeNull() }) - it("does not reset the toast id sequence on dismiss", async () => { + it("gives a toast shown after a dismiss a new id", async () => { const { useUiStore } = await loadWithHash("") useUiStore.getState().showToast("a") + const dismissedId = useUiStore.getState().toast?.id useUiStore.getState().dismissToast() - useUiStore.getState().showToast("b") - expect(useUiStore.getState().toast).toEqual({ id: 2, text: "b" }) + useUiStore.getState().showToast("a") + const next = useUiStore.getState().toast + expect(next?.text).toBe("a") + expect(next?.id).not.toBe(dismissedId) }) it("leaves page and focus alone", async () => { From 3972c7146f1dc811983b634b2e618a4fd966e63b Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 03:51:41 +0000 Subject: [PATCH 16/17] re-pin mail inbox characterization to the unified feed --- .../src/features/mail/mail-page.test.tsx | 222 ++++++++++++++---- 1 file changed, 175 insertions(+), 47 deletions(-) diff --git a/apps/admin/src/features/mail/mail-page.test.tsx b/apps/admin/src/features/mail/mail-page.test.tsx index baf4285..0b5f7fd 100644 --- a/apps/admin/src/features/mail/mail-page.test.tsx +++ b/apps/admin/src/features/mail/mail-page.test.tsx @@ -4,7 +4,10 @@ import type { GetForwardTemplateDefaultResponse, InboundEmailDTO, InboundEmailListItemDTO, - InboxListResponse, + InboxFeedEmailItemDTO, + InboxFeedItemDTO, + InboxFeedReplyItemDTO, + InboxFeedResponse, MailListResponse, MailStatsResponse, MailThreadDTO, @@ -111,8 +114,84 @@ const PRESS = inboxItem({ unread: false, }) -function inboxPage(items: InboundEmailListItemDTO[], nextCursor: string | null = null) { - return { items, nextCursor } satisfies InboxListResponse +function emailFeedItem(item: InboundEmailListItemDTO) { + return { ...item, source: "email" } satisfies InboxFeedEmailItemDTO +} + +const QUESTION_FEED = emailFeedItem(QUESTION) +const PRESS_FEED = emailFeedItem(PRESS) + +const REPORT_REPLY = { + source: "reply", + id: "m-1", + threadId: "t-1", + reportId: "r-1", + cleanupId: null, + org: "Oakland Public Works", + from: "clerk@oaklandca.gov", + subject: "Re: Pothole on 5th Ave", + preview: "Crew is scheduled for Monday", + ts: TS, + unread: true, + threadStatus: "needs_action", + hasAttachments: false, + authVerdict: "fail", + publication: "withheld", +} satisfies InboxFeedReplyItemDTO + +const EVENT_REPLY = { + source: "reply", + id: "m-2", + threadId: "t-3", + reportId: null, + cleanupId: "c-1", + org: "Parks Department", + from: "parks@oaklandca.gov", + subject: "Re: Lake Merritt cleanup", + preview: "Gloves will be at the boathouse", + ts: TS, + unread: false, + threadStatus: "replied", + hasAttachments: false, + authVerdict: "pass", + publication: null, +} satisfies InboxFeedReplyItemDTO + +const REPORT_REPLY_THREAD = { + ...thread({ + id: "t-1", + subject: "Re: Pothole on 5th Ave", + dir: "in", + from: "clerk@oaklandca.gov", + status: "needs_action", + reportId: "r-1", + }), + messages: [ + { + id: "m-1", + who: "Oakland Public Works", + from: "clerk@oaklandca.gov", + to: "outreach@civfix.org", + dir: "in", + body: "Body of Re: Pothole on 5th Ave", + ts: TS, + attachments: [], + authVerdict: "fail", + publication: "withheld", + }, + ], +} satisfies MailThreadDTO + +function mockThreadDetails(...threads: MailThreadDTO[]) { + apiMock.getMailThread.mockImplementation(async ({ id }: { id: string }) => { + const t = threads.find((x) => x.id === id) + if (!t) throw new Error(`no thread ${id}`) + return t + }) +} + +function feedPage(items: InboxFeedItemDTO[], nextCursor: string | null = null) { + return { items, nextCursor } satisfies InboxFeedResponse } function inboxDetail(item: InboundEmailListItemDTO): InboundEmailDTO { @@ -137,14 +216,14 @@ const STATS = { unread: 2, threads: 17, sent: 40, bounced: 3, failed: 1 } satisf // The page always loads the stats strip, the forward template and both folder lists, so every test // resolves the ones it is not about. -function mockChrome({ inbox = inboxPage([]) }: { inbox?: InboxListResponse } = {}) { +function mockChrome({ feed = feedPage([]) }: { feed?: InboxFeedResponse } = {}) { apiMock.getMailStats.mockResolvedValue(STATS) apiMock.getForwardTemplateDefault.mockResolvedValue({ subjectTemplate: null, bodyTemplate: null, updatedAt: null, } satisfies GetForwardTemplateDefaultResponse) - apiMock.listInbox.mockResolvedValue(inbox) + apiMock.listInboxFeed.mockResolvedValue(feed) } describe("MailPage outreach", () => { @@ -320,8 +399,8 @@ describe("MailPage inbox", () => { await userEvent.click(screen.getByRole("radio", { name: /Inbox/ })) } - it("switches to the Inbox folder with its own chips and no stats strip", async () => { - mockChrome({ inbox: inboxPage([]) }) + it("switches to the Inbox folder with the feed filter chips and no stats strip", async () => { + mockChrome() apiMock.listMail.mockResolvedValue(mailPage([])) renderWithQuery() await screen.findByText("Sent · 7d") @@ -329,101 +408,148 @@ describe("MailPage inbox", () => { expect(screen.getByRole("radio", { name: /Inbox/ })).toHaveAttribute("aria-checked", "true") expect(screen.queryByText("Sent · 7d")).not.toBeInTheDocument() - expect(screen.getByRole("button", { name: "Unread" })).toBeInTheDocument() - expect(screen.getByRole("button", { name: "Archived" })).toBeInTheDocument() - expect(screen.queryByRole("button", { name: "Needs attention" })).not.toBeInTheDocument() - expect(screen.getByPlaceholderText("Search sender, subject…")).toBeInTheDocument() + for (const chip of ["All", "Unread", "Replies", "Needs review", "Unmatched", "Archived"]) { + expect(screen.getByRole("button", { name: chip })).toBeInTheDocument() + } + for (const chip of ["Inbound", "Outbound", "Needs attention"]) { + expect(screen.queryByRole("button", { name: chip })).not.toBeInTheDocument() + } + expect(within(listCard()).getByRole("heading", { name: "All" })).toBeInTheDocument() + expect(screen.getByPlaceholderText("Search sender, subject, org…")).toBeInTheDocument() }) - it("shows the loading state while the inbox is in flight", async () => { + it("shows the loading state while the inbox feed is in flight", async () => { mockChrome() - apiMock.listInbox.mockReturnValue(new Promise(() => {})) + apiMock.listInboxFeed.mockReturnValue(new Promise(() => {})) apiMock.listMail.mockResolvedValue(mailPage([])) renderWithQuery() await openInbox() expect(within(listCard()).getByRole("status")).toHaveTextContent("Loading inbox...") + expect(within(detailCard()).getByText("No message selected")).toBeInTheDocument() }) - it("shows the empty copy when the inbox is empty", async () => { - mockChrome({ inbox: inboxPage([]) }) + it("shows the per-filter empty copy when the inbox feed is empty", async () => { + mockChrome() apiMock.listMail.mockResolvedValue(mailPage([])) renderWithQuery() await openInbox() - expect(await within(listCard()).findByText("Empty")).toBeInTheDocument() - expect(within(listCard()).getByText("No messages here.")).toBeInTheDocument() + expect(await within(listCard()).findByText("Inbox is empty")).toBeInTheDocument() + expect( + within(listCard()).getByText("City replies and other mail sent to civfix show up here."), + ).toBeInTheDocument() expect(within(detailCard()).getByText("No message selected")).toBeInTheDocument() + + await userEvent.click(screen.getByRole("button", { name: "Needs review" })) + + expect(await within(listCard()).findByText("Nothing to review")).toBeInTheDocument() + expect( + within(listCard()).getByText("Replies held back from a report chat or event show up here."), + ).toBeInTheDocument() }) - it("shows the error state with the failure message and a retry", async () => { + it("shows the error state with the failure message and refetches the feed on retry", async () => { mockChrome() - apiMock.listInbox.mockRejectedValue(new Error("Inbox backend unreachable")) + apiMock.listInboxFeed.mockRejectedValue(new Error("Inbox backend unreachable")) apiMock.listMail.mockResolvedValue(mailPage([])) + mockInboxMessages(PRESS) renderWithQuery() await openInbox() const alert = await within(listCard()).findByRole("alert") expect(alert).toHaveTextContent("Could not load this") expect(alert).toHaveTextContent("Inbox backend unreachable") - expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument() + + apiMock.listInboxFeed.mockResolvedValue(feedPage([PRESS_FEED])) + await userEvent.click(within(alert).getByRole("button", { name: "Try again" })) + + expect(await within(listCard()).findByText("Press inquiry")).toBeInTheDocument() + expect(within(listCard()).queryByRole("alert")).not.toBeInTheDocument() }) - it("renders inbox rows and auto-selects the first message into the reader", async () => { - mockChrome({ inbox: inboxPage([QUESTION, PRESS]) }) + it("renders reply and email rows and auto-selects the first item into the reader", async () => { + mockChrome({ feed: feedPage([REPORT_REPLY, QUESTION_FEED, EVENT_REPLY]) }) apiMock.listMail.mockResolvedValue(mailPage([])) - mockInboxMessages(QUESTION, PRESS) + mockThreadDetails(REPORT_REPLY_THREAD) renderWithQuery() await openInbox() - await within(listCard()).findByText("Question about my report") + await within(listCard()).findByText("Re: Pothole on 5th Ave") const list = listCard() + expect(within(list).getByText("3")).toBeInTheDocument() + expect(within(list).getByText("Oakland Public Works")).toBeInTheDocument() + expect(within(list).getByText("Report reply")).toBeInTheDocument() + expect(within(list).getByText("Withheld")).toBeInTheDocument() + expect(within(list).getByText("Question about my report")).toBeInTheDocument() expect(within(list).getByText("resident@example.com")).toBeInTheDocument() expect(within(list).getByText("support")).toBeInTheDocument() expect(within(list).getByText("Unread")).toBeInTheDocument() - expect(within(list).getByText("Press inquiry")).toBeInTheDocument() - expect(within(list).getByText("reporter@news.example")).toBeInTheDocument() - expect(within(list).getByText("Read")).toBeInTheDocument() + expect(within(list).getByText("Re: Lake Merritt cleanup")).toBeInTheDocument() + expect(within(list).getByText("Parks Department")).toBeInTheDocument() + expect(within(list).getByText("Event reply")).toBeInTheDocument() + expect(within(list).getByText("Replied")).toBeInTheDocument() + expect(apiMock.listInboxFeed).toHaveBeenCalledWith({ filter: "all" }) const card = detailCard() - expect(await within(card).findByText("Body of Question about my report")).toBeInTheDocument() - expect(within(card).getByText("to hello@civfix.org")).toBeInTheDocument() - expect(within(card).getByRole("button", { name: /Mark read/ })).toBeEnabled() - expect(within(card).getByRole("button", { name: /Archive/ })).toBeEnabled() - expect(apiMock.getInboxMessage).toHaveBeenCalledWith({ id: "i-1" }) + expect(await within(card).findByText("Body of Re: Pothole on 5th Ave")).toBeInTheDocument() + expect(within(card).getByText("Failed sender check")).toBeInTheDocument() + expect(within(card).getByText("Withheld")).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /Publish reply/ })).toBeInTheDocument() + expect(within(card).getByRole("button", { name: /View report/ })).toBeInTheDocument() + expect(within(card).getByPlaceholderText("Reply to Oakland Public Works…")).toBeInTheDocument() + expect(apiMock.getMailThread).toHaveBeenCalledWith({ id: "t-1" }) + expect(apiMock.getInboxMessage).not.toHaveBeenCalled() }) - it("opens the clicked message in the reader", async () => { - mockChrome({ inbox: inboxPage([QUESTION, PRESS]) }) + it("opens a clicked email row in the inbound message reader", async () => { + mockChrome({ feed: feedPage([REPORT_REPLY, PRESS_FEED]) }) apiMock.listMail.mockResolvedValue(mailPage([])) - mockInboxMessages(QUESTION, PRESS) + mockThreadDetails(REPORT_REPLY_THREAD) + mockInboxMessages(PRESS) renderWithQuery() await openInbox() - await within(detailCard()).findByText("Body of Question about my report") + await within(detailCard()).findByText("Body of Re: Pothole on 5th Ave") await userEvent.click(within(listCard()).getByText("Press inquiry")) const card = detailCard() expect(await within(card).findByText("Body of Press inquiry")).toBeInTheDocument() + expect(within(card).getByText("to hello@civfix.org")).toBeInTheDocument() expect(within(card).getByRole("button", { name: /Mark read/ })).toBeDisabled() + expect(within(card).getByRole("button", { name: /Archive/ })).toBeEnabled() + expect(within(card).queryByRole("button", { name: /Publish reply/ })).not.toBeInTheDocument() + expect(apiMock.getInboxMessage).toHaveBeenCalledWith({ id: "i-2" }) }) - it("sends the chosen filter chip to the api", async () => { - mockChrome({ inbox: inboxPage([PRESS]) }) + it("sends the chosen filter chip and search to listInboxFeed and titles the list with the chip", async () => { + mockChrome({ feed: feedPage([PRESS_FEED]) }) apiMock.listMail.mockResolvedValue(mailPage([])) mockInboxMessages(PRESS) renderWithQuery() await openInbox() await within(listCard()).findByText("Press inquiry") - await userEvent.click(screen.getByRole("button", { name: "Unread" })) - await waitFor(() => expect(apiMock.listInbox).toHaveBeenLastCalledWith({ status: "unread" })) - - await userEvent.click(screen.getByRole("button", { name: "Archived" })) - await waitFor(() => expect(apiMock.listInbox).toHaveBeenLastCalledWith({ status: "archived" })) + const chips = [ + ["Unread", "unread"], + ["Replies", "replies"], + ["Needs review", "review"], + ["Unmatched", "unmatched"], + ["Archived", "archived"], + ] as const + for (const [label, filter] of chips) { + await userEvent.click(screen.getByRole("button", { name: label })) + await waitFor(() => expect(apiMock.listInboxFeed).toHaveBeenLastCalledWith({ filter })) + expect(within(listCard()).getByRole("heading", { name: label })).toBeInTheDocument() + } + + await userEvent.type(screen.getByPlaceholderText("Search sender, subject, org…"), "press") + await waitFor(() => + expect(apiMock.listInboxFeed).toHaveBeenLastCalledWith({ filter: "archived", q: "press" }), + ) }) it("shows Load more when a cursor is returned and fetches the next page with it", async () => { mockChrome() - apiMock.listInbox.mockImplementation(async (params: { cursor?: string }) => - params.cursor === "in-2" ? inboxPage([PRESS]) : inboxPage([QUESTION], "in-2"), + apiMock.listInboxFeed.mockImplementation(async (params: { cursor?: string }) => + params.cursor === "feed-2" ? feedPage([PRESS_FEED]) : feedPage([QUESTION_FEED], "feed-2"), ) apiMock.listMail.mockResolvedValue(mailPage([])) mockInboxMessages(QUESTION, PRESS) @@ -434,11 +560,12 @@ describe("MailPage inbox", () => { await userEvent.click(screen.getByRole("button", { name: "Load more" })) expect(await within(listCard()).findByText("Press inquiry")).toBeInTheDocument() - expect(apiMock.listInbox).toHaveBeenLastCalledWith({ status: "all", cursor: "in-2" }) + expect(apiMock.listInboxFeed).toHaveBeenLastCalledWith({ filter: "all", cursor: "feed-2" }) + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument() }) it("opens an inbox: deep link in the Inbox folder", async () => { - mockChrome({ inbox: inboxPage([QUESTION, PRESS]) }) + mockChrome({ feed: feedPage([QUESTION_FEED, PRESS_FEED]) }) apiMock.listMail.mockResolvedValue(mailPage([])) mockInboxMessages(QUESTION, PRESS) renderWithQuery() @@ -446,5 +573,6 @@ describe("MailPage inbox", () => { expect(screen.getByRole("radio", { name: /Inbox/ })).toHaveAttribute("aria-checked", "true") expect(await within(detailCard()).findByText("Body of Press inquiry")).toBeInTheDocument() expect(within(detailCard()).queryByText("Body of Question about my report")).not.toBeInTheDocument() + expect(apiMock.getInboxMessage).not.toHaveBeenCalledWith({ id: "i-1" }) }) }) From 8d105bc216933df07f2c709e30768860a0f3443a Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 04:38:02 +0000 Subject: [PATCH 17/17] page tests wait on the right pane and use fake timers for debounce --- .../src/features/events/events-page.test.tsx | 11 +++++-- .../moderation/moderation-page.test.tsx | 31 ++++++++++++------- .../src/features/orgs/orgs-page.test.tsx | 4 +-- .../features/reports/reports-page.test.tsx | 19 +++++++----- .../src/features/users/users-page.test.tsx | 11 +++++-- 5 files changed, 48 insertions(+), 28 deletions(-) diff --git a/apps/admin/src/features/events/events-page.test.tsx b/apps/admin/src/features/events/events-page.test.tsx index a1adf5f..22e4999 100644 --- a/apps/admin/src/features/events/events-page.test.tsx +++ b/apps/admin/src/features/events/events-page.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from "@testing-library/react" +import { act, screen, waitFor, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import type { AdminEventDTO, @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" +import { startFakeTimersWithUser } from "@/test/fake-timers" import { renderWithQuery } from "@/test/render" import { detailCard } from "@/test/panes" import { EventsPage } from "@/features/events/events-page" @@ -194,8 +195,12 @@ describe("EventsPage", () => { renderWithQuery() await screen.findByText("Beach sweep") - await userEvent.type(screen.getByPlaceholderText("Search title, place, organizer…"), " beach ") - await waitFor(() => expect(apiMock.listAdminEvents).toHaveBeenLastCalledWith({ q: "beach" })) + const user = startFakeTimersWithUser() + await user.type(screen.getByPlaceholderText("Search title, place, organizer…"), " beach ") + await act(async () => { + vi.advanceTimersByTime(250) + }) + expect(apiMock.listAdminEvents).toHaveBeenLastCalledWith({ q: "beach" }) }) it("shows Load more when a cursor is returned and fetches the next page with it", async () => { diff --git a/apps/admin/src/features/moderation/moderation-page.test.tsx b/apps/admin/src/features/moderation/moderation-page.test.tsx index fb30e92..cb371af 100644 --- a/apps/admin/src/features/moderation/moderation-page.test.tsx +++ b/apps/admin/src/features/moderation/moderation-page.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from "@testing-library/react" +import { act, screen, waitFor, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import type { GovClaimDTO, @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" +import { startFakeTimersWithUser } from "@/test/fake-timers" import { renderWithQuery } from "@/test/render" import { detailCard } from "@/test/panes" import { ModerationPage } from "@/features/moderation/moderation-page" @@ -244,8 +245,12 @@ describe("ModerationPage queue", () => { renderWithQuery() await screen.findByText("MOD-101") - await userEvent.type(screen.getByPlaceholderText("Search flag, reporter, reason…"), " spam ") - await waitFor(() => expect(apiMock.listModeration).toHaveBeenLastCalledWith({ q: "spam" })) + const user = startFakeTimersWithUser() + await user.type(screen.getByPlaceholderText("Search flag, reporter, reason…"), " spam ") + await act(async () => { + vi.advanceTimersByTime(250) + }) + expect(apiMock.listModeration).toHaveBeenLastCalledWith({ q: "spam" }) }) it("shows Load more when a cursor is returned and fetches the next page with it", async () => { @@ -325,8 +330,8 @@ describe("ModerationPage gov claims", () => { renderWithQuery() await openGovClaims() - await screen.findByText("Maya Rivera") const list = listCard("Gov claims") + await within(list).findByText("Maya Rivera") expect(within(list).getByText("City of Oakland")).toBeInTheDocument() expect(within(list).getByText("Public Works Director")).toBeInTheDocument() expect(within(list).getAllByText("1 of 3 checks verified")).toHaveLength(2) @@ -370,7 +375,7 @@ describe("ModerationPage gov claims", () => { mockClaimDetails(RIVERA) renderWithQuery() await openGovClaims() - await screen.findByText("City of Oakland") + await within(listCard("Gov claims")).findByText("City of Oakland") await userEvent.click(screen.getByRole("button", { name: "Approved" })) await waitFor(() => @@ -387,12 +392,14 @@ describe("ModerationPage gov claims", () => { mockClaimDetails(RIVERA) renderWithQuery() await openGovClaims() - await screen.findByText("City of Oakland") - - await userEvent.type(screen.getByPlaceholderText("Search name or organization…"), " maya ") - await waitFor(() => - expect(apiMock.listGovClaims).toHaveBeenLastCalledWith({ filter: "pending", q: "maya" }), - ) + await within(listCard("Gov claims")).findByText("City of Oakland") + + const user = startFakeTimersWithUser() + await user.type(screen.getByPlaceholderText("Search name or organization…"), " maya ") + await act(async () => { + vi.advanceTimersByTime(250) + }) + expect(apiMock.listGovClaims).toHaveBeenLastCalledWith({ filter: "pending", q: "maya" }) }) it("shows Load more when a cursor is returned and fetches the next page with it", async () => { @@ -403,7 +410,7 @@ describe("ModerationPage gov claims", () => { mockClaimDetails(RIVERA, NGUYEN) renderWithQuery() await openGovClaims() - await screen.findByText("City of Oakland") + await within(listCard("Gov claims")).findByText("City of Oakland") await userEvent.click(screen.getByRole("button", { name: "Load more" })) diff --git a/apps/admin/src/features/orgs/orgs-page.test.tsx b/apps/admin/src/features/orgs/orgs-page.test.tsx index c3ba19a..886b5df 100644 --- a/apps/admin/src/features/orgs/orgs-page.test.tsx +++ b/apps/admin/src/features/orgs/orgs-page.test.tsx @@ -11,7 +11,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" import { renderWithQuery } from "@/test/render" -import { queueRowOf } from "@/test/panes" +import { detailCard, queueRowOf } from "@/test/panes" import { OrgsPage } from "@/features/orgs/orgs-page" vi.mock("@/lib/api", async (importOriginal) => { @@ -79,7 +79,7 @@ function mockOrgDetails(...orgs: AdminOrgDTO[]) { } async function detailHeading(name: string) { - return screen.findByRole("heading", { level: 2, name }) + return within(detailCard()).findByRole("heading", { level: 2, name }) } describe("OrgsPage list states", () => { diff --git a/apps/admin/src/features/reports/reports-page.test.tsx b/apps/admin/src/features/reports/reports-page.test.tsx index 19a42b2..bb981d1 100644 --- a/apps/admin/src/features/reports/reports-page.test.tsx +++ b/apps/admin/src/features/reports/reports-page.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from "@testing-library/react" +import { act, screen, waitFor, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import type { AdminReportDTO, @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" +import { startFakeTimersWithUser } from "@/test/fake-timers" import { renderWithQuery } from "@/test/render" import { detailCard } from "@/test/panes" import { ReportsPage } from "@/features/reports/reports-page" @@ -232,13 +233,15 @@ describe("ReportsPage", () => { renderWithQuery() await screen.findByText("Couch dumped on sidewalk") - await userEvent.type(screen.getByPlaceholderText("Search title, place, reporter…"), " couch ") - await waitFor(() => - expect(apiMock.listAdminReports).toHaveBeenLastCalledWith({ - filter: "needs_verification", - q: "couch", - }), - ) + const user = startFakeTimersWithUser() + await user.type(screen.getByPlaceholderText("Search title, place, reporter…"), " couch ") + await act(async () => { + vi.advanceTimersByTime(250) + }) + expect(apiMock.listAdminReports).toHaveBeenLastCalledWith({ + filter: "needs_verification", + q: "couch", + }) }) it("shows Load more when a cursor is returned and fetches the next page with it", async () => { diff --git a/apps/admin/src/features/users/users-page.test.tsx b/apps/admin/src/features/users/users-page.test.tsx index 37fc7cd..2802444 100644 --- a/apps/admin/src/features/users/users-page.test.tsx +++ b/apps/admin/src/features/users/users-page.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from "@testing-library/react" +import { act, screen, waitFor, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import type { AdminUserDTO, @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from "vitest" import type * as ApiModule from "@/lib/api" import { apiMock } from "@/test/api-mock" +import { startFakeTimersWithUser } from "@/test/fake-timers" import { renderWithQuery } from "@/test/render" import { detailCard } from "@/test/panes" import { UsersPage } from "@/features/users/users-page" @@ -194,8 +195,12 @@ describe("UsersPage", () => { renderWithQuery() await screen.findByText("Ana Ruiz") - await userEvent.type(screen.getByPlaceholderText("Search name, handle, city…"), " ana ") - await waitFor(() => expect(apiMock.listAdminUsers).toHaveBeenLastCalledWith({ q: "ana" })) + const user = startFakeTimersWithUser() + await user.type(screen.getByPlaceholderText("Search name, handle, city…"), " ana ") + await act(async () => { + vi.advanceTimersByTime(250) + }) + expect(apiMock.listAdminUsers).toHaveBeenLastCalledWith({ q: "ana" }) }) it("shows Load more when a cursor is returned and fetches the next page with it", async () => {