From 9fc448ff3691b9009ceeb455c5fe9d6214420ee4 Mon Sep 17 00:00:00 2001 From: bgard68 Date: Tue, 25 Aug 2026 05:26:01 +0000 Subject: [PATCH] test: cover every line and function of the SPA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage was 38.14% of lines across 44 tests. It is now 100% of lines and 100% of functions across 266. Tooling - @vitest/coverage-v8, configured in vite.config.js over src/**/*.{js,jsx}, excluding main.jsx (which only mounts the app) and the test files. - npm run test:coverage. Tests - apiClient.js had no tests at all and is the file most worth having them: the 401 refresh-and-retry, the shared in-flight refresh that stops two simultaneous 401s from replaying the same rotating token and revoking every session, the cold-start backoff for Azure's Free tier, error mapping, and each Auth/Category/Todo call's verb, path and body. - App.jsx: the silent sign-in on load and each way it can fail, the waking-the-server note, sign-in, register, Google, sign-out, sign-out everywhere, and the mid-session revocation that drops back to the form. - KanbanBoard, Lane, TaskCard, CategoryManager: lane bucketing, the category filter and its fallback when the selected category is deleted, drag and drop, the tap-to-move control touch devices need, editing, and deletion. - useTodos and useCategories: optimistic update, reconcile, and the reload that reverts a failed one. - GoogleButton, ColorPicker, DateField, AuthForm, TodoForm, ThemeToggle, colors: the remaining branches, including theming and the offline paths. Two conventions the suite depends on, both because jsdom is not a browser, are written up in the README so they are not rediscovered the hard way: fireEvent.pointerDown drops clientX/clientY on jsdom's fallback event and feeds NaN into the colour wheel, so those tests dispatch a MouseEvent named pointerdown; and nothing has a size in jsdom, so components that measure themselves get an explicit getBoundingClientRect. Thirteen branches remain uncovered. All are guards that cannot be taken as the code stands — a ref checked for null on the element the same render creates, a ternary on a non-zero constant, a null check behind a caller that already tested the value. They are worth keeping and not worth contorting a test to reach, so branch coverage reads 96.51%. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019h7znwAftvs36vD4YwKMRf --- .gitignore | 1 + README.md | 24 +- package-lock.json | 338 ++++++++++++++---- package.json | 2 + src/App.test.jsx | 212 +++++++++++ src/components/AuthForm.test.jsx | 109 ++++++ src/components/CategoryManager.test.jsx | 258 ++++++++++++++ src/components/ColorPicker.test.jsx | 192 +++++++++- src/components/DateField.test.jsx | 116 ++++++ src/components/GoogleButton.test.jsx | 268 ++++++++++++++ src/components/KanbanBoard.test.jsx | 298 ++++++++++++++++ src/components/Lane.test.jsx | 132 +++++++ src/components/TaskCard.test.jsx | 312 ++++++++++++++++ src/components/ThemeToggle.test.jsx | 43 +++ src/components/TodoForm.test.jsx | 63 +++- src/hooks/useCategories.test.jsx | 49 +++ src/hooks/useTodos.test.jsx | 138 ++++++++ src/lib/apiClient.test.js | 450 ++++++++++++++++++++++++ src/lib/colors.test.js | 46 +++ vite.config.js | 7 + 20 files changed, 2993 insertions(+), 65 deletions(-) create mode 100644 src/App.test.jsx create mode 100644 src/components/CategoryManager.test.jsx create mode 100644 src/components/GoogleButton.test.jsx create mode 100644 src/components/KanbanBoard.test.jsx create mode 100644 src/components/Lane.test.jsx create mode 100644 src/components/TaskCard.test.jsx create mode 100644 src/hooks/useCategories.test.jsx create mode 100644 src/lib/apiClient.test.js diff --git a/.gitignore b/.gitignore index 4ce2d77..dc545cb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ PublishProfiles/ TestResults/ [Tt]est[Rr]esult*/ *.trx +coverage/ coverage*.json coverage*.xml coverage*.info diff --git a/README.md b/README.md index f964534..d42a715 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,39 @@ injected by the deploy workflow. | `npm run build` | Production build to `dist/` | | `npm run preview` | Serve the built `dist/` locally | | `npm test` | Run the Vitest suite once (CI mode) | +| `npm run test:coverage` | Run the suite and print a coverage report | | `npm run test:watch` | Vitest in watch mode | ## Testing ```bash -npm test +npm test # run once +npm run test:coverage # run with a coverage report ``` Vitest reuses Vite's transform pipeline, so `.jsx` tests compile exactly like the app. Tests live next to the code they cover as `*.test.{js,jsx}` under `src/`. +### Coverage + +`vite.config.js` configures the v8 provider over `src/**/*.{js,jsx}`, excluding `main.jsx` (which only +mounts the app) and the test files themselves. + +Every line and every function under `src/` is covered. Thirteen branches are not, and all of them are +guards that cannot be taken as the code stands — a ref checked for null on the element the same render +creates, `WHEEL_RADIUS ? … : 0` on a non-zero constant, a `formatDate` null check behind a caller that +already tested the value. They are worth keeping as guards and are not worth contorting a test to +reach, so the branch figure sits at 96.51% rather than 100%. + +Two conventions the suite depends on, both because jsdom is not a browser: + +- **Pointer coordinates.** jsdom has no `PointerEvent`, and `fireEvent.pointerDown` silently drops + `clientX`/`clientY` on its fallback event — which feeds `NaN` into the colour wheel's maths and makes + a broken assertion look like a passing one. `ColorPicker.test.jsx` dispatches a `MouseEvent` named + `pointerdown` instead, which carries the coordinates and still reaches React's handler. +- **Layout.** Nothing has a size in jsdom, so components that measure themselves (the colour wheel, the + Google button) get an explicit `getBoundingClientRect` in the tests that care. + ## Deployment Pushing to this `frontend` branch triggers [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml), diff --git a/package-lock.json b/package-lock.json index 65af9a5..ec3ff15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.6.4", "@vitejs/plugin-react": "^6.0.5", + "@vitest/coverage-v8": "^4.1.11", "jsdom": "^25.0.1", "vite": "^8.2.1", "vitest": "^4.1.10" @@ -65,6 +66,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", @@ -75,6 +86,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -85,6 +112,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -234,6 +285,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -241,6 +302,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", @@ -366,9 +438,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -386,9 +455,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -406,9 +472,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -426,9 +489,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -446,9 +506,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -466,9 +523,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -717,17 +771,48 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -736,13 +821,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -763,9 +848,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -776,13 +861,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -790,14 +875,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -806,9 +891,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -816,13 +901,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -883,6 +968,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1254,6 +1358,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1309,6 +1423,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1367,6 +1488,45 @@ "dev": true, "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1707,6 +1867,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2007,6 +2195,19 @@ "loose-envify": "^1.1.0" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2051,6 +2252,19 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -2235,19 +2449,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2275,12 +2489,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index f931e60..77efe8d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "vite build", "preview": "vite preview", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { @@ -20,6 +21,7 @@ "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.6.4", "@vitejs/plugin-react": "^6.0.5", + "@vitest/coverage-v8": "^4.1.11", "jsdom": "^25.0.1", "vite": "^8.2.1", "vitest": "^4.1.10" diff --git a/src/App.test.jsx b/src/App.test.jsx new file mode 100644 index 0000000..ed17ec0 --- /dev/null +++ b/src/App.test.jsx @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('./lib/apiClient.js', () => ({ + AuthApi: { + refresh: vi.fn(), + me: vi.fn(), + login: vi.fn(), + register: vi.fn(), + google: vi.fn(), + logout: vi.fn(), + revokeAll: vi.fn(), + }, + TodoApi: { list: vi.fn(), create: vi.fn(), update: vi.fn(), changeStatus: vi.fn(), remove: vi.fn() }, + CategoryApi: { list: vi.fn(), create: vi.fn(), update: vi.fn(), remove: vi.fn() }, + hasSession: vi.fn(), + setOnUnauthorized: vi.fn(), + setOnServerWaking: vi.fn(), +})); + +import { + AuthApi, TodoApi, CategoryApi, hasSession, setOnUnauthorized, setOnServerWaking, +} from './lib/apiClient.js'; +// The real widget needs Google Identity Services; it has its own tests. Here it stands in as a +// plain button that hands back a credential. +vi.mock('./components/GoogleButton.jsx', () => ({ + default: ({ onCredential }) => ( + + ), +})); + +import App from './App.jsx'; + +const user = { id: 1, email: 'me@example.com', role: 'User' }; + +beforeEach(() => { + vi.clearAllMocks(); + hasSession.mockReturnValue(true); + AuthApi.refresh.mockResolvedValue(false); + TodoApi.list.mockResolvedValue([]); + CategoryApi.list.mockResolvedValue([]); +}); + +/** Waits out the silent sign-in attempt that runs on mount. */ +async function settle() { + await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument()); +} + +describe('App startup', () => { + it('shows a loading note while the silent sign-in runs', async () => { + let release; + AuthApi.refresh.mockReturnValue(new Promise((resolve) => { release = resolve; })); + render(); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + + await act(async () => { release(false); }); + await settle(); + }); + + it('says it is waking the server when a cold start is being waited out', async () => { + let signalWaking; + setOnServerWaking.mockImplementation((fn) => { signalWaking = fn; }); + let release; + AuthApi.refresh.mockReturnValue(new Promise((resolve) => { release = resolve; })); + render(); + + await act(async () => { signalWaking(true); }); + + expect(screen.getByText(/waking the server up/i)).toBeInTheDocument(); + + await act(async () => { release(false); }); + await settle(); + }); + + it('restores the session when the refresh cookie is still good', async () => { + AuthApi.refresh.mockResolvedValue(true); + AuthApi.me.mockResolvedValue(user); + render(); + + await waitFor(() => expect(screen.getByText('Signed in as me@example.com')).toBeInTheDocument()); + }); + + it('falls back to the sign-in form when there is no usable cookie', async () => { + AuthApi.refresh.mockResolvedValue(false); + render(); + + await settle(); + expect(AuthApi.me).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); + + it('falls back to the sign-in form when the profile call fails', async () => { + AuthApi.refresh.mockResolvedValue(true); + AuthApi.me.mockRejectedValue(new Error('revoked')); + render(); + + await settle(); + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); + + it('skips the refresh entirely when there is no session to restore', async () => { + hasSession.mockReturnValue(false); + render(); + + await settle(); + expect(AuthApi.refresh).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); +}); + +describe('App authentication', () => { + it('signs in with a password', async () => { + const ui = userEvent.setup(); + AuthApi.login.mockResolvedValue({ user }); + render(); + await settle(); + + await ui.type(screen.getByLabelText(/email/i), 'me@example.com'); + await ui.type(screen.getByLabelText(/password/i, { selector: 'input' }), 'Password1'); + await ui.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => expect(screen.getByText('Signed in as me@example.com')).toBeInTheDocument()); + expect(AuthApi.login).toHaveBeenCalledWith('me@example.com', 'Password1'); + }); + + it('registers a new account', async () => { + const ui = userEvent.setup(); + AuthApi.register.mockResolvedValue({ user }); + render(); + await settle(); + + await ui.click(screen.getByRole('button', { name: /create one/i })); + await ui.type(screen.getByLabelText(/email/i), 'me@example.com'); + await ui.type(screen.getByLabelText(/password/i, { selector: 'input' }), 'Password1'); + await ui.click(screen.getByRole('button', { name: /create account/i })); + + await waitFor(() => expect(screen.getByText('Signed in as me@example.com')).toBeInTheDocument()); + expect(AuthApi.register).toHaveBeenCalledWith('me@example.com', 'Password1'); + }); + + it('drops back to sign-in when the session is revoked mid-use', async () => { + let signalUnauthorized; + setOnUnauthorized.mockImplementation((fn) => { signalUnauthorized = fn; }); + AuthApi.refresh.mockResolvedValue(true); + AuthApi.me.mockResolvedValue(user); + render(); + await waitFor(() => expect(screen.getByText('Signed in as me@example.com')).toBeInTheDocument()); + + await act(async () => { signalUnauthorized(); }); + + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); +}); + +describe('App signed in', () => { + beforeEach(() => { + AuthApi.refresh.mockResolvedValue(true); + AuthApi.me.mockResolvedValue(user); + }); + + async function renderSignedIn() { + render(); + await waitFor(() => expect(screen.getByText('Signed in as me@example.com')).toBeInTheDocument()); + } + + it('shows the board', async () => { + await renderSignedIn(); + + expect(screen.getByRole('heading', { name: 'Board' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'To Do' })).toBeInTheDocument(); + }); + + it('signs out', async () => { + const ui = userEvent.setup(); + AuthApi.logout.mockResolvedValue(undefined); + await renderSignedIn(); + + await ui.click(screen.getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()); + expect(AuthApi.logout).toHaveBeenCalled(); + }); + + it('signs out everywhere', async () => { + const ui = userEvent.setup(); + AuthApi.revokeAll.mockResolvedValue(undefined); + await renderSignedIn(); + + await ui.click(screen.getByRole('button', { name: 'Sign out everywhere' })); + + await waitFor(() => expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()); + expect(AuthApi.revokeAll).toHaveBeenCalled(); + }); +}); + +describe('App Google sign-in', () => { + it('signs in with a Google credential', async () => { + const ui = userEvent.setup(); + AuthApi.google.mockResolvedValue({ user }); + render(); + await settle(); + + await ui.click(screen.getByRole('button', { name: /continue with google/i })); + + await waitFor(() => expect(screen.getByText('Signed in as me@example.com')).toBeInTheDocument()); + expect(AuthApi.google).toHaveBeenCalledWith('google-id-token'); + }); +}); diff --git a/src/components/AuthForm.test.jsx b/src/components/AuthForm.test.jsx index 50d2f44..b8ecb92 100644 --- a/src/components/AuthForm.test.jsx +++ b/src/components/AuthForm.test.jsx @@ -1,6 +1,17 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; + +// The real widget needs Google Identity Services and a configured client id; it has its own +// tests. Here it stands in as a plain button that hands back a credential. +vi.mock('./GoogleButton.jsx', () => ({ + default: ({ onCredential }) => ( + + ), +})); + import AuthForm from './AuthForm.jsx'; describe('', () => { @@ -32,4 +43,102 @@ describe('', () => { await userEvent.click(screen.getByRole('button', { name: /hide password/i })); expect(pw).toHaveAttribute('type', 'password'); }); + + it('registers with the entered credentials', async () => { + const onRegister = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.click(screen.getByRole('button', { name: /create one/i })); + await userEvent.type(screen.getByLabelText(/email/i), ' demo@todoapp.local '); + await userEvent.type(screen.getByLabelText(/^password$/i), 'Password123!'); + await userEvent.click(screen.getByRole('button', { name: /^create account$/i })); + + expect(onRegister).toHaveBeenCalledWith('demo@todoapp.local', 'Password123!'); + expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument(); + }); + + it('switches back to sign-in and clears the error', async () => { + const onLogin = vi.fn().mockRejectedValue(new Error('Invalid email or password.')); + render(); + await userEvent.type(screen.getByLabelText(/email/i), 'demo@todoapp.local'); + await userEvent.type(screen.getByLabelText(/^password$/i), 'wrong'); + await userEvent.click(screen.getByRole('button', { name: /^sign in$/i })); + expect(await screen.findByText('Invalid email or password.')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /create one/i })); + + expect(screen.queryByText('Invalid email or password.')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /^sign in$/i, selector: '.auth__link' })); + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument(); + }); + + it('shows the message from a failed sign-in', async () => { + const onLogin = vi.fn().mockRejectedValue(new Error('This account has been disabled.')); + render(); + + await userEvent.type(screen.getByLabelText(/email/i), 'demo@todoapp.local'); + await userEvent.type(screen.getByLabelText(/^password$/i), 'Password123!'); + await userEvent.click(screen.getByRole('button', { name: /^sign in$/i })); + + expect(await screen.findByText('This account has been disabled.')).toBeInTheDocument(); + }); + + it('prefers a field-level validation message over the generic one', async () => { + const err = new Error('One or more validation errors occurred.'); + err.problem = { errors: { Password: ['Password must contain a letter and a number.'] } }; + const onRegister = vi.fn().mockRejectedValue(err); + render(); + + await userEvent.click(screen.getByRole('button', { name: /create one/i })); + await userEvent.type(screen.getByLabelText(/email/i), 'demo@todoapp.local'); + await userEvent.type(screen.getByLabelText(/^password$/i), 'password'); + await userEvent.click(screen.getByRole('button', { name: /^create account$/i })); + + expect(await screen.findByText('Password must contain a letter and a number.')) + .toBeInTheDocument(); + }); + + it('copes with a validation payload that is not an array', async () => { + const err = new Error('One or more validation errors occurred.'); + err.problem = { errors: { Email: 'Email is required.' } }; + const onLogin = vi.fn().mockRejectedValue(err); + render(); + + await userEvent.type(screen.getByLabelText(/email/i), 'demo@todoapp.local'); + await userEvent.type(screen.getByLabelText(/^password$/i), 'Password123!'); + await userEvent.click(screen.getByRole('button', { name: /^sign in$/i })); + + expect(await screen.findByText('Email is required.')).toBeInTheDocument(); + }); + + it('signs in with a Google credential', async () => { + const onGoogle = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.click(screen.getByRole('button', { name: /continue with google/i })); + + expect(onGoogle).toHaveBeenCalledWith('google-id-token'); + }); + + it('shows the message from a failed Google sign-in', async () => { + const onGoogle = vi.fn().mockRejectedValue(new Error('Your Google email is not verified.')); + render(); + + await userEvent.click(screen.getByRole('button', { name: /continue with google/i })); + + expect(await screen.findByText('Your Google email is not verified.')).toBeInTheDocument(); + }); + + it('shows the cold-start note when the server is waking', () => { + render(); + + expect(screen.getByText(/waking the server up/i)).toBeInTheDocument(); + }); + + it('hides the cold-start note otherwise', () => { + render(); + + expect(screen.queryByText(/waking the server up/i)).not.toBeInTheDocument(); + }); }); diff --git a/src/components/CategoryManager.test.jsx b/src/components/CategoryManager.test.jsx new file mode 100644 index 0000000..55ade2d --- /dev/null +++ b/src/components/CategoryManager.test.jsx @@ -0,0 +1,258 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../lib/apiClient.js', () => ({ + CategoryApi: { + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, +})); + +import { CategoryApi } from '../lib/apiClient.js'; +import CategoryManager from './CategoryManager.jsx'; + +const categories = [ + { id: 1, name: 'Work', color: '#7fb2e6' }, + { id: 2, name: 'Personal', color: null }, +]; + +function renderManager(list = categories) { + const onChanged = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + render(); + return { onChanged, onClose }; +} + +/** The row for a named category, so a control can be found without ambiguity. */ +function rowFor(name) { + return screen.getByText(name).closest('li'); +} + +beforeEach(() => { + vi.clearAllMocks(); + CategoryApi.create.mockResolvedValue({ id: 3 }); + CategoryApi.update.mockResolvedValue({ id: 1 }); + CategoryApi.remove.mockResolvedValue(null); +}); + +describe('CategoryManager listing', () => { + it('lists the categories', () => { + renderManager(); + + expect(screen.getByText('Work')).toBeInTheDocument(); + expect(screen.getByText('Personal')).toBeInTheDocument(); + }); + + it('says so when there are none', () => { + renderManager([]); + + expect(screen.getByText('No categories yet.')).toBeInTheDocument(); + }); + + it('closes on Done', async () => { + const user = userEvent.setup(); + const { onClose } = renderManager(); + + await user.click(screen.getByLabelText('Close')); + + expect(onClose).toHaveBeenCalled(); + }); +}); + +describe('CategoryManager create', () => { + it('creates a category and clears the form', async () => { + const user = userEvent.setup(); + const { onChanged } = renderManager(); + + await user.type(screen.getByLabelText('New category name'), 'Errands'); + await user.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => expect(CategoryApi.create).toHaveBeenCalledWith({ + name: 'Errands', + color: '#7fb2e6', + })); + expect(onChanged).toHaveBeenCalled(); + expect(screen.getByLabelText('New category name')).toHaveValue(''); + }); + + it('trims the name', async () => { + const user = userEvent.setup(); + renderManager(); + + await user.type(screen.getByLabelText('New category name'), ' Errands '); + await user.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => expect(CategoryApi.create) + .toHaveBeenCalledWith(expect.objectContaining({ name: 'Errands' }))); + }); + + it('refuses a blank name without calling the API', async () => { + const user = userEvent.setup(); + renderManager(); + + await user.type(screen.getByLabelText('New category name'), ' '); + await user.click(screen.getByRole('button', { name: 'Add' })); + + expect(await screen.findByText('Name is required.')).toBeInTheDocument(); + expect(CategoryApi.create).not.toHaveBeenCalled(); + }); + + it('surfaces a rejected create and keeps the typed name', async () => { + const user = userEvent.setup(); + CategoryApi.create.mockRejectedValue(new Error('A category with this name already exists.')); + renderManager(); + + await user.type(screen.getByLabelText('New category name'), 'Work'); + await user.click(screen.getByRole('button', { name: 'Add' })); + + expect(await screen.findByText('A category with this name already exists.')).toBeInTheDocument(); + expect(screen.getByLabelText('New category name')).toHaveValue('Work'); + }); +}); + +describe('CategoryManager edit', () => { + it('renames a category', async () => { + const user = userEvent.setup(); + const { onChanged } = renderManager(); + + await user.click(within(rowFor('Work')).getByRole('button', { name: 'Edit' })); + const field = screen.getByLabelText('Category name'); + await user.clear(field); + await user.type(field, 'Job'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(CategoryApi.update) + .toHaveBeenCalledWith(1, { name: 'Job', color: '#7fb2e6' })); + expect(onChanged).toHaveBeenCalled(); + }); + + it('falls back to the default color for a category that has none', async () => { + const user = userEvent.setup(); + renderManager(); + + await user.click(within(rowFor('Personal')).getByRole('button', { name: 'Edit' })); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(CategoryApi.update) + .toHaveBeenCalledWith(2, { name: 'Personal', color: '#64748b' })); + }); + + it('refuses a blank name', async () => { + const user = userEvent.setup(); + renderManager(); + + await user.click(within(rowFor('Work')).getByRole('button', { name: 'Edit' })); + await user.clear(screen.getByLabelText('Category name')); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('Name is required.')).toBeInTheDocument(); + expect(CategoryApi.update).not.toHaveBeenCalled(); + }); + + it('stays open when the save is rejected', async () => { + const user = userEvent.setup(); + CategoryApi.update.mockRejectedValue(new Error('Conflict')); + renderManager(); + + await user.click(within(rowFor('Work')).getByRole('button', { name: 'Edit' })); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('Conflict')).toBeInTheDocument(); + expect(screen.getByLabelText('Category name')).toBeInTheDocument(); + }); + + it('abandons the edit on cancel', async () => { + const user = userEvent.setup(); + renderManager(); + + await user.click(within(rowFor('Work')).getByRole('button', { name: 'Edit' })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByLabelText('Category name')).not.toBeInTheDocument(); + expect(CategoryApi.update).not.toHaveBeenCalled(); + }); + + it('clears a previous error when a new edit starts', async () => { + const user = userEvent.setup(); + renderManager(); + + await user.type(screen.getByLabelText('New category name'), ' '); + await user.click(screen.getByRole('button', { name: 'Add' })); + expect(await screen.findByText('Name is required.')).toBeInTheDocument(); + + await user.click(within(rowFor('Work')).getByRole('button', { name: 'Edit' })); + + expect(screen.queryByText('Name is required.')).not.toBeInTheDocument(); + }); +}); + +describe('CategoryManager colors', () => { + it('sends the color chosen for a new category', async () => { + const user = userEvent.setup(); + renderManager(); + + await user.click(screen.getByRole('button', { name: /new category color/i })); + const hex = screen.getByLabelText(/new category color hex value/i); + await user.clear(hex); + await user.type(hex, '00ff00'); + await user.type(screen.getByLabelText('New category name'), 'Errands'); + await user.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => expect(CategoryApi.create) + .toHaveBeenCalledWith({ name: 'Errands', color: '#00ff00' })); + }); + + it('sends the recolored value when a category is edited', async () => { + const user = userEvent.setup(); + renderManager(); + + await user.click(within(rowFor('Work')).getByRole('button', { name: 'Edit' })); + // The add form carries its own picker, so scope to the row being edited. + const editRow = within(document.querySelector('.cat-manager__row--edit')); + await user.click(editRow.getByRole('button', { name: /^category color:/i })); + const hex = screen.getByLabelText(/^category color hex value$/i); + await user.clear(hex); + await user.type(hex, '123456'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(CategoryApi.update) + .toHaveBeenCalledWith(1, { name: 'Work', color: '#123456' })); + }); +}); + +describe('CategoryManager delete', () => { + it('deletes after the confirmation is accepted', async () => { + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + const { onChanged } = renderManager(); + + await user.click(within(rowFor('Work')).getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(CategoryApi.remove).toHaveBeenCalledWith(1)); + expect(onChanged).toHaveBeenCalled(); + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('Work')); + }); + + it('does nothing when the confirmation is declined', async () => { + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderManager(); + + await user.click(within(rowFor('Work')).getByRole('button', { name: 'Delete' })); + + expect(CategoryApi.remove).not.toHaveBeenCalled(); + }); + + it('surfaces a rejected delete', async () => { + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + CategoryApi.remove.mockRejectedValue(new Error('Still in use')); + renderManager(); + + await user.click(within(rowFor('Work')).getByRole('button', { name: 'Delete' })); + + expect(await screen.findByText('Still in use')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/ColorPicker.test.jsx b/src/components/ColorPicker.test.jsx index 5adf192..967a3b8 100644 --- a/src/components/ColorPicker.test.jsx +++ b/src/components/ColorPicker.test.jsx @@ -1,8 +1,22 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import ColorPicker from './ColorPicker.jsx'; +// jsdom has no PointerEvent, and fireEvent.pointerDown drops clientX/clientY on the fallback +// event — which silently feeds NaN into the wheel maths. A MouseEvent carries the coordinates +// and still triggers React's onPointerDown handlers. +function pointer(type, coords = {}) { + return new MouseEvent(type, { bubbles: true, cancelable: true, ...coords }); +} + +/** jsdom lays nothing out, so the wheel's 156px box has to be supplied. */ +function measureWheel(wheel) { + vi.spyOn(wheel, 'getBoundingClientRect').mockReturnValue({ + left: 0, top: 0, width: 156, height: 156, right: 156, bottom: 156, + }); +} + describe('ColorPicker', () => { it('starts collapsed and opens the popover on click', async () => { render( {}} label="Category color" />); @@ -37,4 +51,180 @@ describe('ColorPicker', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); + + it('toggles closed again from the trigger', async () => { + render( {}} label="Category color" />); + const trigger = screen.getByRole('button', { name: /category color/i }); + + await userEvent.click(trigger); + await userEvent.click(trigger); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes on a click outside', async () => { + render( +
+ {}} label="Category color" /> + +
+ ); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + + await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('stays open on a click inside the popover', async () => { + render( {}} label="Category color" />); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + + await userEvent.click(screen.getByLabelText(/category color hex value/i)); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('ignores keys other than Escape', async () => { + render( {}} label="Category color" />); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + + await userEvent.keyboard('{Enter}'); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('ignores a half-typed hex value', async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + + const hex = screen.getByLabelText(/category color hex value/i); + await userEvent.clear(hex); + await userEvent.type(hex, '00ff'); + + expect(onChange).not.toHaveBeenCalled(); + expect(hex).toHaveValue('00ff'); // still shows what was typed + }); + + it('accepts a hex without the leading hash and normalises it', async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + + const hex = screen.getByLabelText(/category color hex value/i); + await userEvent.clear(hex); + await userEvent.type(hex, 'AABBCC'); + + expect(onChange).toHaveBeenLastCalledWith('#aabbcc'); + }); + + it('emits a new color when brightness is dragged', async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + + fireEvent.change(screen.getByRole('slider'), { target: { value: '50' } }); + + expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/^#[0-9a-f]{6}$/)); + }); + + it('picks a hue and saturation from a press on the wheel', async () => { + const onChange = vi.fn(); + const { container } = render( + + ); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + + const wheel = container.querySelector('.color-picker__wheel'); + measureWheel(wheel); + + // Straight right of centre at full radius: hue 90, saturation 1. + fireEvent(wheel, pointer('pointerdown', { clientX: 156, clientY: 78 })); + + expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/^#[0-9a-f]{6}$/)); + }); + + it('tracks a drag across the wheel and stops on release', async () => { + const onChange = vi.fn(); + const { container } = render( + + ); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + + const wheel = container.querySelector('.color-picker__wheel'); + measureWheel(wheel); + + fireEvent(wheel, pointer('pointerdown', { clientX: 100, clientY: 78 })); + fireEvent(wheel, pointer('pointermove', { clientX: 120, clientY: 78 })); + const whileDragging = onChange.mock.calls.length; + + fireEvent(wheel, pointer('pointerup')); + fireEvent(wheel, pointer('pointermove', { clientX: 140, clientY: 78 })); + + expect(whileDragging).toBe(2); + expect(onChange).toHaveBeenCalledTimes(2); // the move after release is ignored + expect(onChange).toHaveBeenLastCalledWith(expect.stringMatching(/^#[0-9a-f]{6}$/)); + }); + + it('ignores a move that was never preceded by a press', async () => { + const onChange = vi.fn(); + const { container } = render( + + ); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + + fireEvent(container.querySelector('.color-picker__wheel'), pointer('pointermove', { clientX: 10, clientY: 10 })); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('adopts a color chosen elsewhere', async () => { + const { rerender } = render( + {}} label="Category color" /> + ); + + rerender( {}} label="Category color" />); + + expect(screen.getByRole('button', { name: /category color: #00ff00/i })).toBeInTheDocument(); + }); + + it('leaves the hex field alone while it is being typed in', async () => { + const { rerender } = render( + {}} label="Category color" /> + ); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + const hex = screen.getByLabelText(/category color hex value/i); + await userEvent.clear(hex); + await userEvent.type(hex, '00ff00'); + + // The parent echoes the committed value back while the field still has focus. + rerender( {}} label="Category color" />); + + expect(hex).toHaveValue('00ff00'); // not rewritten to '#00ff00' mid-edit + }); + + it('falls back to a sensible hue when the incoming value is not a color', () => { + render( {}} label="Category color" />); + + expect(screen.getByRole('button', { name: /category color: not-a-color/i })).toBeInTheDocument(); + }); + + it('defaults its label', async () => { + render( {}} />); + + expect(screen.getByRole('button', { name: /^color: #4f46e5$/i })).toBeInTheDocument(); + }); + + it('accepts a hex typed with the leading hash', async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /category color/i })); + + const hex = screen.getByLabelText(/category color hex value/i); + await userEvent.clear(hex); + await userEvent.type(hex, '#00FF00'); + + expect(onChange).toHaveBeenLastCalledWith('#00ff00'); + }); }); diff --git a/src/components/DateField.test.jsx b/src/components/DateField.test.jsx index 6a91abb..d12c394 100644 --- a/src/components/DateField.test.jsx +++ b/src/components/DateField.test.jsx @@ -30,4 +30,120 @@ describe('', () => { expect(input.value).toBe(''); expect(onChange).toHaveBeenLastCalledWith(''); }); + + it('shows an existing ISO value as a masked date', () => { + render(); + + expect(screen.getByLabelText('Due date')).toHaveValue('07/19/2026'); + }); + + it('shows nothing for an empty value', () => { + render(); + + expect(screen.getByLabelText('Due date')).toHaveValue(''); + }); + + it('shows nothing for a value that is not a full date', () => { + render(); + + expect(screen.getByLabelText('Due date')).toHaveValue(''); + }); + + it('adopts a value set from outside', () => { + const { rerender } = render(); + + rerender(); + + expect(screen.getByLabelText('Due date')).toHaveValue('01/02/2026'); + }); + + it('emits nothing until the date is complete', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByLabelText('Due date'); + + fireEvent.change(input, { target: { value: '0719' } }); + + expect(input).toHaveValue('07/19'); + expect(onChange).toHaveBeenLastCalledWith(''); + }); + + it('ignores extra digits past the eighth', () => { + const onChange = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Due date'), { target: { value: '071920261234' } }); + + expect(screen.getByLabelText('Due date')).toHaveValue('07/19/2026'); + expect(onChange).toHaveBeenLastCalledWith('2026-07-19'); + }); + + it('rejects a month outside 1-12', () => { + const onChange = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Due date'), { target: { value: '13/01/2026' } }); + + expect(onChange).toHaveBeenLastCalledWith(''); + }); + + it('accepts a leap day in a leap year', () => { + const onChange = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Due date'), { target: { value: '02/29/2024' } }); + + expect(onChange).toHaveBeenLastCalledWith('2024-02-29'); + }); + + it('opens the native picker from the calendar button', async () => { + const { container } = render(); + const native = container.querySelector('.date-native'); + native.showPicker = vi.fn(); + + fireEvent.click(screen.getByLabelText('Open calendar')); + + expect(native.showPicker).toHaveBeenCalled(); + }); + + it('focuses the native input when the browser has no picker API', async () => { + const { container } = render(); + const native = container.querySelector('.date-native'); + native.showPicker = vi.fn(() => { throw new Error('not supported'); }); + const focus = vi.spyOn(native, 'focus'); + + fireEvent.click(screen.getByLabelText('Open calendar')); + + expect(focus).toHaveBeenCalled(); + }); + + it('fills the bar from a date picked in the native control', () => { + const onChange = vi.fn(); + const { container } = render(); + + fireEvent.change(container.querySelector('.date-native'), { target: { value: '2026-03-04' } }); + + expect(screen.getByLabelText('Due date')).toHaveValue('03/04/2026'); + expect(onChange).toHaveBeenLastCalledWith('2026-03-04'); + }); + + it('defaults its label', () => { + render(); + + expect(screen.getByLabelText('Date')).toBeInTheDocument(); + }); + + it('still formats when the browser refuses to move the caret', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByLabelText('Due date'); + vi.spyOn(input, 'setSelectionRange').mockImplementation(() => { + throw new Error('not supported on this input type'); + }); + + fireEvent.change(input, { target: { value: '07192026' } }); + + expect(input).toHaveValue('07/19/2026'); + expect(onChange).toHaveBeenLastCalledWith('2026-07-19'); + }); }); diff --git a/src/components/GoogleButton.test.jsx b/src/components/GoogleButton.test.jsx new file mode 100644 index 0000000..d3e9153 --- /dev/null +++ b/src/components/GoogleButton.test.jsx @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, waitFor } from '@testing-library/react'; + +const SCRIPT_SRC = 'https://accounts.google.com/gsi/client'; + +/** + * The client id is read once at module load, so each test re-imports the component with the + * environment it needs. + */ +async function loadButton(clientId) { + vi.resetModules(); + if (clientId === undefined) { + vi.stubEnv('VITE_GOOGLE_CLIENT_ID', ''); + } else { + vi.stubEnv('VITE_GOOGLE_CLIENT_ID', clientId); + } + return (await import('./GoogleButton.jsx')).default; +} + +/** Google Identity Services, reduced to the two calls this component makes. */ +function stubGoogleIdentity() { + const identity = { + initialize: vi.fn(), + renderButton: vi.fn(), + }; + window.google = { accounts: { id: identity } }; + return identity; +} + +/** A matchMedia whose change listeners the test can fire. */ +function stubMatchMedia({ matches = false } = {}) { + const listeners = new Set(); + const media = { + matches, + addEventListener: vi.fn((_event, fn) => listeners.add(fn)), + removeEventListener: vi.fn((_event, fn) => listeners.delete(fn)), + }; + window.matchMedia = vi.fn(() => media); + return { media, fireChange: () => listeners.forEach((fn) => fn({ matches: !matches })) }; +} + +const originalMatchMedia = window.matchMedia; + +beforeEach(() => { + document.documentElement.removeAttribute('data-theme'); + document.querySelectorAll(`script[src="${SCRIPT_SRC}"]`).forEach((s) => s.remove()); + delete window.google; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + window.matchMedia = originalMatchMedia; + delete window.google; +}); + +describe('GoogleButton without a client id', () => { + it('explains what to configure instead of rendering a dead button', async () => { + const GoogleButton = await loadButton(''); + + render(); + + expect(screen.getByText(/VITE_GOOGLE_CLIENT_ID/)).toBeInTheDocument(); + }); + + it('loads nothing from Google', async () => { + const GoogleButton = await loadButton(''); + + render(); + + expect(document.querySelector(`script[src="${SCRIPT_SRC}"]`)).toBeNull(); + }); +}); + +describe('GoogleButton with a client id', () => { + it('injects the Google script once and initializes when it loads', async () => { + const GoogleButton = await loadButton('client-123.apps.googleusercontent.com'); + render(); + + const script = document.querySelector(`script[src="${SCRIPT_SRC}"]`); + expect(script).not.toBeNull(); + expect(script.async).toBe(true); + expect(script.defer).toBe(true); + + const identity = stubGoogleIdentity(); + await act(async () => { script.onload(); }); + + expect(identity.initialize).toHaveBeenCalledWith(expect.objectContaining({ + client_id: 'client-123.apps.googleusercontent.com', + })); + expect(identity.renderButton).toHaveBeenCalled(); + }); + + it('initializes straight away when the script is already on the page', async () => { + const script = document.createElement('script'); + script.src = SCRIPT_SRC; + document.body.appendChild(script); + const identity = stubGoogleIdentity(); + + const GoogleButton = await loadButton('client-123'); + await act(async () => { render(); }); + + expect(identity.initialize).toHaveBeenCalled(); + // No second copy of the script. + expect(document.querySelectorAll(`script[src="${SCRIPT_SRC}"]`)).toHaveLength(1); + }); + + it('hands the returned credential to the caller', async () => { + const onCredential = vi.fn(); + const identity = stubGoogleIdentity(); + const script = document.createElement('script'); + script.src = SCRIPT_SRC; + document.body.appendChild(script); + + const GoogleButton = await loadButton('client-123'); + await act(async () => { render(); }); + + const { callback } = identity.initialize.mock.calls[0][0]; + callback({ credential: 'id-token-abc' }); + + expect(onCredential).toHaveBeenCalledWith('id-token-abc'); + }); + + it('survives a credential arriving with no handler attached', async () => { + const identity = stubGoogleIdentity(); + const script = document.createElement('script'); + script.src = SCRIPT_SRC; + document.body.appendChild(script); + + const GoogleButton = await loadButton('client-123'); + await act(async () => { render(); }); + + const { callback } = identity.initialize.mock.calls[0][0]; + expect(() => callback({ credential: 'id-token-abc' })).not.toThrow(); + }); + + it('does nothing when the script loads but Google never appears', async () => { + const GoogleButton = await loadButton('client-123'); + render(); + + const script = document.querySelector(`script[src="${SCRIPT_SRC}"]`); + + // No window.google — the load handler must not throw. + await act(async () => { expect(() => script.onload()).not.toThrow(); }); + }); +}); + +describe('GoogleButton theming', () => { + async function renderConfigured({ onCredential = vi.fn() } = {}) { + const script = document.createElement('script'); + script.src = SCRIPT_SRC; + document.body.appendChild(script); + const identity = stubGoogleIdentity(); + + const GoogleButton = await loadButton('client-123'); + const view = render(); + await act(async () => {}); + return { identity, view }; + } + + const themeOf = (identity) => + identity.renderButton.mock.calls.at(-1)[1].theme; + + it('uses the dark treatment when the page is explicitly dark', async () => { + document.documentElement.setAttribute('data-theme', 'dark'); + + const { identity } = await renderConfigured(); + + expect(themeOf(identity)).toBe('filled_black'); + }); + + it('uses the light treatment when the page is explicitly light', async () => { + document.documentElement.setAttribute('data-theme', 'light'); + stubMatchMedia({ matches: true }); // an explicit choice must win over the OS + + const { identity } = await renderConfigured(); + + expect(themeOf(identity)).toBe('outline'); + }); + + it('follows the OS preference when no explicit choice has been made', async () => { + stubMatchMedia({ matches: true }); + + const { identity } = await renderConfigured(); + + expect(themeOf(identity)).toBe('filled_black'); + }); + + it('treats an unavailable matchMedia as light rather than failing', async () => { + window.matchMedia = vi.fn(() => { throw new Error('unsupported'); }); + + const { identity } = await renderConfigured(); + + expect(themeOf(identity)).toBe('outline'); + }); + + it('redraws when the theme attribute flips', async () => { + const { identity } = await renderConfigured(); + const before = identity.renderButton.mock.calls.length; + + await act(async () => { + document.documentElement.setAttribute('data-theme', 'dark'); + // MutationObserver callbacks are delivered as microtasks. + await Promise.resolve(); + }); + + await waitFor(() => expect(identity.renderButton.mock.calls.length).toBeGreaterThan(before)); + expect(themeOf(identity)).toBe('filled_black'); + }); + + it('redraws when the OS preference changes', async () => { + const { fireChange } = stubMatchMedia({ matches: false }); + const { identity } = await renderConfigured(); + const before = identity.renderButton.mock.calls.length; + + await act(async () => { fireChange(); }); + + expect(identity.renderButton.mock.calls.length).toBeGreaterThan(before); + }); + + it('stops listening once it unmounts', async () => { + const { media } = stubMatchMedia(); + const { view } = await renderConfigured(); + + view.unmount(); + + expect(media.removeEventListener).toHaveBeenCalled(); + }); + + it('unmounts cleanly when matchMedia was unavailable', async () => { + window.matchMedia = vi.fn(() => { throw new Error('unsupported'); }); + const { view } = await renderConfigured(); + + expect(() => view.unmount()).not.toThrow(); + }); + + it('clamps the button width to the range Google accepts', async () => { + // jsdom reports a zero-width box, so the component falls back to its default. + const { identity } = await renderConfigured(); + + const { width } = identity.renderButton.mock.calls.at(-1)[1]; + expect(width).toBeGreaterThanOrEqual(240); + expect(width).toBeLessThanOrEqual(400); + }); + + it('measures the surrounding box when the layout reports one', async () => { + const spy = vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockReturnValue({ width: 1000, height: 40, top: 0, left: 0, right: 0, bottom: 0 }); + + const { identity } = await renderConfigured(); + + expect(identity.renderButton.mock.calls.at(-1)[1].width).toBe(400); // clamped down + spy.mockRestore(); + }); + + it('skips the redraw when Google is no longer available', async () => { + const { identity } = await renderConfigured(); + const before = identity.renderButton.mock.calls.length; + delete window.google; + + await act(async () => { + document.documentElement.setAttribute('data-theme', 'dark'); + await Promise.resolve(); + }); + + expect(identity.renderButton.mock.calls.length).toBe(before); + }); +}); diff --git a/src/components/KanbanBoard.test.jsx b/src/components/KanbanBoard.test.jsx new file mode 100644 index 0000000..8397e5b --- /dev/null +++ b/src/components/KanbanBoard.test.jsx @@ -0,0 +1,298 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, within, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../lib/apiClient.js', () => ({ + TodoApi: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + changeStatus: vi.fn(), + remove: vi.fn(), + }, + CategoryApi: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, +})); + +import { TodoApi, CategoryApi } from '../lib/apiClient.js'; +import KanbanBoard from './KanbanBoard.jsx'; + +const categories = [ + { id: 1, name: 'Work', color: '#7fb2e6' }, + { id: 2, name: 'Personal', color: '#ef9db4' }, +]; + +const todo = (id, overrides = {}) => ({ + id, + title: `Task ${id}`, + description: '', + status: 0, + priority: 1, + priorityName: 'Medium', + categoryId: 1, + dueDate: null, + isCompleted: false, + concurrencyToken: `token-${id}`, + ...overrides, +}); + +/** The lane section with the given heading, so a card can be located by column. */ +function lane(label) { + return screen.getByRole('heading', { name: label }).closest('.lane'); +} + +// The board toolbar and the category panel both carry a "Category" control and an "Add"/"Close" +// button, so every query below is scoped to the one it means. +const toolbar = () => within(document.querySelector('.board-filter')); +const panel = () => within(document.querySelector('.cat-manager')); +const categoryFilter = () => toolbar().getByLabelText('Category'); + +beforeEach(() => { + vi.clearAllMocks(); + TodoApi.list.mockResolvedValue([]); + CategoryApi.list.mockResolvedValue(categories); +}); + +async function renderBoard() { + render(); + await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument()); +} + +describe('KanbanBoard', () => { + it('shows a loading note until the todos arrive', async () => { + let release; + TodoApi.list.mockReturnValue(new Promise((resolve) => { release = resolve; })); + render(); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + + release([]); + await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument()); + }); + + it('renders the three lanes', async () => { + await renderBoard(); + + expect(screen.getByRole('heading', { name: 'To Do' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'In Progress' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Done' })).toBeInTheDocument(); + }); + + it('buckets each task into its own lane', async () => { + TodoApi.list.mockResolvedValue([ + todo(1, { status: 0 }), + todo(2, { status: 1 }), + todo(3, { status: 2, isCompleted: true }), + ]); + await renderBoard(); + + expect(within(lane('To Do')).getByText('Task 1')).toBeInTheDocument(); + expect(within(lane('In Progress')).getByText('Task 2')).toBeInTheDocument(); + expect(within(lane('Done')).getByText('Task 3')).toBeInTheDocument(); + }); + + it('keeps a task with an unexpected status off the known lanes', async () => { + TodoApi.list.mockResolvedValue([todo(1, { status: 9 })]); + await renderBoard(); + + // It must not silently land in "To Do" — that would misreport the board. + expect(within(lane('To Do')).queryByText('Task 1')).not.toBeInTheDocument(); + expect(screen.getByText('1 tasks · 0 done')).toBeInTheDocument(); + }); + + it('counts the tasks and the completed ones', async () => { + TodoApi.list.mockResolvedValue([ + todo(1), + todo(2, { status: 2, isCompleted: true }), + todo(3, { status: 2, isCompleted: true }), + ]); + await renderBoard(); + + expect(screen.getByText('3 tasks · 2 done')).toBeInTheDocument(); + }); + + it('shows the load error', async () => { + TodoApi.list.mockRejectedValue(new Error('Network is down')); + await renderBoard(); + + expect(screen.getByText('Network is down')).toBeInTheDocument(); + }); +}); + +describe('KanbanBoard category filter', () => { + it('offers every category plus "All"', async () => { + await renderBoard(); + + const filter = categoryFilter(); + expect(within(filter).getByRole('option', { name: 'All categories' })).toBeInTheDocument(); + expect(within(filter).getByRole('option', { name: 'Work' })).toBeInTheDocument(); + expect(within(filter).getByRole('option', { name: 'Personal' })).toBeInTheDocument(); + }); + + it('hides tasks outside the chosen category', async () => { + const user = userEvent.setup(); + TodoApi.list.mockResolvedValue([ + todo(1, { categoryId: 1 }), + todo(2, { categoryId: 2 }), + ]); + await renderBoard(); + + await user.selectOptions(categoryFilter(), '2'); + + expect(screen.queryByText('Task 1')).not.toBeInTheDocument(); + expect(screen.getByText('Task 2')).toBeInTheDocument(); + // The footer still counts the whole board, not the filtered view. + expect(screen.getByText('2 tasks · 0 done')).toBeInTheDocument(); + }); + + it('falls back to "All" when the selected category disappears', async () => { + const user = userEvent.setup(); + TodoApi.list.mockResolvedValue([todo(1, { categoryId: 1 }), todo(2, { categoryId: 2 })]); + await renderBoard(); + + await user.selectOptions(categoryFilter(), '2'); + expect(screen.queryByText('Task 1')).not.toBeInTheDocument(); + + // Someone deletes that category in the manager panel; the board reloads the list. + CategoryApi.list.mockResolvedValue([categories[0]]); + CategoryApi.remove.mockResolvedValue(null); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + await user.click(toolbar().getByRole('button', { name: 'Manage categories' })); + const row = panel().getByText('Personal').closest('li'); + await user.click(within(row).getByRole('button', { name: 'Delete' })); + + // Without the fallback the board would filter on a category that no longer exists + // and show nothing at all. + await waitFor(() => expect(categoryFilter()).toHaveValue('all')); + expect(screen.getByText('Task 1')).toBeInTheDocument(); + }); +}); + +describe('KanbanBoard category manager panel', () => { + it('opens and closes from the toolbar', async () => { + const user = userEvent.setup(); + await renderBoard(); + + await user.click(toolbar().getByRole('button', { name: 'Manage categories' })); + expect(screen.getByRole('heading', { name: 'Categories' })).toBeInTheDocument(); + + await user.click(toolbar().getByRole('button', { name: 'Close' })); + expect(screen.queryByRole('heading', { name: 'Categories' })).not.toBeInTheDocument(); + }); + + it('closes from the panel itself', async () => { + const user = userEvent.setup(); + await renderBoard(); + + await user.click(toolbar().getByRole('button', { name: 'Manage categories' })); + await user.click(panel().getByLabelText('Close')); + + expect(screen.queryByRole('heading', { name: 'Categories' })).not.toBeInTheDocument(); + }); + + it('picks up a newly created category', async () => { + const user = userEvent.setup(); + await renderBoard(); + + await user.click(toolbar().getByRole('button', { name: 'Manage categories' })); + CategoryApi.create.mockResolvedValue({ id: 3, name: 'Errands', color: '#86c97b' }); + CategoryApi.list.mockResolvedValue([...categories, { id: 3, name: 'Errands', color: '#86c97b' }]); + + await user.type(panel().getByLabelText('New category name'), 'Errands'); + await user.click(panel().getByRole('button', { name: 'Add' })); + + await waitFor(() => expect( + within(categoryFilter()).getByRole('option', { name: 'Errands' }) + ).toBeInTheDocument()); + }); +}); + +describe('KanbanBoard drag state', () => { + it('marks the board while a card is being dragged, and clears it afterwards', async () => { + TodoApi.list.mockResolvedValue([todo(1)]); + const { container } = render(); + await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument()); + + const note = screen.getByText('Task 1').closest('.note'); + const dataTransfer = { setData: vi.fn(), effectAllowed: '' }; + + fireEvent.dragStart(note, { dataTransfer }); + expect(container.querySelector('.board').className).toContain('is-dragging'); + expect(dataTransfer.setData).toHaveBeenCalledWith('text/plain', '1'); + + fireEvent.dragEnd(note); + expect(container.querySelector('.board').className).not.toContain('is-dragging'); + }); + + it('moves a card when it is dropped on another lane', async () => { + TodoApi.list.mockResolvedValue([todo(1, { status: 0 })]); + TodoApi.changeStatus.mockResolvedValue(todo(1, { status: 2, isCompleted: true })); + await renderBoard(); + + fireEvent.drop(lane('Done'), { + dataTransfer: { getData: () => '1', dropEffect: '' }, + }); + + await waitFor(() => expect(TodoApi.changeStatus).toHaveBeenCalledWith(1, 2)); + await waitFor(() => expect(within(lane('Done')).getByText('Task 1')).toBeInTheDocument()); + }); +}); + +describe('KanbanBoard task lifecycle', () => { + it('adds a created task to the board', async () => { + const user = userEvent.setup(); + TodoApi.create.mockResolvedValue(todo(9, { title: 'Brand new' })); + await renderBoard(); + + await user.type(screen.getByPlaceholderText(/add a task/i), 'Brand new'); + await user.click(screen.getByRole('button', { name: /add/i })); + + await waitFor(() => expect(screen.getByText('Brand new')).toBeInTheDocument()); + }); + + it('removes a deleted task from the board', async () => { + const user = userEvent.setup(); + TodoApi.list.mockResolvedValue([todo(1)]); + TodoApi.remove.mockResolvedValue(null); + await renderBoard(); + + await user.click(screen.getByLabelText('Delete')); + + await waitFor(() => expect(screen.queryByText('Task 1')).not.toBeInTheDocument()); + expect(TodoApi.remove).toHaveBeenCalledWith(1); + }); + + it('applies an edit to the card', async () => { + const user = userEvent.setup(); + TodoApi.list.mockResolvedValue([todo(1)]); + TodoApi.update.mockResolvedValue(todo(1, { title: 'Renamed' })); + await renderBoard(); + + await user.click(screen.getByLabelText('Edit')); + const title = screen.getByLabelText('Edit title'); + await user.clear(title); + await user.type(title, 'Renamed'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(screen.getByText('Renamed')).toBeInTheDocument()); + }); + + it('explains a concurrency conflict in plain language', async () => { + const user = userEvent.setup(); + TodoApi.list.mockResolvedValue([todo(1)]); + const conflict = new Error('The resource was modified by someone else.'); + conflict.status = 409; + TodoApi.update.mockRejectedValue(conflict); + await renderBoard(); + + await user.click(screen.getByLabelText('Edit')); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText(/changed elsewhere/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/Lane.test.jsx b/src/components/Lane.test.jsx new file mode 100644 index 0000000..62211d4 --- /dev/null +++ b/src/components/Lane.test.jsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Lane from './Lane.jsx'; + +const categories = [{ id: 1, name: 'Work', color: '#7fb2e6' }]; + +const todo = (id, overrides = {}) => ({ + id, + title: `Task ${id}`, + description: '', + status: 0, + priority: 1, + priorityName: 'Medium', + categoryId: 1, + dueDate: null, + isCompleted: false, + concurrencyToken: `token-${id}`, + ...overrides, +}); + +function renderLane({ todos = [], status = 0 } = {}) { + const handlers = { + onDropCard: vi.fn(), + onDragStart: vi.fn(), + onDragEnd: vi.fn(), + onUpdate: vi.fn(), + onDelete: vi.fn(), + }; + + const { container } = render( + + ); + + return { ...handlers, lane: container.querySelector('.lane') }; +} + +/** jsdom implements no drag-and-drop, so the transfer object is supplied explicitly. */ +function dataTransfer(payload = '') { + return { + dropEffect: '', + getData: vi.fn(() => payload), + setData: vi.fn(), + }; +} + +describe('Lane', () => { + it('shows the label and the card count', () => { + renderLane({ todos: [todo(1), todo(2)] }); + + expect(screen.getByRole('heading', { name: 'To Do' })).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('prompts when empty', () => { + renderLane({ todos: [] }); + + expect(screen.getByText('Drop tasks here')).toBeInTheDocument(); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('renders a card per todo', () => { + renderLane({ todos: [todo(1), todo(2)] }); + + expect(screen.getByText('Task 1')).toBeInTheDocument(); + expect(screen.getByText('Task 2')).toBeInTheDocument(); + expect(screen.queryByText('Drop tasks here')).not.toBeInTheDocument(); + }); + + it('highlights while a card is dragged over it', () => { + const { lane } = renderLane(); + + fireEvent.dragOver(lane, { dataTransfer: dataTransfer() }); + + expect(lane.className).toContain('is-over'); + }); + + it('stays highlighted across repeated dragover events', () => { + const { lane } = renderLane(); + const transfer = dataTransfer(); + + fireEvent.dragOver(lane, { dataTransfer: transfer }); + fireEvent.dragOver(lane, { dataTransfer: transfer }); + + expect(lane.className).toContain('is-over'); + }); + + it('drops the highlight when the card leaves', () => { + const { lane } = renderLane(); + + fireEvent.dragOver(lane, { dataTransfer: dataTransfer() }); + fireEvent.dragLeave(lane); + + expect(lane.className).not.toContain('is-over'); + }); + + it('moves the dropped card into this lane', () => { + const { lane, onDropCard } = renderLane({ status: 2 }); + + fireEvent.dragOver(lane, { dataTransfer: dataTransfer() }); + fireEvent.drop(lane, { dataTransfer: dataTransfer('7') }); + + expect(onDropCard).toHaveBeenCalledWith(7, 2); + expect(lane.className).not.toContain('is-over'); + }); + + it('ignores a drop that carries no card id', () => { + const { lane, onDropCard } = renderLane(); + + fireEvent.drop(lane, { dataTransfer: dataTransfer('') }); + + expect(onDropCard).not.toHaveBeenCalled(); + }); + + it('ignores a drop carrying something that is not a card id', () => { + const { lane, onDropCard } = renderLane(); + + fireEvent.drop(lane, { dataTransfer: dataTransfer('not-a-number') }); + + expect(onDropCard).not.toHaveBeenCalled(); + }); + + it('passes the move handler down so a tap-move works too', async () => { + const userEvent = (await import('@testing-library/user-event')).default; + const user = userEvent.setup(); + const { onDropCard } = renderLane({ todos: [todo(3)], status: 0 }); + + await user.click(screen.getByLabelText('Move to another lane')); + await user.click(screen.getByRole('button', { name: '→ Done' })); + + expect(onDropCard).toHaveBeenCalledWith(3, 2); + }); +}); diff --git a/src/components/TaskCard.test.jsx b/src/components/TaskCard.test.jsx new file mode 100644 index 0000000..bf1b10b --- /dev/null +++ b/src/components/TaskCard.test.jsx @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, within, act, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TaskCard from './TaskCard.jsx'; + +const categories = [ + { id: 1, name: 'Work', color: '#7fb2e6' }, + { id: 2, name: 'Personal', color: null }, +]; + +const baseTodo = { + id: 10, + title: 'Write the thing', + description: '', + status: 0, + priority: 1, + priorityName: 'Medium', + categoryId: 1, + dueDate: null, + isCompleted: false, + concurrencyToken: 'token-1', +}; + +function renderCard(todo = {}, props = {}) { + const handlers = { + onUpdate: vi.fn().mockResolvedValue(undefined), + onDelete: vi.fn(), + onMove: vi.fn(), + onDragStart: vi.fn(), + onDragEnd: vi.fn(), + ...props, + }; + + render(); + return handlers; +} + +describe('TaskCard display', () => { + it('shows the title and its category', () => { + renderCard(); + + expect(screen.getByText('Write the thing')).toBeInTheDocument(); + expect(screen.getByText('Work')).toBeInTheDocument(); + }); + + it('falls back to "Uncategorized" when the category is missing', () => { + renderCard({ categoryId: null }); + + expect(screen.getByText('Uncategorized')).toBeInTheDocument(); + }); + + it('falls back to "Uncategorized" when the category was deleted', () => { + renderCard({ categoryId: 999 }); + + expect(screen.getByText('Uncategorized')).toBeInTheDocument(); + }); + + it('hides the notes line when there are none', () => { + renderCard({ description: '' }); + + expect(screen.queryByText(/notes go here/i)).not.toBeInTheDocument(); + }); + + it('shows the notes when there are some', () => { + renderCard({ description: 'notes go here' }); + + expect(screen.getByText('notes go here')).toBeInTheDocument(); + }); + + it('marks a completed task with a check', () => { + renderCard({ isCompleted: true, status: 2 }); + + expect(screen.getByTitle('Done')).toBeInTheDocument(); + }); + + it('leaves an open task unchecked', () => { + renderCard(); + + expect(screen.queryByTitle('Done')).not.toBeInTheDocument(); + }); + + it('shows no due date when the task has none', () => { + renderCard({ dueDate: null }); + + expect(screen.queryByText(/overdue/)).not.toBeInTheDocument(); + }); +}); + +describe('TaskCard due dates', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('flags a past due date as overdue', () => { + renderCard({ dueDate: '2026-06-01T00:00:00Z' }); + + expect(screen.getByText(/overdue/)).toBeInTheDocument(); + }); + + it('does not flag a future due date', () => { + renderCard({ dueDate: '2026-07-01T00:00:00Z' }); + + expect(screen.queryByText(/overdue/)).not.toBeInTheDocument(); + }); + + it('does not call a completed task overdue', () => { + renderCard({ dueDate: '2026-06-01T00:00:00Z', isCompleted: true }); + + expect(screen.queryByText(/overdue/)).not.toBeInTheDocument(); + }); +}); + +describe('TaskCard actions', () => { + it('deletes on the delete control', async () => { + const user = userEvent.setup(); + const { onDelete } = renderCard(); + + await user.click(screen.getByLabelText('Delete')); + + expect(onDelete).toHaveBeenCalledWith(10); + }); + + it('reports the drag so the board can highlight the lanes', () => { + const { onDragStart } = renderCard(); + const note = screen.getByText('Write the thing').closest('.note'); + + // jsdom has no drag support, so the handler is invoked the way React would. + const dataTransfer = { setData: vi.fn(), effectAllowed: '' }; + fireEvent.dragStart(note, { dataTransfer }); + + expect(onDragStart).toHaveBeenCalled(); + }); + + it('reports the end of a drag', () => { + const { onDragEnd } = renderCard(); + const note = screen.getByText('Write the thing').closest('.note'); + + fireEvent.dragEnd(note); + + expect(onDragEnd).toHaveBeenCalled(); + }); +}); + +describe('TaskCard tap-to-move', () => { + // Native HTML5 drag events are mouse-only, so touch devices need this control. + it('offers the other lanes and not the current one', async () => { + const user = userEvent.setup(); + renderCard({ status: 0 }); + + await user.click(screen.getByLabelText('Move to another lane')); + + const group = screen.getByRole('group', { name: 'Move this task to' }); + expect(within(group).getByRole('button', { name: '→ In Progress' })).toBeInTheDocument(); + expect(within(group).getByRole('button', { name: '→ Done' })).toBeInTheDocument(); + expect(within(group).queryByRole('button', { name: '→ To Do' })).not.toBeInTheDocument(); + }); + + it('moves the task and closes the control', async () => { + const user = userEvent.setup(); + const { onMove } = renderCard({ status: 0 }); + + await user.click(screen.getByLabelText('Move to another lane')); + await user.click(screen.getByRole('button', { name: '→ Done' })); + + expect(onMove).toHaveBeenCalledWith(10, 2); + expect(screen.queryByRole('group', { name: 'Move this task to' })).not.toBeInTheDocument(); + }); + + it('toggles closed again', async () => { + const user = userEvent.setup(); + renderCard(); + + await user.click(screen.getByLabelText('Move to another lane')); + await user.click(screen.getByLabelText('Move to another lane')); + + expect(screen.queryByRole('group', { name: 'Move this task to' })).not.toBeInTheDocument(); + }); +}); + +describe('TaskCard editing', () => { + it('saves the edited fields with the concurrency token the card was rendered with', async () => { + const user = userEvent.setup(); + const { onUpdate } = renderCard({ description: 'old notes' }); + + await user.click(screen.getByLabelText('Edit')); + const title = screen.getByLabelText('Edit title'); + await user.clear(title); + await user.type(title, 'New title'); + await user.selectOptions(screen.getByLabelText('Edit category'), '2'); + await user.selectOptions(screen.getByLabelText('Edit priority'), '2'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onUpdate).toHaveBeenCalledWith(10, expect.objectContaining({ + title: 'New title', + description: 'old notes', + priority: 2, + categoryId: 2, + dueDate: null, + concurrencyToken: 'token-1', + })); + // Back to the read view: the card re-renders from props, which the parent owns. + expect(screen.queryByLabelText('Edit title')).not.toBeInTheDocument(); + }); + + it('sends null for cleared notes and no category', async () => { + const user = userEvent.setup(); + const { onUpdate } = renderCard({ description: 'old notes' }); + + await user.click(screen.getByLabelText('Edit')); + await user.clear(screen.getByLabelText('Edit notes')); + await user.selectOptions(screen.getByLabelText('Edit category'), ''); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onUpdate).toHaveBeenCalledWith(10, expect.objectContaining({ + description: null, + categoryId: null, + })); + }); + + it('trims whitespace off the title', async () => { + const user = userEvent.setup(); + const { onUpdate } = renderCard(); + + await user.click(screen.getByLabelText('Edit')); + const title = screen.getByLabelText('Edit title'); + await user.clear(title); + await user.type(title, ' Trimmed '); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onUpdate).toHaveBeenCalledWith(10, expect.objectContaining({ title: 'Trimmed' })); + }); + + it('refuses to save a blank title', async () => { + const user = userEvent.setup(); + const { onUpdate } = renderCard(); + + await user.click(screen.getByLabelText('Edit')); + await user.clear(screen.getByLabelText('Edit title')); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onUpdate).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Edit title')).toBeInTheDocument(); // still editing + }); + + it('discards the draft on cancel', async () => { + const user = userEvent.setup(); + const { onUpdate } = renderCard(); + + await user.click(screen.getByLabelText('Edit')); + await user.type(screen.getByLabelText('Edit title'), ' extra'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onUpdate).not.toHaveBeenCalled(); + expect(screen.getByText('Write the thing')).toBeInTheDocument(); + }); + + it('disables both buttons while the save is in flight, then closes the editor', async () => { + const user = userEvent.setup(); + let finishSave; + const onUpdate = vi.fn(() => new Promise((resolve) => { finishSave = resolve; })); + renderCard({}, { onUpdate }); + + await user.click(screen.getByLabelText('Edit')); + await user.click(screen.getByRole('button', { name: 'Save' })); + + // A second click would send a second update with the same concurrency token. + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + await act(async () => { finishSave(); }); + + expect(screen.queryByLabelText('Edit title')).not.toBeInTheDocument(); + }); + + it('seeds the date field from an existing due date', async () => { + const user = userEvent.setup(); + renderCard({ dueDate: '2026-07-19T00:00:00Z' }); + + await user.click(screen.getByLabelText('Edit')); + + expect(screen.getByLabelText('Edit due date')).toHaveValue('07/19/2026'); + }); + + it('sends a due date typed into the date bar', async () => { + const user = userEvent.setup(); + const { onUpdate } = renderCard(); + + await user.click(screen.getByLabelText('Edit')); + await user.type(screen.getByLabelText('Edit due date'), '07192026'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onUpdate).toHaveBeenCalledWith(10, expect.objectContaining({ + dueDate: new Date('2026-07-19').toISOString(), + })); + }); + + it('clears a due date that is emptied out', async () => { + const user = userEvent.setup(); + const { onUpdate } = renderCard({ dueDate: '2026-07-19T00:00:00Z' }); + + await user.click(screen.getByLabelText('Edit')); + await user.clear(screen.getByLabelText('Edit due date')); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onUpdate).toHaveBeenCalledWith(10, expect.objectContaining({ dueDate: null })); + }); +}); diff --git a/src/components/ThemeToggle.test.jsx b/src/components/ThemeToggle.test.jsx index 3764f0a..2a90b34 100644 --- a/src/components/ThemeToggle.test.jsx +++ b/src/components/ThemeToggle.test.jsx @@ -22,4 +22,47 @@ describe('', () => { expect(document.documentElement.getAttribute('data-theme')).toBe('light'); expect(localStorage.getItem('todo.theme')).toBe('light'); }); + + it('starts dark when that was the stored choice', () => { + localStorage.setItem('todo.theme', 'dark'); + + render(); + + expect(screen.getByRole('button', { name: /switch to light mode/i })).toBeInTheDocument(); + }); + + it('starts light when that was the stored choice, whatever the OS says', () => { + localStorage.setItem('todo.theme', 'light'); + window.matchMedia = () => ({ matches: true, addEventListener() {}, removeEventListener() {} }); + + render(); + + expect(screen.getByRole('button', { name: /switch to dark mode/i })).toBeInTheDocument(); + }); + + it('follows the OS preference until a choice is made', () => { + window.matchMedia = () => ({ matches: true, addEventListener() {}, removeEventListener() {} }); + + render(); + + expect(screen.getByRole('button', { name: /switch to light mode/i })).toBeInTheDocument(); + }); + + it('treats an unavailable matchMedia as light rather than failing', () => { + window.matchMedia = () => { throw new Error('unsupported'); }; + + render(); + + expect(screen.getByRole('button', { name: /switch to dark mode/i })).toBeInTheDocument(); + }); + + it('toggles back to light', async () => { + localStorage.setItem('todo.theme', 'dark'); + render(); + + await userEvent.click(screen.getByRole('button', { name: /switch to light mode/i })); + + expect(localStorage.getItem('todo.theme')).toBe('light'); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); }); diff --git a/src/components/TodoForm.test.jsx b/src/components/TodoForm.test.jsx index 449fc4b..9dfb067 100644 --- a/src/components/TodoForm.test.jsx +++ b/src/components/TodoForm.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import TodoForm from './TodoForm.jsx'; @@ -20,4 +20,65 @@ describe('', () => { expect(onCreate).toHaveBeenCalledTimes(1); expect(onCreate.mock.calls[0][0]).toMatchObject({ title: 'Buy milk' }); }); + + it('sends every field and then clears the form', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByLabelText('Title'), 'Buy milk'); + await userEvent.type(screen.getByLabelText('Description'), ' two litres '); + await userEvent.selectOptions(screen.getByLabelText('Category'), '1'); + await userEvent.selectOptions(screen.getByLabelText('Priority'), '2'); + await userEvent.type(screen.getByLabelText('Due date'), '07192026'); + await userEvent.click(screen.getByRole('button', { name: /^add$/i })); + + expect(onCreate).toHaveBeenCalledWith({ + title: 'Buy milk', + description: 'two litres', + priority: 2, + categoryId: 1, + dueDate: new Date('2026-07-19').toISOString(), + }); + expect(screen.getByLabelText('Title')).toHaveValue(''); + }); + + it('sends nulls for the optional fields left blank', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByLabelText('Title'), 'Bare task'); + await userEvent.click(screen.getByRole('button', { name: /^add$/i })); + + expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ + description: null, + categoryId: null, + dueDate: null, + })); + }); + + it('shows the failure and keeps what was typed', async () => { + const onCreate = vi.fn().mockRejectedValue(new Error('Title must be under 200 characters.')); + render(); + + await userEvent.type(screen.getByLabelText('Title'), 'Too long'); + await userEvent.click(screen.getByRole('button', { name: /^add$/i })); + + expect(await screen.findByText('Title must be under 200 characters.')).toBeInTheDocument(); + expect(screen.getByLabelText('Title')).toHaveValue('Too long'); + }); + + it('disables the button while the create is in flight', async () => { + let finish; + const onCreate = vi.fn(() => new Promise((resolve) => { finish = resolve; })); + render(); + + await userEvent.type(screen.getByLabelText('Title'), 'Slow one'); + await userEvent.click(screen.getByRole('button', { name: /^add$/i })); + + expect(screen.getByRole('button', { name: /adding/i })).toBeDisabled(); + + await act(async () => { finish(); }); + + expect(screen.getByRole('button', { name: /^add$/i })).toBeEnabled(); + }); }); diff --git a/src/hooks/useCategories.test.jsx b/src/hooks/useCategories.test.jsx new file mode 100644 index 0000000..bd8afb0 --- /dev/null +++ b/src/hooks/useCategories.test.jsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +vi.mock('../lib/apiClient.js', () => ({ + CategoryApi: { list: vi.fn() }, +})); + +import { CategoryApi } from '../lib/apiClient.js'; +import { useCategories } from './useCategories.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useCategories()', () => { + it('starts empty and loads on mount', async () => { + CategoryApi.list.mockResolvedValue([{ id: 1, name: 'Work' }]); + + const { result } = renderHook(() => useCategories()); + + expect(result.current.categories).toEqual([]); + await waitFor(() => expect(result.current.categories).toHaveLength(1)); + expect(CategoryApi.list).toHaveBeenCalledTimes(1); + }); + + it('refetches on reload', async () => { + CategoryApi.list.mockResolvedValue([{ id: 1, name: 'Work' }]); + const { result } = renderHook(() => useCategories()); + await waitFor(() => expect(result.current.categories).toHaveLength(1)); + + CategoryApi.list.mockResolvedValue([{ id: 1, name: 'Work' }, { id: 2, name: 'Personal' }]); + await act(async () => { await result.current.reload(); }); + + expect(result.current.categories).toHaveLength(2); + expect(CategoryApi.list).toHaveBeenCalledTimes(2); + }); + + it('keeps a stable reload identity so effects do not re-run', async () => { + CategoryApi.list.mockResolvedValue([]); + const { result, rerender } = renderHook(() => useCategories()); + await waitFor(() => expect(CategoryApi.list).toHaveBeenCalled()); + + const first = result.current.reload; + rerender(); + + expect(result.current.reload).toBe(first); + expect(CategoryApi.list).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useTodos.test.jsx b/src/hooks/useTodos.test.jsx index 7758998..3e17803 100644 --- a/src/hooks/useTodos.test.jsx +++ b/src/hooks/useTodos.test.jsx @@ -60,3 +60,141 @@ describe('useTodos()', () => { expect(result.current.error).toBe('boom'); }); }); + +describe('useTodos() mutations', () => { + it('surfaces a failed initial load', async () => { + TodoApi.list.mockRejectedValue(new Error('Network is down')); + const { result } = renderHook(() => useTodos()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBe('Network is down'); + expect(result.current.todos).toEqual([]); + }); + + it('ignores a move to the lane the card is already in', async () => { + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.moveCard(1, 0); }); + + expect(TodoApi.changeStatus).not.toHaveBeenCalled(); + }); + + it('ignores a move for a card that is not on the board', async () => { + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.moveCard(999, 2); }); + + expect(TodoApi.changeStatus).not.toHaveBeenCalled(); + }); + + it('keeps the optimistic move when the server returns nothing to reconcile', async () => { + TodoApi.changeStatus.mockResolvedValue(null); + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.moveCard(1, 2); }); + + expect(result.current.todos.find((t) => t.id === 1).status).toBe(2); + expect(TodoApi.list).toHaveBeenCalledTimes(1); // no reload + }); + + it('appends a created todo without refetching the board', async () => { + TodoApi.create.mockResolvedValue({ id: 3, title: 'C', status: 0 }); + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.createTodo({ title: 'C' }); }); + + expect(result.current.todos).toHaveLength(3); + expect(TodoApi.list).toHaveBeenCalledTimes(1); + }); + + it('reloads when a create returns nothing to append', async () => { + TodoApi.create.mockResolvedValue(null); + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.createTodo({ title: 'C' }); }); + + expect(TodoApi.list).toHaveBeenCalledTimes(2); + }); + + it('lets a failed create reach the caller so the form can show it', async () => { + TodoApi.create.mockRejectedValue(new Error('Title is required.')); + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await expect(result.current.createTodo({ title: '' })).rejects.toThrow('Title is required.'); + }); + + it('merges an updated todo into place', async () => { + TodoApi.update.mockResolvedValue({ id: 1, title: 'Renamed' }); + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.updateTodo(1, { title: 'Renamed' }); }); + + expect(result.current.todos.find((t) => t.id === 1).title).toBe('Renamed'); + expect(TodoApi.list).toHaveBeenCalledTimes(1); + }); + + it('reloads when an update returns nothing to merge', async () => { + TodoApi.update.mockResolvedValue(null); + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.updateTodo(1, { title: 'Renamed' }); }); + + expect(TodoApi.list).toHaveBeenCalledTimes(2); + }); + + it('reloads and explains a 409 rather than showing the raw message', async () => { + const conflict = new Error('The resource was modified by someone else.'); + conflict.status = 409; + TodoApi.update.mockRejectedValue(conflict); + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.updateTodo(1, { title: 'Renamed' }); }); + + expect(TodoApi.list).toHaveBeenCalledTimes(2); + expect(result.current.error).toMatch(/changed elsewhere/i); + }); + + it('shows any other update failure as-is without reloading', async () => { + TodoApi.update.mockRejectedValue(new Error('Title is required.')); + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.updateTodo(1, { title: '' }); }); + + expect(result.current.error).toBe('Title is required.'); + expect(TodoApi.list).toHaveBeenCalledTimes(1); + }); + + it('removes a deleted todo immediately', async () => { + TodoApi.remove.mockResolvedValue(null); + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.deleteTodo(1); }); + + expect(result.current.todos.map((t) => t.id)).toEqual([2]); + expect(TodoApi.list).toHaveBeenCalledTimes(1); + }); + + it('puts a failed delete back by reloading', async () => { + TodoApi.remove.mockRejectedValue(new Error('Gone already')); + const { result } = renderHook(() => useTodos()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.deleteTodo(1); }); + + expect(TodoApi.list).toHaveBeenCalledTimes(2); + expect(result.current.todos).toHaveLength(2); + expect(result.current.error).toBe('Gone already'); + }); +}); diff --git a/src/lib/apiClient.test.js b/src/lib/apiClient.test.js new file mode 100644 index 0000000..fe3d3cf --- /dev/null +++ b/src/lib/apiClient.test.js @@ -0,0 +1,450 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Every test re-imports the module so the in-memory access token, the shared in-flight +// refresh, and the registered callbacks all start clean. +async function loadClient() { + vi.resetModules(); + return import('./apiClient.js'); +} + +/** A fetch response stub. `body` is serialised unless it is already a string. */ +function respond(status, body = null, { text } = {}) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => (text !== undefined ? text : body === null ? '' : JSON.stringify(body)), + }; +} + +let fetchMock; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('request plumbing', () => { + it('sends JSON and credentials, and returns the parsed body', async () => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(200, [{ id: 1 }])); + + const todos = await TodoApi.list(); + + expect(todos).toEqual([{ id: 1 }]); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/todos'); + expect(init.credentials).toBe('include'); + expect(init.headers['Content-Type']).toBe('application/json'); + }); + + it('omits the Authorization header until a session is set', async () => { + const { TodoApi, setSession } = await loadClient(); + fetchMock.mockResolvedValue(respond(200, [])); + + await TodoApi.list(); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBeUndefined(); + + setSession({ accessToken: 'token-abc' }); + await TodoApi.list(); + expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer token-abc'); + }); + + it('drops the Authorization header again once the session is cleared', async () => { + const { TodoApi, setSession, clearSession } = await loadClient(); + fetchMock.mockResolvedValue(respond(200, [])); + + setSession({ accessToken: 'token-abc' }); + clearSession(); + await TodoApi.list(); + + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBeUndefined(); + }); + + it('treats 204 as no content', async () => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(204)); + + await expect(TodoApi.remove(1)).resolves.toBeNull(); + }); + + it('treats an empty body as no content', async () => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(200, null, { text: '' })); + + await expect(TodoApi.list()).resolves.toBeNull(); + }); + + it('hasSession is always true — the refresh cookie is httpOnly and cannot be inspected', async () => { + const { hasSession } = await loadClient(); + + expect(hasSession()).toBe(true); + }); +}); + +describe('error mapping', () => { + it('prefers the problem title', async () => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(400, { title: 'Bad input', detail: 'ignored' })); + + await expect(TodoApi.list()).rejects.toThrow('Bad input'); + }); + + it('falls back to the problem detail', async () => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(409, { detail: 'Already exists' })); + + await expect(TodoApi.list()).rejects.toThrow('Already exists'); + }); + + it('falls back to the status code when the body carries neither', async () => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(500, {})); + + await expect(TodoApi.list()).rejects.toThrow('Request failed (500)'); + }); + + it('falls back to the status code when there is no body at all', async () => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(500, null, { text: '' })); + + await expect(TodoApi.list()).rejects.toThrow('Request failed (500)'); + }); + + it('carries the status and the problem document on the error', async () => { + const { TodoApi } = await loadClient(); + const problem = { title: 'Conflict', current: { id: 1 } }; + fetchMock.mockResolvedValue(respond(409, problem)); + + await expect(TodoApi.list()).rejects.toMatchObject({ status: 409, problem }); + }); +}); + +describe('401 refresh-and-retry', () => { + it('refreshes once and replays the original request', async () => { + const { TodoApi } = await loadClient(); + fetchMock + .mockResolvedValueOnce(respond(401, { title: 'Expired' })) + .mockResolvedValueOnce(respond(200, { accessToken: 'fresh' })) // the refresh + .mockResolvedValueOnce(respond(200, [{ id: 7 }])); // the replay + + await expect(TodoApi.list()).resolves.toEqual([{ id: 7 }]); + + const [, refreshInit] = fetchMock.mock.calls[1]; + expect(refreshInit.method).toBe('POST'); + // The header's presence is the CSRF proof; its value is irrelevant. + expect(refreshInit.headers['X-Refresh-CSRF']).toBeDefined(); + // The replay carries the token the refresh returned. + expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe('Bearer fresh'); + }); + + it('gives up and notifies when the refresh itself fails', async () => { + const { TodoApi, setOnUnauthorized, setSession } = await loadClient(); + const onUnauthorized = vi.fn(); + setOnUnauthorized(onUnauthorized); + setSession({ accessToken: 'stale' }); + + fetchMock + .mockResolvedValueOnce(respond(401, { title: 'Expired' })) + .mockResolvedValueOnce(respond(401, { title: 'No cookie' })); // the refresh + + await expect(TodoApi.list()).rejects.toThrow('Expired'); + + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); // no replay after a failed refresh + }); + + it('does not require an unauthorized handler to be registered', async () => { + const { TodoApi } = await loadClient(); + fetchMock + .mockResolvedValueOnce(respond(401, { title: 'Expired' })) + .mockResolvedValueOnce(respond(401, { title: 'No cookie' })); + + await expect(TodoApi.list()).rejects.toThrow('Expired'); + }); + + it('shares one refresh between requests that 401 at the same instant', async () => { + const { TodoApi } = await loadClient(); + let releaseRefresh; + const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; }); + + fetchMock.mockImplementation(async (url) => { + if (url.endsWith('/api/auth/refresh')) { + await refreshGate; + return respond(200, { accessToken: 'fresh' }); + } + // First call from each of the two requests 401s; the replays succeed. + return fetchMock.mock.calls.filter((c) => !c[0].endsWith('/api/auth/refresh')).length <= 2 + ? respond(401, { title: 'Expired' }) + : respond(200, []); + }); + + const both = Promise.all([TodoApi.list(), TodoApi.list()]); + await Promise.resolve(); + releaseRefresh(); + await both; + + // Two POSTs of the same rotating refresh token would look like reuse to the backend + // and revoke every session. + const refreshCalls = fetchMock.mock.calls.filter((c) => c[0].endsWith('/api/auth/refresh')); + expect(refreshCalls).toHaveLength(1); + }); + + it('allows a fresh refresh after the in-flight one settles', async () => { + const { AuthApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(200, { accessToken: 'fresh' })); + + await expect(AuthApi.refresh()).resolves.toBe(true); + await expect(AuthApi.refresh()).resolves.toBe(true); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('reports a failed refresh as not signed in', async () => { + const { AuthApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(401, { title: 'No cookie' })); + + await expect(AuthApi.refresh()).resolves.toBe(false); + }); +}); + +describe('cold-start resilience', () => { + // Azure's Free tier unloads the app after idle, so the first request after a quiet spell + // can take a minute. These paths are the difference between that and "Failed to fetch". + beforeEach(() => { + vi.useFakeTimers(); + }); + + /** Runs `promise` to completion, flushing the backoff sleeps as they are scheduled. */ + async function withBackoffFlushed(promise) { + const settled = promise.then( + (value) => ({ value }), + (error) => ({ error }), + ); + + let done = false; + settled.then(() => { done = true; }); + + // Each iteration lets pending microtasks run, then fires whatever sleep they queued. + for (let i = 0; i < 40 && !done; i++) { + await vi.advanceTimersByTimeAsync(20000); + } + + const outcome = await settled; + if (outcome.error) throw outcome.error; + return outcome.value; + } + + it.each([502, 503, 504])('retries a %i while the instance is starting up', async (status) => { + const { TodoApi, setOnServerWaking } = await loadClient(); + const onWaking = vi.fn(); + setOnServerWaking(onWaking); + + fetchMock + .mockResolvedValueOnce(respond(status)) + .mockResolvedValueOnce(respond(200, [])); + + await expect(withBackoffFlushed(TodoApi.list())).resolves.toEqual([]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + // The UI is told once that it is waiting, and once that the wait is over. + expect(onWaking.mock.calls).toEqual([[true], [false]]); + }); + + it('retries a network error until the server answers', async () => { + const { TodoApi, setOnServerWaking } = await loadClient(); + const onWaking = vi.fn(); + setOnServerWaking(onWaking); + + fetchMock + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValueOnce(respond(200, [{ id: 1 }])); + + await expect(withBackoffFlushed(TodoApi.list())).resolves.toEqual([{ id: 1 }]); + + expect(onWaking.mock.calls).toEqual([[true], [false]]); + }); + + it('gives up on a network error once the retry budget is spent', async () => { + const { TodoApi, setOnServerWaking } = await loadClient(); + const onWaking = vi.fn(); + setOnServerWaking(onWaking); + + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + await expect(withBackoffFlushed(TodoApi.list())).rejects.toThrow('Failed to fetch'); + + // The first attempt plus WAKE_MAX_RETRIES more. + expect(fetchMock).toHaveBeenCalledTimes(7); + expect(onWaking).toHaveBeenLastCalledWith(false); + }); + + it('surfaces a persistent 503 as an error rather than retrying forever', async () => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(503, { title: 'Service Unavailable' })); + + await expect(withBackoffFlushed(TodoApi.list())).rejects.toThrow('Service Unavailable'); + + expect(fetchMock).toHaveBeenCalledTimes(7); + }); + + it('does not signal the UI when the very first attempt succeeds', async () => { + const { TodoApi, setOnServerWaking } = await loadClient(); + const onWaking = vi.fn(); + setOnServerWaking(onWaking); + + fetchMock.mockResolvedValue(respond(200, [])); + + await withBackoffFlushed(TodoApi.list()); + + expect(onWaking).not.toHaveBeenCalled(); + }); + + it('works with no waking handler registered', async () => { + const { TodoApi } = await loadClient(); + fetchMock + .mockResolvedValueOnce(respond(503)) + .mockResolvedValueOnce(respond(200, [])); + + await expect(withBackoffFlushed(TodoApi.list())).resolves.toEqual([]); + }); +}); + +describe('AuthApi', () => { + it.each([ + ['register', ['a@b.com', 'pw'], '/api/auth/register', { email: 'a@b.com', password: 'pw' }], + ['login', ['a@b.com', 'pw'], '/api/auth/login', { email: 'a@b.com', password: 'pw' }], + ['google', ['id-token'], '/api/auth/google', { idToken: 'id-token' }], + ])('%s posts to %s and adopts the returned session', async (method, args, path, body) => { + const { AuthApi, TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(200, { accessToken: 'new-token', user: { id: 1 } })); + + const auth = await AuthApi[method](...args); + + expect(auth.user).toEqual({ id: 1 }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(path); + expect(JSON.parse(init.body)).toEqual(body); + // credentials: 'include' so the browser stores the refresh cookie the server sets. + expect(init.credentials).toBe('include'); + // No Authorization header on an anonymous endpoint. + expect(init.headers.Authorization).toBeUndefined(); + + fetchMock.mockClear(); + fetchMock.mockResolvedValue(respond(200, [])); + await TodoApi.list(); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer new-token'); + }); + + it('surfaces a rejected sign-in', async () => { + const { AuthApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(401, { title: 'Invalid email or password.' })); + + await expect(AuthApi.login('a@b.com', 'wrong')).rejects.toThrow('Invalid email or password.'); + }); + + it('me() reads the current profile', async () => { + const { AuthApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(200, { id: 1, email: 'a@b.com' })); + + await expect(AuthApi.me()).resolves.toEqual({ id: 1, email: 'a@b.com' }); + expect(fetchMock.mock.calls[0][0]).toBe('/api/auth/me'); + }); + + it.each(['logout', 'revokeAll'])('%s clears the local session even when the call fails', async (method) => { + const { AuthApi, TodoApi, setSession } = await loadClient(); + setSession({ accessToken: 'token-abc' }); + fetchMock.mockResolvedValue(respond(500, { title: 'Boom' })); + + await expect(AuthApi[method]()).rejects.toThrow('Boom'); + + // The user asked to be signed out; a server error must not leave the token in memory. + fetchMock.mockClear(); + fetchMock.mockResolvedValue(respond(200, [])); + await TodoApi.list(); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBeUndefined(); + }); + + it.each([ + ['logout', '/api/auth/logout'], + ['revokeAll', '/api/auth/revoke-all'], + ])('%s posts to %s', async (method, path) => { + const { AuthApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(204)); + + await AuthApi[method](); + + expect(fetchMock.mock.calls[0][0]).toBe(path); + expect(fetchMock.mock.calls[0][1].method).toBe('POST'); + }); +}); + +describe('CategoryApi', () => { + it.each([ + ['list', [], 'GET', '/api/categories', undefined], + ['create', [{ name: 'Work' }], 'POST', '/api/categories', { name: 'Work' }], + ['update', [3, { name: 'Study' }], 'PUT', '/api/categories/3', { name: 'Study' }], + ['remove', [3], 'DELETE', '/api/categories/3', undefined], + ])('%s issues %s %s', async (method, args, verb, path, body) => { + const { CategoryApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(204)); + + await CategoryApi[method](...args); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(path); + expect(init.method ?? 'GET').toBe(verb); + expect(init.body ? JSON.parse(init.body) : undefined).toEqual(body); + }); +}); + +describe('TodoApi', () => { + it.each([ + ['no filter or search', [], '/api/todos'], + ['the default All filter dropped', ['All'], '/api/todos'], + ['a filter', ['Active'], '/api/todos?filter=Active'], + ['a search term', ['All', 'milk'], '/api/todos?search=milk'], + ['both', ['Completed', 'milk'], '/api/todos?filter=Completed&search=milk'], + ['an empty filter', ['', 'milk'], '/api/todos?search=milk'], + ])('list() with %s requests %s', async (_label, args, expected) => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(200, [])); + + await TodoApi.list(...args); + + expect(fetchMock.mock.calls[0][0]).toBe(expected); + }); + + it.each([ + ['create', [{ title: 'A' }], 'POST', '/api/todos', { title: 'A' }], + ['update', [5, { title: 'B' }], 'PUT', '/api/todos/5', { title: 'B' }], + ['remove', [5], 'DELETE', '/api/todos/5', undefined], + ])('%s issues %s %s', async (method, args, verb, path, body) => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(204)); + + await TodoApi[method](...args); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(path); + expect(init.method).toBe(verb); + expect(init.body ? JSON.parse(init.body) : undefined).toEqual(body); + }); + + it('changeStatus sends the concurrency token so a stale move is rejected', async () => { + const { TodoApi } = await loadClient(); + fetchMock.mockResolvedValue(respond(200, { id: 5 })); + + await TodoApi.changeStatus(5, 2, 'token-xyz'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/todos/5/status'); + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body)).toEqual({ status: 2, concurrencyToken: 'token-xyz' }); + }); +}); diff --git a/src/lib/colors.test.js b/src/lib/colors.test.js index bb56e26..317fcb0 100644 --- a/src/lib/colors.test.js +++ b/src/lib/colors.test.js @@ -56,3 +56,49 @@ describe('hexToHsv()', () => { expect(hexToHsv('nope')).toBe(null); }); }); + +describe('hsvToHex() across the hue circle', () => { + it('covers every sixth of the circle', () => { + expect(hsvToHex(0, 1, 1)).toBe('#ff0000'); // 0-60 + expect(hsvToHex(90, 1, 1)).toBe('#80ff00'); // 60-120 + expect(hsvToHex(150, 1, 1)).toBe('#00ff80'); // 120-180 + expect(hsvToHex(210, 1, 1)).toBe('#0080ff'); // 180-240 + expect(hsvToHex(270, 1, 1)).toBe('#8000ff'); // 240-300 + expect(hsvToHex(330, 1, 1)).toBe('#ff0080'); // 300-360 + }); + + it('wraps a hue outside 0-360', () => { + expect(hsvToHex(360, 1, 1)).toBe(hsvToHex(0, 1, 1)); + expect(hsvToHex(-30, 1, 1)).toBe(hsvToHex(330, 1, 1)); + }); + + it('clamps saturation and value into range', () => { + expect(hsvToHex(0, 2, 2)).toBe(hsvToHex(0, 1, 1)); + expect(hsvToHex(0, -1, -1)).toBe('#000000'); + }); + +}); + +describe('hexToHsv() around the circle', () => { + it('finds the hue whichever channel is brightest', () => { + expect(hexToHsv('#ff0000').h).toBeCloseTo(0); // red is max + expect(hexToHsv('#00ff00').h).toBeCloseTo(120); // green is max + expect(hexToHsv('#0000ff').h).toBeCloseTo(240); // blue is max + }); + + it('wraps a negative hue back into 0-360', () => { + // Red is max and blue exceeds green, which computes a negative hue first. + expect(hexToHsv('#ff00ff').h).toBeCloseTo(300); + }); + + it('reports no hue for greys', () => { + expect(hexToHsv('#808080')).toMatchObject({ h: 0, s: 0 }); + expect(hexToHsv('#000000')).toMatchObject({ h: 0, s: 0, v: 0 }); + }); + + it.each([null, undefined, ''])('treats %s as invalid rather than throwing', (input) => { + expect(hexToHsv(input)).toBe(null); + expect(isValidHexColor(input)).toBe(false); + expect(tint(input)).toBe(tint('#64748b')); + }); +}); diff --git a/vite.config.js b/vite.config.js index 1b7e0d5..6c1d591 100644 --- a/vite.config.js +++ b/vite.config.js @@ -19,5 +19,12 @@ export default defineConfig({ globals: true, setupFiles: './src/test/setup.js', css: false, + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary', 'json'], + include: ['src/**/*.{js,jsx}'], + // main.jsx only mounts the app into the DOM, and setup.js is the harness itself. + exclude: ['src/main.jsx', 'src/test/**', 'src/**/*.test.{js,jsx}'], + }, }, });