diff --git a/.dockerignore b/.dockerignore index d0f3243..05172ce 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,11 @@ **/.ruff_cache **/.mypy_cache backend/tests +**/.coverage +**/htmlcov +**/coverage +backend/pytest.xml +backend/pytest-coverage.txt **/*.log **/.DS_Store .env diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4048083..a5b20e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,12 @@ on: - 'backend/alembic.ini' - 'backend/pyproject.toml' - 'backend/uv.lock' + - 'frontend/src/**' + - 'frontend/package.json' + - 'frontend/bun.lock' + - 'frontend/vitest.config.ts' + - 'frontend/tsconfig*.json' + - 'docs/api/openapi.yml' - '.github/workflows/test.yml' push: branches: [main] @@ -78,3 +84,25 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: OpenAPI spec is in sync with FastAPI app run: uv run python -m scripts.dump_openapi --check + + frontend-vitest: + name: Frontend vitest + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - name: Run vitest with coverage + # CI=true switches reporters to include junit + run: bunx vitest run --coverage + - name: Coverage report + if: always() + uses: davelosert/vitest-coverage-report-action@v2 + with: + working-directory: frontend + name: Frontend coverage + # default 'pr' no-ops on push, but be explicit to mirror backend job + comment-on: ${{ github.event_name == 'pull_request' && 'pr' || 'none' }} diff --git a/frontend/.dockerignore b/frontend/.dockerignore index ec38b1c..2de60cd 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -4,6 +4,16 @@ dist .env .env.* coverage +junit.xml *.log .DS_Store .vite + +# tests never reach the runtime nginx image, exclude from build context too +src/test +**/__tests__ +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx +vitest.config.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..422b0da 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +coverage +junit.xml + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/README.md b/frontend/README.md index dd21339..a0f0509 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -46,14 +46,47 @@ Starts a Prism mock server from the OpenAPI spec and proxies to it automatically ## Frontend Tests -Frontend smoke tests use Vitest, React Testing Library, and Prism. +Frontend tests use Vitest, React Testing Library, and stubbed `fetch`/`EventSource`. +The structure mirrors the backend test suite: shared fixtures and factories live +alongside the test files and exercise the same flows the running app does. -The test command starts a Prism mock API from the OpenAPI specification at `docs/api/openapi.yml`, then runs the frontend test suite. +| Layer | Backend equivalent | Frontend equivalent | +| ------------ | --------------------------------- | --------------------------------------------------------- | +| Test runner | `pytest` | `vitest run` | +| HTTP harness | `httpx.AsyncClient` over ASGI app | `mockFetch` helper + `@testing-library/react` | +| Streams | aiomqtt test stubs | `FakeEventSource` mock with `emit`/`emitOpen`/`emitError` | +| Fixtures | `tests/conftest.py` | `src/test/setup.ts`, `src/test/helpers.tsx` | +| Factories | `tests/_helpers.py` | `makeBerth`, `makeUser`, `makeEvent`, `buildAuthContext` | +| Coverage | `pytest-cov` (`fail_under = 80`) | `@vitest/coverage-v8` (`thresholds.lines = 80`) | + +Tests live next to the source they exercise: + +``` +src/ + __tests__/ app + HarborMap entry tests + components/__tests__/ UI panels + layout chrome + hooks/__tests__/ data hooks (fake timers + mocked fetch/EventSource) + lib/__tests__/ pure helpers + pages/__tests__/ page components (Dashboard, Settings) + test/ shared setup, factories, EventSource mock +``` ### Run tests locally ```bash cd frontend bun install -bun run test +bun run test # full suite (boots Prism alongside vitest, mirrors CI) +bun run test:watch # vitest watch mode, no Prism +bun run test:cov # full suite with v8 coverage report ``` + +### Writing new tests + +- Reach for `renderWithAuthLayout` whenever a component uses `useOutletContext`. +- Use `mockFetch((url, init) => ...)` to replace the network layer; combine with + `jsonResponse` / `errorResponse` from `src/test/helpers.tsx`. +- For SSE-driven hooks, drive the `FakeEventSource` via `getLastEventSource()` + and call `emit("berth.update", payload)`. +- Keep factory builders in sync with `src/api-types.ts` — when a schema changes, + the type checker catches stale fixtures. diff --git a/frontend/bun.lock b/frontend/bun.lock index 0ffce1b..6ee2a36 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -34,6 +34,7 @@ "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.5", "concurrently": "^9.2.1", "jsdom": "^29.0.2", "openapi-typescript": "^7.13.0", @@ -57,10 +58,18 @@ "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@biomejs/biome": ["@biomejs/biome@2.4.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.13", "@biomejs/cli-darwin-x64": "2.4.13", "@biomejs/cli-linux-arm64": "2.4.13", "@biomejs/cli-linux-arm64-musl": "2.4.13", "@biomejs/cli-linux-x64": "2.4.13", "@biomejs/cli-linux-x64-musl": "2.4.13", "@biomejs/cli-win32-arm64": "2.4.13", "@biomejs/cli-win32-x64": "2.4.13" }, "bin": { "biome": "bin/biome" } }, "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg=="], @@ -309,6 +318,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.5", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", "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" }, "peerDependencies": { "@vitest/browser": "4.1.5", "vitest": "4.1.5" }, "optionalPeers": ["@vitest/browser"] }, "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A=="], + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], @@ -351,6 +362,8 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -519,6 +532,8 @@ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], "http-reasons": ["http-reasons@0.1.0", "", {}, "sha512-P6kYh0lKZ+y29T2Gqz+RlC9WBLhKe8kDmcJ+A+611jFfxdPsbMRQ5aNmFRM3lENqFkK+HTTL+tlQviAiv0AbLQ=="], @@ -547,11 +562,17 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -621,6 +642,10 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], @@ -901,6 +926,8 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -947,6 +974,8 @@ "concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "json-schema-ref-parser/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], "load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], diff --git a/frontend/package.json b/frontend/package.json index cdca61d..b1a681e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,8 @@ "format": "biome format --write .", "gen:api": "openapi-typescript ../docs/api/openapi.yml -o src/api-types.ts", "test": "bunx concurrently -k -s first -n prism,vitest \"bunx @stoplight/prism-cli mock ../docs/api/openapi.yml -p 4010\" \"vitest run\"", - "test:watch": "vitest" + "test:watch": "vitest", + "test:cov": "bunx concurrently -k -s first -n prism,vitest \"bunx @stoplight/prism-cli mock ../docs/api/openapi.yml -p 4010\" \"vitest run --coverage\"" }, "dependencies": { "@fontsource-variable/jost": "^5.2.8", @@ -46,6 +47,7 @@ "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.5", "concurrently": "^9.2.1", "jsdom": "^29.0.2", "openapi-typescript": "^7.13.0", diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx new file mode 100644 index 0000000..b64d9b9 --- /dev/null +++ b/frontend/src/__tests__/App.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { App } from "../App"; + +vi.mock("../components/layout/MainLayout", () => ({ + MainLayout: () =>
, +})); + +describe("App", () => { + test("renders MainLayout for a marina route", async () => { + window.history.pushState({}, "", "/saltsjobaden"); + render(); + expect(await screen.findByTestId("main-layout")).toBeInTheDocument(); + }); + + test("redirects root path to /saltsjobaden", async () => { + window.history.pushState({}, "", "/"); + render(); + await screen.findByTestId("main-layout"); + expect(window.location.pathname).toBe("/saltsjobaden"); + }); +}); diff --git a/frontend/src/__tests__/HarborMap.test.tsx b/frontend/src/__tests__/HarborMap.test.tsx new file mode 100644 index 0000000..39309d1 --- /dev/null +++ b/frontend/src/__tests__/HarborMap.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Outlet, Route, Routes } from "react-router-dom"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + buildAuthContext, + errorResponse, + jsonResponse, + makeBerth, + makeUser, + mockFetch, +} from "../test/helpers"; + +vi.mock("../svgMap", () => ({ + SvgMap: ({ onBerthClickCB }: { onBerthClickCB?: (id: string) => void }) => ( + + ), +})); + +vi.mock("../svg", () => ({ + mapBerthIds: new Set(["B1", "B2"]), + berthSlots: {}, +})); + +import { DashboardLayoutProvider } from "../components/layout/DashboardLayoutContext"; +import { HarborMap } from "../HarborMap"; + +function renderHarborMap( + authOverrides: Parameters[0] = {}, +) { + const ctx = buildAuthContext(authOverrides); + return render( + + + + }> + } /> + + + + , + ); +} + +describe("HarborMap", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("visitor view renders HarborOverview with snapshot data", async () => { + mockFetch(() => jsonResponse([makeBerth({ berth_id: "B1" })])); + renderHarborMap(); + expect(await screen.findByText("Harbor Overview")).toBeInTheDocument(); + }); + + test("harbormaster view renders Harbor Master HUD", async () => { + mockFetch(() => jsonResponse([makeBerth({ berth_id: "B1" })])); + renderHarborMap({ user: makeUser({ role: "harbormaster" }) }); + expect(await screen.findByText("Harbor Master HUD")).toBeInTheDocument(); + }); + + test("clicking a berth opens the detail panel", async () => { + mockFetch((url) => { + if (url.endsWith("/api/berths")) + return jsonResponse([makeBerth({ berth_id: "B1" })]); + if (url.includes("/api/berths/B1")) + return jsonResponse(makeBerth({ berth_id: "B1", label: "1" })); + return errorResponse(404); + }); + const user = userEvent.setup(); + renderHarborMap({ user: makeUser({ role: "boat_owner" }) }); + await user.click(await screen.findByTestId("svg-map")); + await waitFor(() => + expect(screen.getByText("Berth Detail")).toBeInTheDocument(), + ); + }); +}); diff --git a/frontend/src/components/__tests__/ActivityLogPanel.test.tsx b/frontend/src/components/__tests__/ActivityLogPanel.test.tsx new file mode 100644 index 0000000..c5eb890 --- /dev/null +++ b/frontend/src/components/__tests__/ActivityLogPanel.test.tsx @@ -0,0 +1,132 @@ +import { act, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + jsonResponse, + makeBerth, + makeUser, + mockFetch, + renderWithAuthLayout, +} from "../../test/helpers"; +import { ActivityLogPanel } from "../ActivityLogPanel"; + +type Berth = ReturnType; + +describe("ActivityLogPanel", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("shows empty state when no events", async () => { + mockFetch(() => jsonResponse([])); + renderWithAuthLayout(, { + auth: { user: makeUser({ role: "harbormaster" }), token: "t" }, + }); + expect( + await screen.findByText(/waiting for activity/i), + ).toBeInTheDocument(); + }); + + test("hydrates events from per-user localStorage key", async () => { + const user = makeUser({ user_id: "u123", role: "harbormaster" }); + localStorage.setItem( + `dockpulse_activity_log:${user.user_id}`, + JSON.stringify([ + { + id: "e1", + timestamp: new Date("2026-05-05T10:00:00Z").toISOString(), + type: "status_change", + berthId: "B1", + berthLabel: "1", + details: "Status changed from free to occupied", + status: "occupied", + }, + ]), + ); + mockFetch(() => jsonResponse([])); + renderWithAuthLayout(, { + auth: { user, token: "t" }, + }); + expect( + await screen.findByText(/Status changed from free to occupied/), + ).toBeInTheDocument(); + }); + + test("filters events by type", async () => { + const user = makeUser({ user_id: "u123", role: "harbormaster" }); + localStorage.setItem( + `dockpulse_activity_log:${user.user_id}`, + JSON.stringify([ + { + id: "e1", + timestamp: new Date().toISOString(), + type: "status_change", + berthId: "B1", + berthLabel: "1", + details: "Status changed", + status: "free", + }, + { + id: "e2", + timestamp: new Date().toISOString(), + type: "owner_assignment", + berthId: "B2", + berthLabel: "2", + details: "New owner assigned to berth", + }, + ]), + ); + mockFetch(() => jsonResponse([])); + const userEvt = userEvent.setup(); + renderWithAuthLayout(, { + auth: { user, token: "t" }, + }); + await screen.findByText("Status changed"); + await userEvt.click(screen.getByRole("button", { name: /^owners$/i })); + expect(screen.queryByText("Status changed")).not.toBeInTheDocument(); + expect(screen.getByText("New owner assigned to berth")).toBeInTheDocument(); + }); + + test("synthesizes events from berth status changes", async () => { + mockFetch(() => jsonResponse([])); + const user = makeUser({ user_id: "u123", role: "harbormaster" }); + let setBerths!: (b: Berth[]) => void; + function Harness() { + const [berths, set] = useState([ + makeBerth({ berth_id: "B1", status: "free" }), + ]); + setBerths = set; + return ; + } + renderWithAuthLayout(, { auth: { user, token: "t" } }); + await screen.findByText(/waiting for activity/i); + act(() => setBerths([makeBerth({ berth_id: "B1", status: "occupied" })])); + await waitFor(() => + expect( + screen.getByText("Status changed from free to occupied"), + ).toBeInTheDocument(), + ); + }); + + test("close fires callback", async () => { + mockFetch(() => jsonResponse([])); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithAuthLayout( + , + { auth: { user: makeUser({ role: "harbormaster" }), token: "t" } }, + ); + await screen.findByText(/waiting for activity/i); + const buttons = screen.getAllByRole("button"); + const closeBtn = buttons.find( + (b) => + !["all", "status", "owners"].includes( + b.textContent?.toLowerCase() ?? "", + ), + ); + if (!closeBtn) throw new Error("close button not found"); + await user.click(closeBtn); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/__tests__/AuthDialog.test.tsx b/frontend/src/components/__tests__/AuthDialog.test.tsx new file mode 100644 index 0000000..3a2414c --- /dev/null +++ b/frontend/src/components/__tests__/AuthDialog.test.tsx @@ -0,0 +1,150 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { errorResponse, jsonResponse, mockFetch } from "../../test/helpers"; +import { AuthDialog } from "../layout/AuthDialog"; + +function renderDialog( + overrides: Partial> = {}, +) { + const props = { + open: true, + onOpenChange: vi.fn(), + onAuthSuccess: vi.fn(), + ...overrides, + }; + return { props, ...render() }; +} + +describe("AuthDialog", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("renders login tab by default", () => { + renderDialog(); + expect(screen.getByText(/welcome back/i)).toBeInTheDocument(); + }); + + test("switches to signup tab", async () => { + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole("tab", { name: /sign up/i })); + expect( + screen.getByRole("heading", { name: /create account/i }), + ).toBeInTheDocument(); + }); + + test("submit disabled until login fields filled", async () => { + renderDialog(); + const submit = screen.getByRole("button", { name: /sign in/i }); + expect(submit).toBeDisabled(); + }); + + test("login flow calls onAuthSuccess with token", async () => { + mockFetch((url) => { + if (url.endsWith("/api/auth/login")) + return jsonResponse({ access_token: "tok-123" }); + return errorResponse(404); + }); + const onAuthSuccess = vi.fn(); + const user = userEvent.setup(); + renderDialog({ onAuthSuccess }); + await user.type(screen.getByLabelText(/^email$/i), "olle@example.com"); + await user.type(screen.getByLabelText(/^password$/i), "secret"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + await waitFor(() => + expect(onAuthSuccess).toHaveBeenCalledWith("tok-123", { + email: "olle@example.com", + }), + ); + }); + + test("login surfaces server error", async () => { + mockFetch((url) => { + if (url.endsWith("/api/auth/login")) + return errorResponse(401, { detail: "wrong password" }); + return errorResponse(404); + }); + const user = userEvent.setup(); + renderDialog(); + await user.type(screen.getByLabelText(/^email$/i), "olle@example.com"); + await user.type(screen.getByLabelText(/^password$/i), "secret"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + expect(await screen.findByText(/wrong password/i)).toBeInTheDocument(); + }); + + test("signup blocks until passwords match", async () => { + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole("tab", { name: /sign up/i })); + await user.type(screen.getByLabelText(/^email$/i), "olle@example.com"); + await user.type(screen.getByLabelText(/first name/i), "Olle"); + await user.type(screen.getByLabelText(/last name/i), "Owner"); + await user.type(screen.getByLabelText(/^password$/i), "abcd"); + await user.type(screen.getByLabelText(/confirm password/i), "different"); + expect( + screen.getByRole("button", { name: /create account/i }), + ).toBeDisabled(); + expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument(); + }); + + test("signup calls register then auto-logs in", async () => { + const calls: string[] = []; + mockFetch((url) => { + calls.push(url); + if (url.endsWith("/api/auth/register")) return jsonResponse({}); + if (url.endsWith("/api/auth/login")) + return jsonResponse({ access_token: "tok-new" }); + return errorResponse(404); + }); + const onAuthSuccess = vi.fn(); + const user = userEvent.setup(); + renderDialog({ onAuthSuccess }); + await user.click(screen.getByRole("tab", { name: /sign up/i })); + await user.type(screen.getByLabelText(/^email$/i), "ny@example.com"); + await user.type(screen.getByLabelText(/first name/i), "Nya"); + await user.type(screen.getByLabelText(/last name/i), "Skeppare"); + await user.type(screen.getByLabelText(/^password$/i), "password"); + await user.type(screen.getByLabelText(/confirm password/i), "password"); + await user.click(screen.getByRole("button", { name: /create account/i })); + await waitFor(() => expect(onAuthSuccess).toHaveBeenCalled()); + expect(calls).toContain("/api/auth/register"); + expect(calls).toContain("/api/auth/login"); + }); + + test("signup surfaces array-form 422 errors", async () => { + mockFetch((url) => { + if (url.endsWith("/api/auth/register")) + return errorResponse(422, { + detail: [{ loc: ["body", "email"], msg: "exists" }], + }); + return errorResponse(404); + }); + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole("tab", { name: /sign up/i })); + await user.type(screen.getByLabelText(/^email$/i), "ny@example.com"); + await user.type(screen.getByLabelText(/first name/i), "Nya"); + await user.type(screen.getByLabelText(/last name/i), "Skeppare"); + await user.type(screen.getByLabelText(/^password$/i), "password"); + await user.type(screen.getByLabelText(/confirm password/i), "password"); + await user.click(screen.getByRole("button", { name: /create account/i })); + expect(await screen.findByText(/email: exists/i)).toBeInTheDocument(); + }); + + test("login throws when token missing in response", async () => { + mockFetch((url) => { + if (url.endsWith("/api/auth/login")) return jsonResponse({}); + return errorResponse(404); + }); + const user = userEvent.setup(); + renderDialog(); + await user.type(screen.getByLabelText(/^email$/i), "olle@example.com"); + await user.type(screen.getByLabelText(/^password$/i), "secret"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + expect( + await screen.findByText(/no access token was returned/i), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/BerthDetailPanel.test.tsx b/frontend/src/components/__tests__/BerthDetailPanel.test.tsx new file mode 100644 index 0000000..5526472 --- /dev/null +++ b/frontend/src/components/__tests__/BerthDetailPanel.test.tsx @@ -0,0 +1,116 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + errorResponse, + jsonResponse, + makeBerth, + makeEvent, + makeUser, + mockFetch, + renderWithAuthLayout, +} from "../../test/helpers"; +import { BerthDetailPanel } from "../BerthDetailPanel"; + +describe("BerthDetailPanel", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("renders supplied live berth without fetching", async () => { + const fetchSpy = mockFetch(() => jsonResponse(makeBerth())); + const berth = makeBerth({ + berth_id: "B7", + label: "7", + status: "occupied", + }); + renderWithAuthLayout( + {}} berth={berth} />, + { + auth: { user: makeUser({ role: "boat_owner" }), token: "t" }, + }, + ); + expect(await screen.findByText("7")).toBeInTheDocument(); + expect(screen.getByText("occupied")).toBeInTheDocument(); + // boat owner does not load events list + const eventCalls = fetchSpy.mock.calls.filter((c) => + String(c[0]).includes("/events"), + ); + expect(eventCalls).toHaveLength(0); + }); + + test("fetches berth when no live data passed", async () => { + mockFetch(() => + jsonResponse(makeBerth({ berth_id: "B1", label: "1", status: "free" })), + ); + renderWithAuthLayout( + {}} />, + { auth: { user: makeUser({ role: "boat_owner" }), token: "t" } }, + ); + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(screen.getByText("free")).toBeInTheDocument(); + }); + + test("surfaces fetch error", async () => { + mockFetch(() => errorResponse(500, { detail: "boom" })); + renderWithAuthLayout( + {}} />, + { auth: { user: makeUser({ role: "boat_owner" }), token: "t" } }, + ); + expect(await screen.findByText(/Error:/)).toBeInTheDocument(); + }); + + test("harbormaster sees recent events list", async () => { + mockFetch((url) => { + if (url.includes("/events")) + return jsonResponse([ + makeEvent({ event_id: "e1", event_type: "occupied" }), + makeEvent({ event_id: "e2", event_type: "freed" }), + ]); + return jsonResponse(makeBerth({ berth_id: "B1" })); + }); + renderWithAuthLayout( + {}} + berth={makeBerth({ berth_id: "B1" })} + />, + { auth: { user: makeUser({ role: "harbormaster" }), token: "t" } }, + ); + expect(await screen.findByText("Recent Activity")).toBeInTheDocument(); + expect(await screen.findByText("occupied")).toBeInTheDocument(); + expect(await screen.findByText("freed")).toBeInTheDocument(); + }); + + test("close button fires callback after timeout", async () => { + mockFetch(() => jsonResponse(makeBerth({ berth_id: "B1" }))); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithAuthLayout( + , + { auth: { user: makeUser({ role: "boat_owner" }), token: "t" } }, + ); + await user.click(screen.getByLabelText(/close panel/i)); + await vi.waitFor(() => expect(onClose).toHaveBeenCalled(), { + timeout: 1500, + }); + }); + + test("renders battery bar when battery_pct provided", async () => { + mockFetch(() => jsonResponse(makeBerth({ battery_pct: 15 }))); + renderWithAuthLayout( + {}} + berth={makeBerth({ berth_id: "B1", battery_pct: 15 })} + />, + { auth: { user: makeUser({ role: "boat_owner" }), token: "t" } }, + ); + expect(await screen.findByText("Node Battery")).toBeInTheDocument(); + expect(await screen.findByText("15%")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/DashboardLayoutContext.test.tsx b/frontend/src/components/__tests__/DashboardLayoutContext.test.tsx new file mode 100644 index 0000000..fd2bef6 --- /dev/null +++ b/frontend/src/components/__tests__/DashboardLayoutContext.test.tsx @@ -0,0 +1,90 @@ +import { act, render, renderHook } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, test } from "vitest"; +import { + DashboardLayoutProvider, + useDashboardLayout, +} from "../layout/DashboardLayoutContext"; + +function wrap(userRole?: string) { + return ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); +} + +describe("DashboardLayoutContext", () => { + test("throws when used outside provider", () => { + // suppress react error boundary noise + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => renderHook(() => useDashboardLayout())).toThrow( + /must be used within a DashboardLayoutProvider/, + ); + spy.mockRestore(); + }); + + test("toggleOverview opens overview and closes activity log", () => { + const { result } = renderHook(() => useDashboardLayout(), { + wrapper: wrap(), + }); + act(() => result.current.setIsActivityLogOpen(true)); + expect(result.current.isActivityLogOpen).toBe(true); + act(() => result.current.toggleOverview()); + expect(result.current.isOverviewOpen).toBe(true); + expect(result.current.isActivityLogOpen).toBe(false); + }); + + test("toggleActivityLog opens activity log and closes overview", () => { + const { result } = renderHook(() => useDashboardLayout(), { + wrapper: wrap(), + }); + act(() => result.current.setIsOverviewOpen(true)); + act(() => result.current.toggleActivityLog()); + expect(result.current.isActivityLogOpen).toBe(true); + expect(result.current.isOverviewOpen).toBe(false); + }); + + test("closeAllPanels resets both panels", () => { + const { result } = renderHook(() => useDashboardLayout(), { + wrapper: wrap(), + }); + act(() => result.current.setIsOverviewOpen(true)); + act(() => result.current.setIsActivityLogOpen(true)); + act(() => result.current.closeAllPanels()); + expect(result.current.isOverviewOpen).toBe(false); + expect(result.current.isActivityLogOpen).toBe(false); + }); + + test("sidebar offset depends on harbormaster role and expansion", () => { + const { result, rerender } = renderHook(() => useDashboardLayout(), { + wrapper: wrap("harbormaster"), + }); + expect(result.current.sidebarOffset).toBe(112); + act(() => result.current.setIsMenuExpanded(true)); + expect(result.current.sidebarOffset).toBe(288); + rerender(); + }); + + test("non-harbormaster always gets default offset", () => { + const { result } = renderHook(() => useDashboardLayout(), { + wrapper: wrap("boat_owner"), + }); + expect(result.current.sidebarOffset).toBe(16); + }); + + test("provider renders children", () => { + const { getByText } = render( + + + child + + , + ); + expect(getByText("child")).toBeInTheDocument(); + }); +}); + +import { vi } from "vitest"; diff --git a/frontend/src/components/__tests__/Footer.test.tsx b/frontend/src/components/__tests__/Footer.test.tsx new file mode 100644 index 0000000..ceb6696 --- /dev/null +++ b/frontend/src/components/__tests__/Footer.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { Footer } from "../layout/Footer"; + +describe("Footer", () => { + test("renders current year and tagline", () => { + render(