diff --git a/Makefile b/Makefile index 7bfd1cfc..d7e90f83 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,9 @@ PIP := pip3 VERSION := $(shell git describe --tag) COMMIT := $(shell git rev-parse --short HEAD) -.PHONY: +# FORCE is an always-out-of-date target with no recipe; listing it as a prerequisite +# forces that target's recipe to run every time (the classic "FORCE target" idiom). +FORCE: help: @echo "Typical commands (more see below):" @@ -43,6 +45,7 @@ help: @echo " make web-deps - Install web app dependencies (npm install the universe)" @echo " make web-build - Actually build the web app" @echo " make web-lint - Run eslint on the web app" + @echo " make web-test - Run vitest unit tests for the web app" @echo " make web-fmt - Run prettier on the web app" @echo " make web-fmt-check - Run prettier on the web app, but don't change anything" @echo @@ -52,7 +55,9 @@ help: @echo " make docs-build - Actually build the documentation" @echo @echo "Test/check:" - @echo " make test - Run tests" + @echo " make test - Run all tests (Go + web)" + @echo " make cli-test - Run Go tests only" + @echo " make web-test - Run web app tests only" @echo " make race - Run tests with -race flag" @echo " make coverage - Run tests and show coverage" @echo " make coverage-html - Run tests and show coverage (as HTML)" @@ -82,7 +87,7 @@ help: # Building everything -clean: .PHONY +clean: FORCE rm -rf dist build server/docs server/site build: web docs cli @@ -119,7 +124,7 @@ build-deps-ubuntu: docs: docs-deps docs-build -docs-venv: .PHONY +docs-venv: FORCE $(PYTHON) -m venv ./venv docs-build: docs-venv @@ -128,7 +133,7 @@ docs-build: docs-venv docs-deps: docs-venv (. venv/bin/activate && $(PIP) install -r requirements.txt) -docs-deps-update: .PHONY +docs-deps-update: FORCE (. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade) @@ -163,6 +168,9 @@ web-fmt-check: web-lint: cd web && $(NPM) run lint +web-test: + cd web && $(NPM) run test + # Main server/client build cli: cli-deps @@ -269,13 +277,17 @@ check: test web-fmt-check fmt-check vet web-lint lint staticcheck checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck -test: .PHONY +test: cli-test web-test + +testv: cli-testv web-test + +cli-test: FORCE go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') -testv: .PHONY +cli-testv: FORCE go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') -race: .PHONY +race: FORCE go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') coverage: @@ -307,7 +319,7 @@ lint: which golint || go install golang.org/x/lint/golint@latest go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status -staticcheck: .PHONY +staticcheck: FORCE rm -rf build/staticcheck which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest mkdir -p build/staticcheck diff --git a/web/package-lock.json b/web/package-lock.json index 4a7aaa34..f44b5818 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -38,7 +38,8 @@ "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^2.8.8", "vite": "^8.0.16", - "vite-plugin-pwa": "^1.0.0" + "vite-plugin-pwa": "^1.0.0", + "vitest": "^4.1.9" } }, "node_modules/@babel/code-frame": { @@ -3081,6 +3082,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "version": "3.0.0-pre1", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", @@ -3121,6 +3129,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3228,6 +3254,136 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -3471,6 +3627,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -3760,6 +3926,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4331,6 +4507,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4885,6 +5068,16 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -7088,6 +7281,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7293,6 +7500,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8125,6 +8339,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8198,6 +8419,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8507,6 +8742,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -8524,6 +8776,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -9041,6 +9303,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -9184,6 +9536,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/web/package.json b/web/package.json index 9928d2fd..6bde5ba6 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,9 @@ "serve": "vite preview", "format": "prettier . --write", "format:check": "prettier . --check", - "lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/" + "lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@emotion/cache": "^11.11.0", @@ -41,7 +43,8 @@ "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^2.8.8", "vite": "^8.0.16", - "vite-plugin-pwa": "^1.0.0" + "vite-plugin-pwa": "^1.0.0", + "vitest": "^4.1.9" }, "browserslist": { "production": [ diff --git a/web/src/app/errors.test.js b/web/src/app/errors.test.js new file mode 100644 index 00000000..62a9bd9f --- /dev/null +++ b/web/src/app/errors.test.js @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + UnauthorizedError, + UserExistsError, + TopicReservedError, + AccountActionLimitReachedError, + IncorrectPasswordError, + EmailVerificationCodeInvalidError, + EmailPrimaryElsewhereError, + throwAppError, + fetchOrThrow, +} from "./errors"; + +// A minimal stand-in for a fetch Response: just the bits errors.js reads. +const fakeResponse = (status, body) => ({ + status, + json: async () => { + if (body === undefined) throw new Error("no body"); + return body; + }, +}); + +beforeEach(() => { + // errors.js logs to console.log on every error path; keep test output clean. + vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("error classes", () => { + it("carry the server error codes from errors.go", () => { + expect(UserExistsError.CODE).toBe(40901); + expect(TopicReservedError.CODE).toBe(40902); + expect(AccountActionLimitReachedError.CODE).toBe(42906); + expect(IncorrectPasswordError.CODE).toBe(40026); + expect(EmailVerificationCodeInvalidError.CODE).toBe(40051); + expect(EmailPrimaryElsewhereError.CODE).toBe(40908); + }); + + it("are Error subclasses with human-readable messages", () => { + expect(new UnauthorizedError()).toBeInstanceOf(Error); + expect(new UnauthorizedError().message).toBe("Unauthorized"); + expect(new UserExistsError().message).toBe("Username already exists"); + }); +}); + +describe("throwAppError", () => { + it("maps 401 and 403 to UnauthorizedError", async () => { + await expect(throwAppError(fakeResponse(401))).rejects.toBeInstanceOf(UnauthorizedError); + await expect(throwAppError(fakeResponse(403))).rejects.toBeInstanceOf(UnauthorizedError); + }); + + it("maps known ntfy error codes to their specific error classes", async () => { + await expect(throwAppError(fakeResponse(409, { code: UserExistsError.CODE }))).rejects.toBeInstanceOf(UserExistsError); + await expect(throwAppError(fakeResponse(409, { code: TopicReservedError.CODE }))).rejects.toBeInstanceOf(TopicReservedError); + await expect(throwAppError(fakeResponse(429, { code: AccountActionLimitReachedError.CODE }))).rejects.toBeInstanceOf( + AccountActionLimitReachedError + ); + }); + + it("wraps an unknown code that carries an error message in a generic Error", async () => { + await expect(throwAppError(fakeResponse(400, { code: 12345, error: "boom" }))).rejects.toThrow("Error 12345: boom"); + }); + + it("throws a generic 'Unexpected response' error when there is no ntfy error body", async () => { + await expect(throwAppError(fakeResponse(500))).rejects.toThrow("Unexpected response 500"); + }); +}); + +describe("fetchOrThrow", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns the response unchanged on HTTP 200", async () => { + const response = { status: 200 }; + vi.stubGlobal( + "fetch", + vi.fn(async () => response) + ); + await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).resolves.toBe(response); + }); + + it("throws on a non-200 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => fakeResponse(401)) + ); + await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).rejects.toBeInstanceOf(UnauthorizedError); + }); +}); diff --git a/web/src/app/notificationUtils.test.js b/web/src/app/notificationUtils.test.js new file mode 100644 index 00000000..e39f8142 --- /dev/null +++ b/web/src/app/notificationUtils.test.js @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import emojisMapped from "./emojisMapped"; +import { formatTitle, formatMessage, isImage, notificationTag, toNotificationParams, messageWithSequenceId } from "./notificationUtils"; + +// Pick a real alias/emoji pair so we don't hardcode a specific glyph that could change upstream. +const [emojiAlias, emojiChar] = Object.entries(emojisMapped)[0]; + +describe("formatTitle", () => { + it("returns the bare title when there are no emoji tags", () => { + expect(formatTitle({ title: "Hello", tags: ["not-an-emoji"] })).toBe("Hello"); + expect(formatTitle({ title: "Hello" })).toBe("Hello"); + }); + + it("prepends mapped emoji for emoji tags", () => { + expect(formatTitle({ title: "Hello", tags: [emojiAlias] })).toBe(`${emojiChar} Hello`); + }); +}); + +describe("formatMessage", () => { + it("returns the message untouched when the notification has a title", () => { + expect(formatMessage({ title: "T", message: "Body", tags: [emojiAlias] })).toBe("Body"); + }); + + it("prepends emoji to the message when there is no title", () => { + expect(formatMessage({ message: "Body", tags: [emojiAlias] })).toBe(`${emojiChar} Body`); + }); + + it("falls back to an empty string when message is missing", () => { + expect(formatMessage({})).toBe(""); + }); +}); + +describe("isImage", () => { + it("trusts an explicit image MIME type", () => { + expect(isImage({ type: "image/png", name: "whatever.txt" })).toBe(true); + expect(isImage({ type: "application/pdf", name: "x.png" })).toBe(false); + }); + + it("falls back to the file extension when there is no type", () => { + expect(isImage({ name: "photo.JPEG" })).toBeTruthy(); + expect(isImage({ url: "https://ntfy.sh/file/x.webp" })).toBeTruthy(); + expect(isImage({ name: "notes.txt" })).toBeFalsy(); + }); + + it("returns false for a missing attachment", () => { + expect(isImage(undefined)).toBe(false); + }); +}); + +describe("notificationTag", () => { + it("scopes the tag by baseUrl, topic and sequence id", () => { + expect(notificationTag("https://ntfy.sh", "mytopic", 42)).toBe("https://ntfy.sh/mytopic/42"); + }); +}); + +describe("toNotificationParams", () => { + const baseMessage = { + id: "msg-1", + time: 1700000000, + title: "Title", + message: "Body", + actions: [ + { action: "view", label: "Open" }, + { action: "http", label: "Send" }, + { action: "broadcast", label: "Cast" }, + ], + }; + + it("builds the [title, options] tuple consumed by the Notifications API", () => { + const [title, options] = toNotificationParams({ + message: baseMessage, + defaultTitle: "fallback", + topicRoute: "/mytopic", + baseUrl: "https://ntfy.sh", + topic: "mytopic", + }); + + expect(title).toBe("Title"); + expect(options.body).toBe("Body"); + expect(options.tag).toBe("https://ntfy.sh/mytopic/msg-1"); + expect(options.timestamp).toBe(baseMessage.time * 1000); + expect(options.data.subscriptionId).toBe("https://ntfy.sh/mytopic"); + expect(options.data.topicRoute).toBe("/mytopic"); + }); + + it("keeps only view/http actions and maps them to {action,title}", () => { + const [, options] = toNotificationParams({ + message: baseMessage, + defaultTitle: "fallback", + topicRoute: "/mytopic", + baseUrl: "https://ntfy.sh", + topic: "mytopic", + }); + expect(options.actions).toEqual([ + { action: "Open", title: "Open" }, + { action: "Send", title: "Send" }, + ]); + }); + + it("uses the default title when the message has none", () => { + const [title] = toNotificationParams({ + message: { id: "x", time: 1, message: "Body" }, + defaultTitle: "fallback", + topicRoute: "/mytopic", + baseUrl: "https://ntfy.sh", + topic: "mytopic", + }); + expect(title).toBe("fallback"); + }); + + it("prefers sequence_id over id for the notification tag", () => { + const [, options] = toNotificationParams({ + message: { ...baseMessage, sequence_id: 99 }, + defaultTitle: "fallback", + topicRoute: "/mytopic", + baseUrl: "https://ntfy.sh", + topic: "mytopic", + }); + expect(options.tag).toBe("https://ntfy.sh/mytopic/99"); + }); +}); + +describe("messageWithSequenceId", () => { + it("derives sequenceId from sequence_id when absent", () => { + expect(messageWithSequenceId({ id: "a", sequence_id: 7 }).sequenceId).toBe(7); + }); + + it("falls back to id when sequence_id is missing", () => { + expect(messageWithSequenceId({ id: "a" }).sequenceId).toBe("a"); + }); + + it("returns the message unchanged when sequenceId already set", () => { + const m = { id: "a", sequenceId: 1 }; + expect(messageWithSequenceId(m)).toBe(m); + }); +}); diff --git a/web/src/app/utils.test.js b/web/src/app/utils.test.js new file mode 100644 index 00000000..b2efdda0 --- /dev/null +++ b/web/src/app/utils.test.js @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { THEME } from "./Prefs"; +import { + topicUrl, + topicUrlWs, + topicUrlJsonPollWithSince, + accountUrl, + accountTokenUrl, + shortUrl, + expandUrl, + expandSecureUrl, + validUrl, + validTopic, + disallowedTopic, + encodeBase64, + encodeBase64Url, + bearerAuth, + basicAuth, + withBearerAuth, + maybeWithAuth, + splitNoEmpty, + hashCode, + formatBytes, + formatNumber, + formatPrice, + formatShortDuration, + getKebabCaseLangStr, + darkModeEnabled, + urlB64ToUint8Array, +} from "./utils"; + +describe("URL builders", () => { + it("build topic URLs", () => { + expect(topicUrl("https://ntfy.sh", "mytopic")).toBe("https://ntfy.sh/mytopic"); + expect(topicUrlJsonPollWithSince("https://ntfy.sh", "mytopic", 123)).toBe("https://ntfy.sh/mytopic/json?poll=1&since=123"); + }); + + it("rewrite the scheme for websocket URLs", () => { + expect(topicUrlWs("https://ntfy.sh", "mytopic")).toBe("wss://ntfy.sh/mytopic/ws"); + expect(topicUrlWs("http://localhost:8080", "mytopic")).toBe("ws://localhost:8080/mytopic/ws"); + }); + + it("build account URLs", () => { + expect(accountUrl("https://ntfy.sh")).toBe("https://ntfy.sh/v1/account"); + expect(accountTokenUrl("https://ntfy.sh")).toBe("https://ntfy.sh/v1/account/token"); + }); +}); + +describe("url helpers", () => { + it("strip the scheme with shortUrl", () => { + expect(shortUrl("https://ntfy.sh/mytopic")).toBe("ntfy.sh/mytopic"); + }); + + it("expand a bare host to both schemes", () => { + expect(expandUrl("ntfy.sh")).toEqual(["https://ntfy.sh", "http://ntfy.sh"]); + expect(expandSecureUrl("ntfy.sh")).toBe("https://ntfy.sh"); + }); + + it("validate http(s) URLs", () => { + expect(validUrl("https://ntfy.sh")).toBeTruthy(); + expect(validUrl("http://ntfy.sh")).toBeTruthy(); + expect(validUrl("ftp://ntfy.sh")).toBeFalsy(); + }); +}); + +describe("topic validation", () => { + it("rejects disallowed topics (from config.disallowed_topics)", () => { + expect(disallowedTopic("app")).toBe(true); + expect(disallowedTopic("mytopic")).toBe(false); + expect(validTopic("app")).toBe(false); + }); + + it("accepts well-formed topic names and rejects malformed ones", () => { + expect(validTopic("valid_Topic-123")).toBeTruthy(); + expect(validTopic("bad/slash")).toBeFalsy(); + expect(validTopic("with space")).toBeFalsy(); + expect(validTopic("")).toBeFalsy(); + }); +}); + +describe("base64 + auth headers", () => { + it("encodes base64 and base64url", () => { + expect(encodeBase64("hello")).toBe("aGVsbG8="); + expect(encodeBase64Url("hello")).toBe("aGVsbG8"); + }); + + it("builds bearer and basic auth headers", () => { + expect(bearerAuth("tok")).toBe("Bearer tok"); + expect(basicAuth("phil", "secret")).toBe(`Basic ${encodeBase64("phil:secret")}`); + expect(withBearerAuth({ "Content-Type": "application/json" }, "tok")).toEqual({ + "Content-Type": "application/json", + Authorization: "Bearer tok", + }); + }); + + it("picks the right scheme in maybeWithAuth", () => { + expect(maybeWithAuth({}, { username: "u", password: "p" }).Authorization).toBe(basicAuth("u", "p")); + expect(maybeWithAuth({}, { token: "tok" }).Authorization).toBe(bearerAuth("tok")); + expect(maybeWithAuth({ a: 1 }, undefined)).toEqual({ a: 1 }); + }); +}); + +describe("misc pure helpers", () => { + it("splitNoEmpty trims and drops empty entries", () => { + expect(splitNoEmpty("a, b, ,c ", ",")).toEqual(["a", "b", "c"]); + expect(splitNoEmpty("", ",")).toEqual([]); + }); + + it("hashCode is deterministic", () => { + expect(hashCode("")).toBe(0); + expect(hashCode("a")).toBe(97); + expect(hashCode("hello")).toBe(hashCode("hello")); + }); + + it("formatBytes is human readable", () => { + expect(formatBytes(0)).toBe("0 bytes"); + expect(formatBytes(1024)).toBe("1 KB"); + expect(formatBytes(1536)).toBe("1.5 KB"); + }); + + it("formatNumber abbreviates round thousands", () => { + expect(formatNumber(0)).toBe(0); + expect(formatNumber(1000)).toBe("1k"); + expect(formatNumber(1500)).toBe((1500).toLocaleString()); + }); + + it("formatPrice renders cents as dollars", () => { + expect(formatPrice(100)).toBe("$1"); + expect(formatPrice(150)).toBe("$1.5"); + }); + + it("formatShortDuration picks the largest fitting unit", () => { + expect(formatShortDuration(60000, "en")).toContain("minute"); + expect(formatShortDuration(3600000, "en")).toContain("hour"); + }); + + it("getKebabCaseLangStr normalizes language tags", () => { + expect(getKebabCaseLangStr("en_US")).toBe("en-US"); + expect(getKebabCaseLangStr(undefined)).toBe("en"); + expect(getKebabCaseLangStr("")).toBe("en"); + }); + + it("darkModeEnabled honors the theme preference", () => { + expect(darkModeEnabled(false, THEME.DARK)).toBe(true); + expect(darkModeEnabled(true, THEME.LIGHT)).toBe(false); + expect(darkModeEnabled(true, THEME.SYSTEM)).toBe(true); + expect(darkModeEnabled(false, THEME.SYSTEM)).toBe(false); + }); + + it("urlB64ToUint8Array decodes web push keys", () => { + expect(Array.from(urlB64ToUint8Array("AQID"))).toEqual([1, 2, 3]); + }); +}); diff --git a/web/src/test/setup.js b/web/src/test/setup.js new file mode 100644 index 00000000..4adf1f28 --- /dev/null +++ b/web/src/test/setup.js @@ -0,0 +1,19 @@ +/* eslint-env node, es2021 */ +// Minimal window stub so the pure-logic modules import cleanly under the node environment, +// without pulling in jsdom. utils.js -> config.js does `const { config } = window;` at import +// time, and config.js falls back to window.location.origin when base_url is empty. +globalThis.window = { + location: { origin: "https://ntfy.sh" }, + config: { base_url: "", disallowed_topics: ["app", "account", "settings"] }, + atob: globalThis.atob, // urlB64ToUint8Array uses window.atob; Node provides global atob +}; + +// utils.js -> Prefs.js -> db.js -> Session.username() reads localStorage at module load. +// Node has no localStorage; an in-memory stand-in is enough for the pure-logic tests. +const store = new Map(); +globalThis.localStorage = { + getItem: (key) => (store.has(key) ? store.get(key) : null), + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), +}; diff --git a/web/vitest.config.js b/web/vitest.config.js new file mode 100644 index 00000000..8a3a4069 --- /dev/null +++ b/web/vitest.config.js @@ -0,0 +1,14 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { defineConfig } from "vitest/config"; + +// Standalone config (separate from vite.config.js) so the PWA plugin isn't loaded during tests. +// Tests run in the default "node" environment -- see src/test/setup.js for the minimal window stub +// that lets the pure-logic modules import without jsdom. +export default defineConfig({ + test: { + environment: "node", + setupFiles: ["./src/test/setup.js"], + include: ["src/**/*.test.{js,jsx}"], + globals: false, // explicit `import { describe, it, expect } from "vitest"` -> no eslint env change + }, +});