From ba3ebd27f49c8795a11c43c319ac64ef72f6901a Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Tue, 30 Dec 2025 11:14:52 +0100 Subject: [PATCH] feat: add comprehensive test suite and CI pipeline - Add 402 unit tests with 61.7% code coverage - Add Vitest configuration with coverage reporting - Extract testable utility functions from services - Create test.yml workflow (runs on PR and push to main) - Update docker-build.yml to require tests before building - Add scheduler-utils.ts and server-config.ts for testable code Test files added: - auth.test.ts, medications.test.ts, planner.test.ts - settings.test.ts, doses.test.ts, share.test.ts - database.test.ts, server.test.ts, services.test.ts - env.test.ts, translations.test.ts, integration.test.ts - e2e-routes.test.ts, stock-calculation.test.ts --- .github/workflows/docker-build.yml | 11 + .github/workflows/test.yml | 75 + backend/package-lock.json | 2313 ++++++++++++++++- backend/package.json | 11 +- backend/src/db/client.ts | 180 +- backend/src/db/migrate.ts | 81 +- backend/src/index.ts | 140 +- .../src/services/intake-reminder-scheduler.ts | 135 +- backend/src/services/reminder-scheduler.ts | 194 +- backend/src/test/auth.test.ts | 685 +++++ backend/src/test/database.test.ts | 897 +++++++ backend/src/test/doses.test.ts | 364 +++ backend/src/test/e2e-routes.test.ts | 1523 +++++++++++ backend/src/test/env.test.ts | 365 +++ backend/src/test/integration.test.ts | 932 +++++++ backend/src/test/medications.test.ts | 672 +++++ backend/src/test/planner.test.ts | 706 +++++ backend/src/test/server.test.ts | 499 ++++ backend/src/test/services.test.ts | 500 ++++ backend/src/test/settings.test.ts | 510 ++++ backend/src/test/setup.ts | 376 +++ backend/src/test/share.test.ts | 647 +++++ backend/src/test/stock-calculation.test.ts | 635 +++++ backend/src/test/translations.test.ts | 136 + backend/src/utils/scheduler-utils.ts | 337 +++ backend/src/utils/server-config.ts | 125 + backend/vitest.config.ts | 18 + 27 files changed, 12666 insertions(+), 401 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 backend/src/test/auth.test.ts create mode 100644 backend/src/test/database.test.ts create mode 100644 backend/src/test/doses.test.ts create mode 100644 backend/src/test/e2e-routes.test.ts create mode 100644 backend/src/test/env.test.ts create mode 100644 backend/src/test/integration.test.ts create mode 100644 backend/src/test/medications.test.ts create mode 100644 backend/src/test/planner.test.ts create mode 100644 backend/src/test/server.test.ts create mode 100644 backend/src/test/services.test.ts create mode 100644 backend/src/test/settings.test.ts create mode 100644 backend/src/test/setup.ts create mode 100644 backend/src/test/share.test.ts create mode 100644 backend/src/test/stock-calculation.test.ts create mode 100644 backend/src/test/translations.test.ts create mode 100644 backend/src/utils/scheduler-utils.ts create mode 100644 backend/src/utils/server-config.ts create mode 100644 backend/vitest.config.ts diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 8a7f887..5c04208 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -27,7 +27,18 @@ env: REGISTRY: ghcr.io jobs: + # ============================================================================= + # Run Tests First (reuse test workflow) + # ============================================================================= + test: + name: Run Tests + uses: ./.github/workflows/test.yml + + # ============================================================================= + # Build and Push Docker Images (only after tests pass) + # ============================================================================= build-and-push: + needs: test runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1491928 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,75 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + # Allow this workflow to be called by other workflows + workflow_call: + +jobs: + # ============================================================================= + # Backend Tests + # ============================================================================= + backend-test: + name: Backend Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: backend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: TypeScript type check + run: npx tsc --noEmit + + - name: Run tests with coverage + run: npm run test:coverage + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: backend-coverage + path: backend/coverage/ + retention-days: 7 + + # ============================================================================= + # Frontend Build Validation + # ============================================================================= + frontend-build: + name: Frontend Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: TypeScript type check & build + run: npm run build diff --git a/backend/package-lock.json b/backend/package-lock.json index e8b53a7..ef9a7bb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,8 +28,26 @@ "devDependencies": { "@types/node": "^22.7.4", "@types/nodemailer": "^6.4.21", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^2.1.9", + "supertest": "^7.0.0", "tsx": "^4.19.0", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vitest": "^2.1.8" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@aws-crypto/sha256-browser": { @@ -720,6 +738,63 @@ "node": ">=18.0.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1538,11 +1613,61 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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/@libsql/client": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.10.0.tgz", "integrity": "sha512-2ERn08T4XOVx34yBtUPq0RDjAdd9TJ5qNH/izugr208ml2F94mk92qC64kXyDVQINodWJvp3kAdq6P4zTtCZ7g==", "license": "MIT", + "peer": true, "dependencies": { "@libsql/core": "^0.10.0", "@libsql/hrana-client": "^0.6.2", @@ -1697,6 +1822,29 @@ "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", "license": "MIT" }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -1712,6 +1860,325 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@smithy/abort-controller": { "version": "4.2.7", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz", @@ -2347,6 +2814,27 @@ "node": ">=18.0.0" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", @@ -2367,6 +2855,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2376,6 +2888,152 @@ "@types/node": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -2454,6 +3112,13 @@ "node": ">=16.17.0" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asn1.js": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", @@ -2466,6 +3131,23 @@ "safer-buffer": "^2.1.0" } }, + "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/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -2485,6 +3167,13 @@ "fastq": "^1.17.1" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/bn.js": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", @@ -2498,6 +3187,84 @@ "dev": true, "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2516,6 +3283,29 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2559,6 +3349,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2582,6 +3379,44 @@ "node": ">= 12" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2609,6 +3444,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -2742,6 +3588,21 @@ } } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2763,6 +3624,62 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2811,6 +3728,26 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "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/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/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -2871,6 +3808,13 @@ "fast-decode-uri-component": "^1.0.1" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -3049,6 +3993,46 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -3061,6 +4045,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3085,6 +4087,55 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", @@ -3121,6 +4172,71 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/helmet": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", @@ -3130,6 +4246,13 @@ "node": ">=18.0.0" } }, + "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-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3180,6 +4303,60 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "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-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.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/jackspeak": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", @@ -3301,6 +4478,13 @@ ], "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.2.4", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", @@ -3310,6 +4494,54 @@ "node": "20 || >=22" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "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", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -3319,6 +4551,16 @@ "node": ">= 0.8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -3395,6 +4637,32 @@ "obliterator": "^2.0.4" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/node-addon-api": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", @@ -3471,6 +4739,19 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", @@ -3486,6 +4767,16 @@ "node": ">=14.0.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/openid-client": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.1.tgz", @@ -3530,6 +4821,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/pino": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz", @@ -3567,6 +4882,35 @@ "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", "license": "MIT" }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -3589,6 +4933,22 @@ "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", "license": "ISC" }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -3648,6 +5008,48 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3763,6 +5165,89 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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", @@ -3784,6 +5269,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -3793,6 +5288,13 @@ "node": ">= 10.x" } }, + "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/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3802,6 +5304,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/steed": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", @@ -3924,6 +5433,159 @@ ], "license": "MIT" }, + "node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "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/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -3933,6 +5595,50 @@ "real-require": "^0.2.0" } }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toad-cache": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", @@ -4021,6 +5727,587 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -4045,6 +6332,23 @@ "node": ">= 8" } }, + "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/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -4136,6 +6440,13 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/backend/package.json b/backend/package.json index 4a14682..2a7b4a6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,10 @@ "dev": "tsx watch src/index.ts", "build": "tsc -p tsconfig.json", "start": "node dist/index.js", - "migrate": "tsx src/db/migrate.ts" + "migrate": "tsx src/db/migrate.ts", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@fastify/cookie": "^10.0.1", @@ -30,7 +33,11 @@ "devDependencies": { "@types/node": "^22.7.4", "@types/nodemailer": "^6.4.21", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^2.1.9", + "supertest": "^7.0.0", "tsx": "^4.19.0", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vitest": "^2.1.8" } } diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 7548d5c..50ead72 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -6,61 +6,46 @@ import dotenv from "dotenv"; dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); -// Use absolute path to ensure it works in Docker -const dataDir = resolve(process.cwd(), "data"); -const dbPath = resolve(dataDir, "medassist-ng.db"); -const url = `file:${dbPath}`; +// ============================================================================= +// Exported utility functions for testing +// ============================================================================= -console.log(`[DB] Data directory: ${dataDir}`); -console.log(`[DB] Database path: ${dbPath}`); -console.log(`[DB] Database URL: ${url}`); +/** Build the database URL from a path */ +export function buildDbUrl(dbPath: string): string { + return `file:${dbPath}`; +} -// Ensure data directory exists and is writable -try { - if (!existsSync(dataDir)) { - mkdirSync(dataDir, { recursive: true }); - console.log(`[DB] Created data directory: ${dataDir}`); - } else { - console.log(`[DB] Data directory exists: ${dataDir}`); +/** Get data directory and database path */ +export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } { + const dataDir = resolve(cwd, "data"); + const dbPath = resolve(dataDir, "medassist-ng.db"); + const url = buildDbUrl(dbPath); + return { dataDir, dbPath, url }; +} + +/** Ensure data directory exists and is writable */ +export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } { + try { + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }); + } + + // Check if directory is writable + accessSync(dataDir, constants.W_OK); + + // Try to create a test file to verify write access + const testFile = resolve(dataDir, ".write-test"); + writeFileSync(testFile, "test"); + + return { success: true }; + } catch (err: any) { + return { success: false, error: err.message }; } - - // Check if directory is writable - accessSync(dataDir, constants.W_OK); - console.log(`[DB] Data directory is writable`); - - // Log directory stats - const stats = statSync(dataDir); - console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`); - console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`); - - // Try to create a test file to verify write access - const testFile = resolve(dataDir, ".write-test"); - writeFileSync(testFile, "test"); - console.log(`[DB] Write test successful`); - -} catch (err: any) { - console.error(`[DB] ERROR: Cannot access data directory: ${err.message}`); - console.error(`[DB] Please ensure the volume mount has correct permissions.`); - console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`); - process.exit(1); } -let client: Client; -try { - client = createClient({ url }); - console.log(`[DB] Database client created successfully`); -} catch (err: any) { - console.error(`[DB] ERROR: Failed to create database client: ${err.message}`); - console.error(`[DB] Database path: ${dbPath}`); - process.exit(1); -} - -export const db = drizzle(client); - -// Auto-run migrations (self-healing database) -async function runMigrations() { - // First, ensure all tables exist (for fresh databases) - const tableCreations = [ +/** Get the SQL statements for creating all tables */ +export function getTableCreationSQL(): string[] { + return [ `CREATE TABLE IF NOT EXISTS users ( id integer PRIMARY KEY AUTOINCREMENT, username text NOT NULL UNIQUE, @@ -148,31 +133,98 @@ async function runMigrations() { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, ]; +} + +/** Run table creation migrations on a client */ +export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> { + const tableCreations = getTableCreationSQL(); + const errors: string[] = []; for (const sql of tableCreations) { try { await client.execute(sql); } catch (e: any) { - console.error(`[DB] Table creation error:`, e.message); + errors.push(e.message); } } + + return { success: errors.length === 0, errors }; +} + +/** Ensure default user exists for auth-disabled mode */ +export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise { + if (authEnabled) { + return false; // No default user needed + } + + try { + const result = await client.execute("SELECT id FROM users WHERE id = 1"); + if (result.rows.length === 0) { + await client.execute( + "INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')" + ); + return true; // Created + } + return false; // Already exists + } catch (e: any) { + console.error(`[DB] Error creating default user:`, e.message); + return false; + } +} + +// ============================================================================= +// Database initialization (runs on import) +// ============================================================================= + +// Use absolute path to ensure it works in Docker +const { dataDir, dbPath, url } = getDbPaths(); + +console.log(`[DB] Data directory: ${dataDir}`); +console.log(`[DB] Database path: ${dbPath}`); +console.log(`[DB] Database URL: ${url}`); + +// Ensure data directory exists and is writable +const dirResult = ensureDataDirectory(dataDir); +if (!dirResult.success) { + console.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`); + console.error(`[DB] Please ensure the volume mount has correct permissions.`); + console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`); + process.exit(1); +} else { + console.log(`[DB] Data directory is writable`); + + // Log directory stats + const stats = statSync(dataDir); + console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`); + console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`); + console.log(`[DB] Write test successful`); +} + +let client: Client; +try { + client = createClient({ url }); + console.log(`[DB] Database client created successfully`); +} catch (err: any) { + console.error(`[DB] ERROR: Failed to create database client: ${err.message}`); + console.error(`[DB] Database path: ${dbPath}`); + process.exit(1); +} + +export const db = drizzle(client); + +// Auto-run migrations (self-healing database) +async function runMigrations() { + const result = await runTableMigrations(client); + if (result.errors.length > 0) { + result.errors.forEach(err => console.error(`[DB] Table creation error:`, err)); + } console.log(`[DB] Tables verified/created`); // If auth is disabled, ensure a default user exists (ID=1) const authEnabled = process.env.AUTH_ENABLED === "true"; - if (!authEnabled) { - try { - // Check if default user exists - const result = await client.execute("SELECT id FROM users WHERE id = 1"); - if (result.rows.length === 0) { - await client.execute( - "INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')" - ); - console.log(`[DB] Created default user for auth-disabled mode`); - } - } catch (e: any) { - console.error(`[DB] Error creating default user:`, e.message); - } + const created = await ensureDefaultUser(client, authEnabled); + if (created) { + console.log(`[DB] Created default user for auth-disabled mode`); } } diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 52243c3..7d61f94 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,20 +1,17 @@ -import { createClient } from "@libsql/client"; +import { createClient, Client } from "@libsql/client"; import dotenv from "dotenv"; import fs from "fs"; import path from "path"; dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); -const url = "file:./data/medassist-ng.db"; +// ============================================================================= +// Exported utility functions for testing +// ============================================================================= -async function main() { - console.log("Starting database setup..."); - console.log("Database URL:", url); - - const client = createClient({ url }); - - // Create tables - fresh schema without roles, with per-user settings - const sql = ` +/** Get the full migration SQL string */ +export function getMigrationSQL(): string { + return ` CREATE TABLE IF NOT EXISTS users ( id integer PRIMARY KEY AUTOINCREMENT, username text NOT NULL UNIQUE, @@ -106,12 +103,58 @@ async function main() { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); `; +} - // Execute each statement separately - const statements = sql.split(';').filter(s => s.trim().length > 0); +/** Split SQL string into individual statements */ +export function splitSQLStatements(sql: string): string[] { + return sql.split(';').filter(s => s.trim().length > 0); +} + +/** Execute migration statements on a client */ +export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> { + const sql = getMigrationSQL(); + const statements = splitSQLStatements(sql); + const errors: string[] = []; + let executed = 0; + + for (const stmt of statements) { + try { + await client.execute(stmt); + executed++; + } catch (err: any) { + errors.push(err.message); + } + } + + return { success: errors.length === 0, executed, errors }; +} + +/** Get a preview of statement (first N characters) */ +export function getStatementPreview(stmt: string, maxLength: number = 50): string { + const trimmed = stmt.trim(); + if (trimmed.length <= maxLength) { + return trimmed; + } + return trimmed.substring(0, maxLength) + "..."; +} + +// ============================================================================= +// CLI execution (only runs when called directly) +// ============================================================================= + +const url = "file:./data/medassist-ng.db"; + +async function main() { + console.log("Starting database setup..."); + console.log("Database URL:", url); + + const client = createClient({ url }); + + const sql = getMigrationSQL(); + const statements = splitSQLStatements(sql); for (const stmt of statements) { - console.log("Executing:", stmt.trim().substring(0, 50) + "..."); + console.log("Executing:", getStatementPreview(stmt)); await client.execute(stmt); } @@ -119,7 +162,11 @@ async function main() { process.exit(0); } -main().catch((err) => { - console.error("Migration failed:", err); - process.exit(1); -}); +// Only run main() if this file is executed directly (not imported) +const isMainModule = import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + main().catch((err) => { + console.error("Migration failed:", err); + process.exit(1); + }); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 7e2af1d..138017e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,14 +1,14 @@ -import Fastify from "fastify"; +import Fastify, { FastifyInstance } from "fastify"; import helmet from "@fastify/helmet"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import sensible from "@fastify/sensible"; -import cookie, { CookieSerializeOptions } from "@fastify/cookie"; +import cookie from "@fastify/cookie"; import jwt from "@fastify/jwt"; import fastifyMultipart from "@fastify/multipart"; import fastifyStatic from "@fastify/static"; import { resolve } from "path"; -import { existsSync, mkdirSync } from "fs"; +import { existsSync } from "fs"; import { env } from "./plugins/env.js"; import { migrationsReady } from "./db/client.js"; import { healthRoutes } from "./routes/health.js"; @@ -22,15 +22,111 @@ import { doseRoutes } from "./routes/doses.js"; import { startReminderScheduler } from "./services/reminder-scheduler.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; +// Re-export utilities from server-config for external use +export { + parseCorsOrigins, + buildBaseCookieOptions, + buildRefreshCookieOptions, + buildAppConfig, + ensureImagesDirectory, + getJwtConfig, +} from "./utils/server-config.js"; + +import { + parseCorsOrigins, + buildBaseCookieOptions, + buildRefreshCookieOptions, + buildAppConfig, + ensureImagesDirectory, + getJwtConfig, +} from "./utils/server-config.js"; + +/** Create and configure Fastify app (without starting) */ +export async function createApp(options?: { + logLevel?: string; + corsOrigins?: string[]; + authEnabled?: boolean; + jwtSecret?: string; + refreshSecret?: string; + cookieSecret?: string; + accessTtlMinutes?: number; + refreshTtlDays?: number; + isProduction?: boolean; + imagesDir?: string; +}): Promise { + const opts = { + logLevel: options?.logLevel ?? "info", + corsOrigins: options?.corsOrigins ?? ["http://localhost:5173"], + authEnabled: options?.authEnabled ?? false, + jwtSecret: options?.jwtSecret, + refreshSecret: options?.refreshSecret, + cookieSecret: options?.cookieSecret ?? "dev-cookie-secret", + accessTtlMinutes: options?.accessTtlMinutes ?? 15, + refreshTtlDays: options?.refreshTtlDays ?? 7, + isProduction: options?.isProduction ?? false, + imagesDir: options?.imagesDir ?? resolve(process.cwd(), "data/images"), + }; + + const app = Fastify({ + logger: { level: opts.logLevel }, + }); + + // Build config + const appConfig = buildAppConfig({ + jwtSecret: opts.jwtSecret, + refreshSecret: opts.refreshSecret, + accessTtlMinutes: opts.accessTtlMinutes, + refreshTtlDays: opts.refreshTtlDays, + isProduction: opts.isProduction, + }); + + app.decorate("config", appConfig); + + // Register plugins + await app.register(sensible); + await app.register(helmet); + await app.register(cors, { origin: opts.corsOrigins, credentials: true }); + await app.register(rateLimit, { max: 100, timeWindow: "1 minute" }); + await app.register(cookie, { secret: opts.cookieSecret }); + + // JWT plugin + const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret); + await app.register(jwt, jwtConfig); + + await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); + + // Only register static if directory exists + if (existsSync(opts.imagesDir)) { + await app.register(fastifyStatic, { + root: opts.imagesDir, + prefix: "/images/", + decorateReply: false, + }); + } + + // Register routes + await app.register(healthRoutes); + await app.register(authRoutes); + await app.register(oidcRoutes); + await app.register(medicationRoutes); + await app.register(settingsRoutes); + await app.register(plannerRoutes); + await app.register(shareRoutes); + await app.register(doseRoutes); + + return app; +} + +// ============================================================================= +// Server initialization (runs on import) +// ============================================================================= + // Wait for database migrations before anything else await migrationsReady; console.log("[DB] Migrations complete, starting server..."); // Ensure images directory exists -const imagesDir = resolve(process.cwd(), "data/images"); -if (!existsSync(imagesDir)) { - mkdirSync(imagesDir, { recursive: true }); -} +const imagesDir = ensureImagesDirectory(); const app = Fastify({ logger: { @@ -38,24 +134,14 @@ const app = Fastify({ }, }); -const origins = env.CORS_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean); +const origins = parseCorsOrigins(env.CORS_ORIGINS); // Auth token TTLs (hardcoded - no need for user configuration) const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL -const baseCookieOptions: CookieSerializeOptions = { - httpOnly: true, - sameSite: "lax", - secure: env.NODE_ENV === "production", - path: "/", - maxAge: accessTtlMinutes * 60, -}; - -const refreshCookieOptions: CookieSerializeOptions = { - ...baseCookieOptions, - maxAge: refreshTtlDays * 24 * 60 * 60, -}; +const baseCookieOptions = buildBaseCookieOptions(accessTtlMinutes, env.NODE_ENV === "production"); +const refreshCookieOptions = buildRefreshCookieOptions(baseCookieOptions, refreshTtlDays); // Config decorator - only include secrets if auth is enabled app.decorate("config", { @@ -77,18 +163,8 @@ await app.register(rateLimit, { await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" }); // JWT plugin - only register with valid secret if auth is enabled -if (env.AUTH_ENABLED && env.JWT_SECRET) { - await app.register(jwt, { - secret: env.JWT_SECRET, - cookie: { cookieName: "access_token", signed: false } - }); -} else { - // Dummy JWT for when auth is disabled - prevents errors - await app.register(jwt, { - secret: "auth-disabled-no-secret-needed", - cookie: { cookieName: "access_token", signed: false } - }); -} +const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET); +await app.register(jwt, jwtConfig); await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit await app.register(fastifyStatic, { diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index c1697fb..a5de06c 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -8,135 +8,42 @@ import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js"; import { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js"; -type Blister = { usage: number; every: number; start: string }; - -type IntakeReminderState = { - sentReminders: string[]; // Array of "medName:timestamp" to track sent reminders -}; +// Import shared utilities +import { + getTimezone, + parseBlisters, + parseTakenByJson, + getUpcomingIntakes, + parseIntakeReminderState, + createDefaultIntakeReminderState, + cleanOldIntakeReminders, + type Blister, + type IntakeReminderState, + type UpcomingIntake, +} from "../utils/scheduler-utils.js"; const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10); const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute -// Get current timezone from TZ env variable or default to UTC -function getTimezone(): string { - return process.env.TZ || "UTC"; -} - -// Parse takenByJson to array of strings -function parseTakenByJson(takenByJson: string | null | undefined): string[] { - if (!takenByJson) return []; - try { - const parsed = JSON.parse(takenByJson); - return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : []; - } catch { - return []; - } -} - const intakeReminderStateFile = resolve(process.cwd(), "data", "intake-reminder-state.json"); function loadIntakeReminderState(): IntakeReminderState { try { if (existsSync(intakeReminderStateFile)) { - const saved = JSON.parse(readFileSync(intakeReminderStateFile, "utf-8")); - return { - sentReminders: saved.sentReminders ?? [], - }; + return parseIntakeReminderState(readFileSync(intakeReminderStateFile, "utf-8")); } } catch { // ignore } - return { sentReminders: [] }; + return createDefaultIntakeReminderState(); } function saveIntakeReminderState(state: IntakeReminderState): void { writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2)); } -function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] { - try { - const usage = JSON.parse(row.usageJson) as number[]; - const every = JSON.parse(row.everyJson) as number[]; - const start = JSON.parse(row.startJson) as string[]; - const len = Math.min(usage.length, every.length, start.length); - const blisters: Blister[] = []; - for (let i = 0; i < len; i++) { - blisters.push({ usage: usage[i], every: every[i], start: start[i] }); - } - return blisters; - } catch { - return []; - } -} - -type UpcomingIntake = { - medName: string; - usage: number; - intakeTime: Date; - intakeTimeStr: string; - takenBy: string[]; // Changed to array - pillWeightMg: number | null; -}; - -function getUpcomingIntakes(medName: string, blisters: Blister[], minutesBefore: number, takenBy: string[], pillWeightMg: number | null, locale: string): UpcomingIntake[] { - const now = Date.now(); - // Window to detect if "now" is the right time to send reminder - // We check if the notify time (intake - 15min) falls within current minute ±1 - const windowStart = now - 2 * 60 * 1000; // 2 minutes ago (catch slightly late checks) - const windowEnd = now + 1 * 60 * 1000; // 1 minute from now - - const upcoming: UpcomingIntake[] = []; - - for (const blister of blisters) { - const startTime = new Date(blister.start).getTime(); - const intervalMs = blister.every * 24 * 60 * 60 * 1000; - - if (intervalMs <= 0) continue; - - // Find the next scheduled intake time (could be today or in the future) - let nextTime = startTime; - - // If start is in the past, calculate occurrences - if (nextTime < now) { - const elapsed = now - startTime; - const intervals = Math.floor(elapsed / intervalMs); - - // Check the current occurrence (today's scheduled time, even if past) - const currentOccurrence = startTime + intervals * intervalMs; - // And the next occurrence - const nextOccurrence = startTime + (intervals + 1) * intervalMs; - - // If today's occurrence is within the reminder window, use it - // (intake hasn't happened yet, we should remind) - const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000; - if (currentNotifyTime >= windowStart && currentOccurrence > now) { - nextTime = currentOccurrence; - } else { - nextTime = nextOccurrence; - } - } - - // Calculate when we should notify for this intake - const notifyTime = nextTime - minutesBefore * 60 * 1000; - - if (notifyTime >= windowStart && notifyTime <= windowEnd) { - const intakeDate = new Date(nextTime); - upcoming.push({ - medName, - usage: blister.usage, - intakeTime: intakeDate, - intakeTimeStr: intakeDate.toLocaleTimeString(locale, { - hour: "2-digit", - minute: "2-digit", - timeZone: getTimezone() - }), - takenBy, - pillWeightMg, - }); - } - } - - return upcoming; +function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] { + return parseBlisters(row); } async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[], language: Language): Promise<{ success: boolean; error?: string }> { @@ -315,7 +222,7 @@ async function checkAndSendIntakeRemindersForUser( // Find all upcoming intakes across all medications for this user for (const med of medsWithReminders) { - const blisters = parseBlisters(med); + const blisters = parseBlistersFromRow(med); const takenByArray = parseTakenByJson(med.takenByJson); const upcoming = getUpcomingIntakes(med.name, blisters, REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale); allUpcoming.push(...upcoming); @@ -380,11 +287,7 @@ async function checkAndSendIntakeRemindersForUser( const newKeys = newReminders.map(i => `user_${settings.userId}:${i.medName}:${i.intakeTime.getTime()}`); // Clean up old entries (older than 24 hours) - const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000; - const cleanedReminders = state.sentReminders.filter(key => { - const timestamp = parseInt(key.split(":").pop() || "0", 10); - return timestamp > oneDayAgo; - }); + const cleanedReminders = cleanOldIntakeReminders(state.sentReminders); saveIntakeReminderState({ sentReminders: [...cleanedReminders, ...newKeys], diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index 312da5a..afd3e25 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -1,155 +1,42 @@ import nodemailer from "nodemailer"; import { eq } from "drizzle-orm"; import { db } from "../db/client.js"; -import { medications, users, userSettings } from "../db/schema.js"; +import { medications, userSettings } from "../db/schema.js"; import { readFileSync, writeFileSync, existsSync } from "fs"; import { resolve } from "path"; import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; -import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js"; +import { getTranslations, t, type Language } from "../i18n/translations.js"; -type Blister = { usage: number; every: number; start: string }; - -type ReminderState = { - lastAutoEmailSent: string | null; // ISO date string - lastAutoEmailDate: string | null; // YYYY-MM-DD - to track if we already sent today - notifiedMedications: string[]; // List of medication names that have been notified (cleared when restocked) - nextScheduledCheck: string | null; // ISO date string for when the next check is scheduled - lastNotificationType: "stock" | "intake" | null; // Type of last notification - lastNotificationChannel: "email" | "push" | "both" | null; // Channel used for last notification -}; +// Import shared utilities +import { + getTimezone, + formatInTimezone, + getCurrentHourInTimezone, + getTodayInTimezone, + getNextScheduledTime, + getMsUntilNextCheck, + parseBlisters, + calculateDailyUsage, + calculateDepletionInfo, + parseReminderState, + createDefaultReminderState, + type Blister, + type ReminderState, +} from "../utils/scheduler-utils.js"; const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time -// Get current timezone from TZ env variable or default to UTC -function getTimezone(): string { - return process.env.TZ || "UTC"; -} - -// Format a date in the configured timezone -function formatInTimezone(date: Date): string { - return date.toLocaleString("de-DE", { - timeZone: getTimezone(), - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit" - }); -} - -// Get current hour in the configured timezone -function getCurrentHourInTimezone(): number { - const now = new Date(); - const timeStr = now.toLocaleString("en-US", { - timeZone: getTimezone(), - hour: "numeric", - hour12: false - }); - return parseInt(timeStr, 10); -} - -// Get today's date string in the configured timezone (YYYY-MM-DD) -function getTodayInTimezone(): string { - const now = new Date(); - const parts = now.toLocaleDateString("en-CA", { timeZone: getTimezone() }).split("-"); - return parts.join("-"); // YYYY-MM-DD format -} - -function getNextScheduledTime(): Date { - const now = new Date(); - const tz = getTimezone(); - - // Get current time components in the target timezone - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false - }); - - const parts = formatter.formatToParts(now); - const getPart = (type: string) => parts.find(p => p.type === type)?.value || "0"; - - const currentHour = parseInt(getPart("hour"), 10); - const currentMinute = parseInt(getPart("minute"), 10); - - // Calculate if we need tomorrow - const needTomorrow = currentHour > REMINDER_HOUR || (currentHour === REMINDER_HOUR && currentMinute > 0); - - // Get the date we want to schedule for - const year = parseInt(getPart("year"), 10); - const month = parseInt(getPart("month"), 10); - let day = parseInt(getPart("day"), 10); - - if (needTomorrow) { - day += 1; - } - - // Handle month overflow simply by adding a day to now if needed - let targetDate: Date; - if (needTomorrow) { - targetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); - } else { - targetDate = new Date(now); - } - - // Get the target date's date string in the timezone - const targetFormatter = new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit" - }); - const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number); - - // Now we need to find the UTC time that corresponds to REMINDER_HOUR:00 on targetDate in the target timezone - // Use a search approach: start with a guess and adjust - const guessUtc = new Date(Date.UTC(targetYear, targetMonth - 1, targetDay, REMINDER_HOUR, 0, 0, 0)); - - // Check what hour this UTC time corresponds to in the target timezone - const checkFormatter = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - hour: "2-digit", - hour12: false - }); - - // Adjust based on the difference - const guessHour = parseInt(checkFormatter.format(guessUtc), 10); - const hourDiff = guessHour - REMINDER_HOUR; - - // Apply correction (if guessHour is higher, we need to subtract time) - const correctedUtc = new Date(guessUtc.getTime() - hourDiff * 60 * 60 * 1000); - - return correctedUtc; -} - -function getMsUntilNextCheck(): number { - const next = getNextScheduledTime(); - return next.getTime() - Date.now(); -} - const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json"); function loadReminderState(): ReminderState { try { if (existsSync(reminderStateFile)) { - const saved = JSON.parse(readFileSync(reminderStateFile, "utf-8")); - return { - lastAutoEmailSent: saved.lastAutoEmailSent ?? null, - lastAutoEmailDate: saved.lastAutoEmailDate ?? null, - notifiedMedications: saved.notifiedMedications ?? [], - nextScheduledCheck: saved.nextScheduledCheck ?? null, - lastNotificationType: saved.lastNotificationType ?? null, - lastNotificationChannel: saved.lastNotificationChannel ?? null, - }; + return parseReminderState(readFileSync(reminderStateFile, "utf-8")); } } catch { // ignore } - return { lastAutoEmailSent: null, lastAutoEmailDate: null, notifiedMedications: [], nextScheduledCheck: null, lastNotificationType: null, lastNotificationChannel: null }; + return createDefaultReminderState(); } function saveReminderState(state: ReminderState): void { @@ -188,39 +75,8 @@ export async function updateUserReminderSentTime( .where(eq(userSettings.userId, userId)); } -function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] { - try { - const usage = JSON.parse(row.usageJson) as number[]; - const every = JSON.parse(row.everyJson) as number[]; - const start = JSON.parse(row.startJson) as string[]; - const len = Math.min(usage.length, every.length, start.length); - const blisters: Blister[] = []; - for (let i = 0; i < len; i++) { - blisters.push({ usage: usage[i], every: every[i], start: start[i] }); - } - return blisters; - } catch { - return []; - } -} - -function calculateDailyUsage(blisters: Blister[]): number { - return blisters.reduce((sum, s) => sum + s.usage / s.every, 0); -} - -function calculateDepletionInfo(med: { count: number; blisters: Blister[] }, language: Language): { daysLeft: number | null; depletionDate: string | null } { - const dailyUsage = calculateDailyUsage(med.blisters); - if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null }; - - const daysLeft = Math.floor(med.count / dailyUsage); - const depletionMs = Date.now() + daysLeft * 86_400_000; - const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), { - weekday: "short", - day: "2-digit", - month: "short", - }); - - return { daysLeft, depletionDate }; +function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] { + return parseBlisters(row); } type LowStockItem = { @@ -236,7 +92,7 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore: const lowStock: LowStockItem[] = []; for (const row of rows) { - const blisters = parseBlisters(row); + const blisters = parseBlistersFromRow(row); const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets; const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language); @@ -486,8 +342,8 @@ async function checkAndSendReminderForUser( let schedulerTimeout: NodeJS.Timeout | null = null; function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: string) => void }): void { - const msUntilNext = getMsUntilNextCheck(); - const nextTime = getNextScheduledTime(); + const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR); + const nextTime = getNextScheduledTime(REMINDER_HOUR); // Save next scheduled time to state const state = loadReminderState(); diff --git a/backend/src/test/auth.test.ts b/backend/src/test/auth.test.ts new file mode 100644 index 0000000..9c0e313 --- /dev/null +++ b/backend/src/test/auth.test.ts @@ -0,0 +1,685 @@ +/** + * E2E Tests for auth routes with AUTH_ENABLED=true + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import Fastify, { FastifyInstance } from "fastify"; +import cookie from "@fastify/cookie"; +import jwt from "@fastify/jwt"; +import sensible from "@fastify/sensible"; +import { createClient, Client } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; + +// Use vi.hoisted to create the db BEFORE mocks are set up +const { testClient, testDb } = vi.hoisted(() => { + const { createClient } = require("@libsql/client"); + const { drizzle } = require("drizzle-orm/libsql"); + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + return { testClient: client, testDb: db }; +}); + +// Mock modules using the hoisted db +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); + +// Enable auth for these tests +vi.mock("../plugins/env.js", () => ({ + env: { + AUTH_ENABLED: true, + LOCAL_AUTH_ENABLED: true, + REGISTRATION_ENABLED: true, + OIDC_ENABLED: false, + NODE_ENV: "test", + LOG_LEVEL: "silent", + PORT: 3000, + CORS_ORIGINS: "*", + JWT_SECRET: "test-jwt-secret-12345", + REFRESH_SECRET: "test-refresh-secret-12345", + COOKIE_SECRET: "test-cookie-secret-12345", + ACCESS_TOKEN_TTL_MINUTES: 15, + REFRESH_TOKEN_TTL_DAYS: 7, + }, +})); + +// Import real auth plugin and routes +const { authRoutes } = await import("../routes/auth.js"); + +// ============================================================================= +// Test Setup +// ============================================================================= + +async function createSchema(client: Client) { + const tableCreations = [ + `CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + password_hash text, + avatar_url text, + auth_provider text NOT NULL DEFAULT 'local', + oidc_subject text, + is_active integer NOT NULL DEFAULT 1, + last_login_at integer, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + updated_at integer NOT NULL DEFAULT (strftime('%s','now')) + )`, + `CREATE TABLE IF NOT EXISTS refresh_tokens ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + token_id text NOT NULL UNIQUE, + expires_at integer NOT NULL, + revoked integer NOT NULL DEFAULT 0, + rotated_at integer, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + ]; + + for (const sql of tableCreations) { + await client.execute(sql); + } +} + +async function clearData(client: Client) { + await client.execute("DELETE FROM refresh_tokens"); + await client.execute("DELETE FROM users"); + await client.execute("DELETE FROM sqlite_sequence"); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("Auth Routes (AUTH_ENABLED=true)", () => { + let app: FastifyInstance; + + beforeAll(async () => { + await createSchema(testClient); + + app = Fastify({ logger: false }); + + await app.register(sensible); + await app.register(cookie, { secret: "test-cookie-secret-12345" }); + await app.register(jwt, { + secret: "test-jwt-secret-12345", + cookie: { cookieName: "access_token", signed: false }, + }); + + // Decorate with config needed by auth routes + app.decorate("config", { + accessSecret: "test-jwt-secret-12345", + refreshSecret: "test-refresh-secret-12345", + accessTtl: 15, + refreshTtl: 7, + cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/", maxAge: 15 * 60 }, + refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/auth", maxAge: 7 * 24 * 60 * 60 }, + }); + + await app.register(authRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + testClient.close(); + }); + + beforeEach(async () => { + await clearData(testClient); + }); + + // --------------------------------------------------------------------------- + // Auth State Tests + // --------------------------------------------------------------------------- + + describe("GET /auth/state", () => { + it("should return auth state", async () => { + const response = await app.inject({ + method: "GET", + url: "/auth/state", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.authEnabled).toBe(true); + expect(data.registrationEnabled).toBe(true); + expect(data.localAuthEnabled).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // Registration Tests + // --------------------------------------------------------------------------- + + describe("POST /auth/register", () => { + it("should register a new user", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "testuser", + password: "TestPassword123", + }, + }); + + expect(response.statusCode).toBe(201); + const data = response.json(); + expect(data.ok).toBe(true); + expect(data.user.username).toBe("testuser"); + }); + + it("should reject duplicate username", async () => { + // First registration + await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "duplicate", + password: "TestPassword123", + }, + }); + + // Second registration with same username + const response = await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "duplicate", + password: "AnotherPassword123", + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().code).toBe("USERNAME_EXISTS"); + }); + + it("should reject short password", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "testuser", + password: "short", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().code).toBe("VALIDATION_ERROR"); + }); + + it("should reject short username", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "ab", + password: "ValidPassword123", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().code).toBe("VALIDATION_ERROR"); + }); + + it("should reject invalid username characters", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "test@user", + password: "ValidPassword123", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().code).toBe("VALIDATION_ERROR"); + }); + }); + + // --------------------------------------------------------------------------- + // Login Tests + // --------------------------------------------------------------------------- + + describe("POST /auth/login", () => { + beforeEach(async () => { + // Create a test user + await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "loginuser", + password: "TestPassword123", + }, + }); + }); + + it("should login with valid credentials", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "loginuser", + password: "TestPassword123", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.ok).toBe(true); + expect(data.user.username).toBe("loginuser"); + + // Should set cookies + const cookies = response.cookies; + expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined(); + expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined(); + }); + + it("should reject invalid password", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "loginuser", + password: "WrongPassword", + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().code).toBe("INVALID_CREDENTIALS"); + }); + + it("should reject non-existent user", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "nonexistent", + password: "TestPassword123", + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().code).toBe("INVALID_CREDENTIALS"); + }); + + it("should support rememberMe option", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "loginuser", + password: "TestPassword123", + rememberMe: true, + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.ok).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // Token Refresh Tests + // --------------------------------------------------------------------------- + + describe("POST /auth/refresh", () => { + it("should refresh access token with valid refresh token", async () => { + // Login first to get tokens + const loginResponse = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "loginuser", + password: "TestPassword123", + }, + }); + + // Need to create user first + await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "refreshuser", + password: "TestPassword123", + }, + }); + + const login = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "refreshuser", + password: "TestPassword123", + }, + }); + + const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token"); + + const response = await app.inject({ + method: "POST", + url: "/auth/refresh", + cookies: { + refresh_token: refreshToken?.value ?? "", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().ok).toBe(true); + }); + + it("should reject without refresh token", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/refresh", + }); + + expect(response.statusCode).toBe(401); + expect(response.json().code).toBe("NO_REFRESH_TOKEN"); + }); + + it("should reject invalid refresh token", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/refresh", + cookies: { + refresh_token: "invalid-token", + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().code).toBe("INVALID_REFRESH_TOKEN"); + }); + }); + + // --------------------------------------------------------------------------- + // Logout Tests + // --------------------------------------------------------------------------- + + describe("POST /auth/logout", () => { + it("should logout and clear cookies", async () => { + // Register and login first + await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "logoutuser", + password: "TestPassword123", + }, + }); + + const login = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "logoutuser", + password: "TestPassword123", + }, + }); + + const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token"); + + const response = await app.inject({ + method: "POST", + url: "/auth/logout", + cookies: { + refresh_token: refreshToken?.value ?? "", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().ok).toBe(true); + }); + + it("should succeed even without refresh token", async () => { + const response = await app.inject({ + method: "POST", + url: "/auth/logout", + }); + + expect(response.statusCode).toBe(200); + expect(response.json().ok).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // Me Endpoint Tests + // --------------------------------------------------------------------------- + + describe("GET /auth/me", () => { + it("should return user info with valid access token", async () => { + // Register and login + await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "meuser", + password: "TestPassword123", + }, + }); + + const login = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "meuser", + password: "TestPassword123", + }, + }); + + const accessToken = login.cookies.find((c: any) => c.name === "access_token"); + + const response = await app.inject({ + method: "GET", + url: "/auth/me", + cookies: { + access_token: accessToken?.value ?? "", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.username).toBe("meuser"); + }); + + it("should reject without access token", async () => { + const response = await app.inject({ + method: "GET", + url: "/auth/me", + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject with invalid access token", async () => { + const response = await app.inject({ + method: "GET", + url: "/auth/me", + cookies: { + access_token: "invalid.jwt.token", + }, + }); + + expect(response.statusCode).toBe(401); + }); + }); + + // --------------------------------------------------------------------------- + // Inactive User Tests + // --------------------------------------------------------------------------- + + describe("Inactive user handling", () => { + it("should reject login for inactive user", async () => { + // Create user + await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "inactiveuser", + password: "TestPassword123", + }, + }); + + // Manually deactivate user in DB + await testClient.execute({ + sql: "UPDATE users SET is_active = 0 WHERE username = ?", + args: ["inactiveuser"], + }); + + const response = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "inactiveuser", + password: "TestPassword123", + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().code).toBe("ACCOUNT_DISABLED"); + }); + }); + + // --------------------------------------------------------------------------- + // Profile Update Tests + // --------------------------------------------------------------------------- + + describe("PUT /auth/me (profile update)", () => { + it("should update password with valid current password", async () => { + // Register and login + await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "profileuser", + password: "TestPassword123", + }, + }); + + const login = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "profileuser", + password: "TestPassword123", + }, + }); + + const accessToken = login.cookies.find((c: any) => c.name === "access_token"); + + const response = await app.inject({ + method: "PUT", + url: "/auth/me", + cookies: { + access_token: accessToken?.value ?? "", + }, + payload: { + currentPassword: "TestPassword123", + newPassword: "NewPassword456", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().ok).toBe(true); + + // Verify can login with new password + const newLogin = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "profileuser", + password: "NewPassword456", + }, + }); + + expect(newLogin.statusCode).toBe(200); + }); + + it("should reject password change without current password", async () => { + await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "profileuser2", + password: "TestPassword123", + }, + }); + + const login = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "profileuser2", + password: "TestPassword123", + }, + }); + + const accessToken = login.cookies.find((c: any) => c.name === "access_token"); + + const response = await app.inject({ + method: "PUT", + url: "/auth/me", + cookies: { + access_token: accessToken?.value ?? "", + }, + payload: { + newPassword: "NewPassword456", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().code).toBe("CURRENT_PASSWORD_REQUIRED"); + }); + + it("should reject password change with wrong current password", async () => { + await app.inject({ + method: "POST", + url: "/auth/register", + payload: { + username: "profileuser3", + password: "TestPassword123", + }, + }); + + const login = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: "profileuser3", + password: "TestPassword123", + }, + }); + + const accessToken = login.cookies.find((c: any) => c.name === "access_token"); + + const response = await app.inject({ + method: "PUT", + url: "/auth/me", + cookies: { + access_token: accessToken?.value ?? "", + }, + payload: { + currentPassword: "WrongPassword", + newPassword: "NewPassword456", + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().code).toBe("INVALID_PASSWORD"); + }); + + it("should reject profile update without auth", async () => { + const response = await app.inject({ + method: "PUT", + url: "/auth/me", + payload: { + currentPassword: "Test123", + newPassword: "NewPassword456", + }, + }); + + expect(response.statusCode).toBe(401); + }); + }); +}); diff --git a/backend/src/test/database.test.ts b/backend/src/test/database.test.ts new file mode 100644 index 0000000..22c7bbf --- /dev/null +++ b/backend/src/test/database.test.ts @@ -0,0 +1,897 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { mkdirSync, rmSync, existsSync } from "fs"; +import { resolve } from "path"; +import { tmpdir } from "os"; + +// Import the exported utility functions from client.ts +import { + buildDbUrl, + getDbPaths, + ensureDataDirectory, + getTableCreationSQL, + runTableMigrations, + ensureDefaultUser, +} from "../db/client.js"; + +// Import the exported utility functions from migrate.ts +import { + getMigrationSQL, + splitSQLStatements, + executeMigration, + getStatementPreview, +} from "../db/migrate.js"; + +describe("Migration Script Utilities", () => { + describe("getMigrationSQL", () => { + it("should return a non-empty SQL string", () => { + const sql = getMigrationSQL(); + expect(typeof sql).toBe("string"); + expect(sql.length).toBeGreaterThan(100); + }); + + it("should contain all table definitions", () => { + const sql = getMigrationSQL(); + expect(sql).toContain("CREATE TABLE IF NOT EXISTS users"); + expect(sql).toContain("CREATE TABLE IF NOT EXISTS medications"); + expect(sql).toContain("CREATE TABLE IF NOT EXISTS user_settings"); + expect(sql).toContain("CREATE TABLE IF NOT EXISTS refresh_tokens"); + expect(sql).toContain("CREATE TABLE IF NOT EXISTS share_tokens"); + expect(sql).toContain("CREATE TABLE IF NOT EXISTS dose_tracking"); + }); + }); + + describe("splitSQLStatements", () => { + it("should split SQL by semicolons", () => { + const sql = "SELECT 1; SELECT 2; SELECT 3;"; + const statements = splitSQLStatements(sql); + expect(statements).toHaveLength(3); + }); + + it("should filter out empty statements", () => { + const sql = "SELECT 1;; ; SELECT 2;"; + const statements = splitSQLStatements(sql); + expect(statements).toHaveLength(2); + }); + + it("should handle statements without trailing semicolon", () => { + const sql = "SELECT 1; SELECT 2"; + const statements = splitSQLStatements(sql); + expect(statements).toHaveLength(2); + }); + + it("should split migration SQL into 6 statements", () => { + const sql = getMigrationSQL(); + const statements = splitSQLStatements(sql); + expect(statements).toHaveLength(6); + }); + + it("should preserve whitespace within statements", () => { + const sql = "CREATE TABLE test (\n id INTEGER\n);"; + const statements = splitSQLStatements(sql); + expect(statements[0]).toContain("\n"); + }); + }); + + describe("getStatementPreview", () => { + it("should return full string if shorter than maxLength", () => { + const preview = getStatementPreview("SELECT 1", 50); + expect(preview).toBe("SELECT 1"); + }); + + it("should truncate and add ellipsis if longer than maxLength", () => { + const preview = getStatementPreview("SELECT * FROM very_long_table_name WHERE condition = true", 20); + expect(preview).toBe("SELECT * FROM very_l..."); + expect(preview.length).toBe(23); // 20 + "..." + }); + + it("should use default maxLength of 50", () => { + const longStmt = "A".repeat(100); + const preview = getStatementPreview(longStmt); + expect(preview).toBe("A".repeat(50) + "..."); + }); + + it("should trim whitespace", () => { + const preview = getStatementPreview(" SELECT 1 ", 50); + expect(preview).toBe("SELECT 1"); + }); + + it("should handle CREATE TABLE statements", () => { + const stmt = "CREATE TABLE IF NOT EXISTS users (id integer PRIMARY KEY)"; + const preview = getStatementPreview(stmt, 30); + expect(preview).toBe("CREATE TABLE IF NOT EXISTS use..."); + }); + }); + + describe("executeMigration", () => { + let client: ReturnType; + + beforeEach(() => { + client = createClient({ url: ":memory:" }); + }); + + it("should execute all migrations successfully", async () => { + const result = await executeMigration(client); + expect(result.success).toBe(true); + expect(result.executed).toBe(6); + expect(result.errors).toHaveLength(0); + }); + + it("should create all tables", async () => { + await executeMigration(client); + + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ); + + const tableNames = tables.rows.map(r => r.name); + expect(tableNames).toContain("users"); + expect(tableNames).toContain("medications"); + expect(tableNames).toContain("user_settings"); + expect(tableNames).toContain("refresh_tokens"); + expect(tableNames).toContain("share_tokens"); + expect(tableNames).toContain("dose_tracking"); + }); + + it("should be idempotent", async () => { + await executeMigration(client); + const result = await executeMigration(client); + expect(result.success).toBe(true); + expect(result.executed).toBe(6); + }); + + it("should allow inserting data after migration", async () => { + await executeMigration(client); + + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + const result = await client.execute("SELECT * FROM users"); + expect(result.rows).toHaveLength(1); + }); + }); +}); + +describe("Database Client Utilities", () => { + describe("buildDbUrl", () => { + it("should build a file:// URL from path", () => { + const url = buildDbUrl("/path/to/db.sqlite"); + expect(url).toBe("file:/path/to/db.sqlite"); + }); + + it("should handle relative paths", () => { + const url = buildDbUrl("./data/test.db"); + expect(url).toBe("file:./data/test.db"); + }); + }); + + describe("getDbPaths", () => { + it("should return correct paths based on cwd", () => { + const paths = getDbPaths("/app"); + expect(paths.dataDir).toBe("/app/data"); + expect(paths.dbPath).toBe("/app/data/medassist-ng.db"); + expect(paths.url).toBe("file:/app/data/medassist-ng.db"); + }); + + it("should use process.cwd() by default", () => { + const paths = getDbPaths(); + expect(paths.dataDir).toContain("data"); + expect(paths.dbPath).toContain("medassist-ng.db"); + }); + }); + + describe("ensureDataDirectory", () => { + const testDir = resolve(tmpdir(), `test-data-dir-${Date.now()}`); + + afterEach(() => { + try { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + } catch { + // ignore cleanup errors + } + }); + + it("should create directory if it does not exist", () => { + const result = ensureDataDirectory(testDir); + expect(result.success).toBe(true); + expect(existsSync(testDir)).toBe(true); + }); + + it("should succeed if directory already exists", () => { + mkdirSync(testDir, { recursive: true }); + const result = ensureDataDirectory(testDir); + expect(result.success).toBe(true); + }); + + it("should create .write-test file", () => { + const result = ensureDataDirectory(testDir); + expect(result.success).toBe(true); + expect(existsSync(resolve(testDir, ".write-test"))).toBe(true); + }); + + it("should return error for invalid path", () => { + // Try to create in a path that can't exist + const result = ensureDataDirectory("/nonexistent/root/path/that/cannot/exist"); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe("getTableCreationSQL", () => { + it("should return array of SQL statements", () => { + const statements = getTableCreationSQL(); + expect(Array.isArray(statements)).toBe(true); + expect(statements.length).toBe(6); + }); + + it("should include users table", () => { + const statements = getTableCreationSQL(); + const usersSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS users")); + expect(usersSQL).toBeDefined(); + expect(usersSQL).toContain("username text NOT NULL UNIQUE"); + expect(usersSQL).toContain("password_hash text"); + expect(usersSQL).toContain("auth_provider text NOT NULL DEFAULT 'local'"); + }); + + it("should include medications table", () => { + const statements = getTableCreationSQL(); + const medsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS medications")); + expect(medsSQL).toBeDefined(); + expect(medsSQL).toContain("user_id integer NOT NULL"); + expect(medsSQL).toContain("taken_by_json text NOT NULL DEFAULT '[]'"); + expect(medsSQL).toContain("FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"); + }); + + it("should include user_settings table", () => { + const statements = getTableCreationSQL(); + const settingsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS user_settings")); + expect(settingsSQL).toBeDefined(); + expect(settingsSQL).toContain("email_enabled integer NOT NULL DEFAULT 0"); + expect(settingsSQL).toContain("language text NOT NULL DEFAULT 'en'"); + }); + + it("should include refresh_tokens table", () => { + const statements = getTableCreationSQL(); + const tokensSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS refresh_tokens")); + expect(tokensSQL).toBeDefined(); + expect(tokensSQL).toContain("token_id text NOT NULL UNIQUE"); + }); + + it("should include share_tokens table", () => { + const statements = getTableCreationSQL(); + const shareSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS share_tokens")); + expect(shareSQL).toBeDefined(); + expect(shareSQL).toContain("taken_by text NOT NULL"); + }); + + it("should include dose_tracking table", () => { + const statements = getTableCreationSQL(); + const doseSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS dose_tracking")); + expect(doseSQL).toBeDefined(); + expect(doseSQL).toContain("dose_id text NOT NULL"); + expect(doseSQL).toContain("marked_by text"); + }); + }); + + describe("runTableMigrations", () => { + let client: ReturnType; + + beforeEach(() => { + client = createClient({ url: ":memory:" }); + }); + + it("should create all tables successfully", async () => { + const result = await runTableMigrations(client); + expect(result.success).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should be idempotent (run twice without errors)", async () => { + await runTableMigrations(client); + const result = await runTableMigrations(client); + expect(result.success).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should create all 6 tables", async () => { + await runTableMigrations(client); + + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ); + + const tableNames = tables.rows.map(r => r.name); + expect(tableNames).toContain("users"); + expect(tableNames).toContain("medications"); + expect(tableNames).toContain("user_settings"); + expect(tableNames).toContain("refresh_tokens"); + expect(tableNames).toContain("share_tokens"); + expect(tableNames).toContain("dose_tracking"); + }); + }); + + describe("ensureDefaultUser", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + await runTableMigrations(client); + }); + + it("should create default user when auth is disabled", async () => { + const created = await ensureDefaultUser(client, false); + expect(created).toBe(true); + + const result = await client.execute("SELECT * FROM users WHERE id = 1"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].username).toBe("default"); + expect(result.rows[0].auth_provider).toBe("local"); + }); + + it("should not create user when auth is enabled", async () => { + const created = await ensureDefaultUser(client, true); + expect(created).toBe(false); + + const result = await client.execute("SELECT * FROM users WHERE id = 1"); + expect(result.rows).toHaveLength(0); + }); + + it("should not duplicate user if already exists", async () => { + // First call creates the user + await ensureDefaultUser(client, false); + + // Second call should not create again + const created = await ensureDefaultUser(client, false); + expect(created).toBe(false); + + // Should still have only one user + const result = await client.execute("SELECT * FROM users"); + expect(result.rows).toHaveLength(1); + }); + }); +}); + +describe("Database Client", () => { + describe("In-Memory Database Creation", () => { + it("should create an in-memory SQLite client", () => { + const client = createClient({ url: ":memory:" }); + expect(client).toBeDefined(); + }); + + it("should create a drizzle instance from client", () => { + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + expect(db).toBeDefined(); + }); + + it("should execute SQL statements", async () => { + const client = createClient({ url: ":memory:" }); + + // Create a simple test table + await client.execute(` + CREATE TABLE IF NOT EXISTS test_table ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ) + `); + + // Insert a row + await client.execute("INSERT INTO test_table (name) VALUES ('test')"); + + // Query the row + const result = await client.execute("SELECT * FROM test_table"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toBe("test"); + }); + }); + + describe("Table Schema Creation", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + }); + + it("should create users table", async () => { + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + password_hash text, + avatar_url text, + auth_provider text NOT NULL DEFAULT 'local', + oidc_subject text, + is_active integer NOT NULL DEFAULT 1, + last_login_at integer, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + updated_at integer NOT NULL DEFAULT (strftime('%s','now')) + ) + `); + + // Verify table exists + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='users'" + ); + expect(tables.rows).toHaveLength(1); + }); + + it("should create medications table with foreign key", async () => { + // First create users table + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local' + ) + `); + + await client.execute(` + CREATE TABLE IF NOT EXISTS medications ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + name text NOT NULL, + generic_name text, + taken_by_json text NOT NULL DEFAULT '[]', + pack_count integer NOT NULL DEFAULT 1, + blisters_per_pack integer NOT NULL DEFAULT 1, + pills_per_blister integer NOT NULL DEFAULT 1, + loose_tablets integer NOT NULL DEFAULT 0, + pill_weight_mg integer, + usage_json text NOT NULL DEFAULT '[]', + every_json text NOT NULL DEFAULT '[]', + start_json text NOT NULL DEFAULT '[]', + image_url text, + expiry_date text, + notes text, + intake_reminders_enabled integer NOT NULL DEFAULT 0, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='medications'" + ); + expect(tables.rows).toHaveLength(1); + }); + + it("should create user_settings table", async () => { + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local' + ) + `); + + await client.execute(` + CREATE TABLE IF NOT EXISTS user_settings ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL UNIQUE, + email_enabled integer NOT NULL DEFAULT 0, + notification_email text, + email_stock_reminders integer NOT NULL DEFAULT 1, + email_intake_reminders integer NOT NULL DEFAULT 1, + shoutrrr_enabled integer NOT NULL DEFAULT 0, + shoutrrr_url text, + shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, + shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, + reminder_days_before integer NOT NULL DEFAULT 7, + repeat_daily_reminders integer NOT NULL DEFAULT 0, + low_stock_days integer NOT NULL DEFAULT 30, + normal_stock_days integer NOT NULL DEFAULT 90, + high_stock_days integer NOT NULL DEFAULT 180, + expiry_warning_days integer NOT NULL DEFAULT 90, + language text NOT NULL DEFAULT 'en', + stock_calculation_mode text NOT NULL DEFAULT 'automatic', + last_auto_email_sent text, + last_notification_type text, + last_notification_channel text, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'" + ); + expect(tables.rows).toHaveLength(1); + }); + + it("should create refresh_tokens table", async () => { + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local' + ) + `); + + await client.execute(` + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + token_id text NOT NULL UNIQUE, + expires_at integer NOT NULL, + rotated_at integer, + revoked integer NOT NULL DEFAULT 0, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'" + ); + expect(tables.rows).toHaveLength(1); + }); + + it("should create share_tokens table", async () => { + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local' + ) + `); + + await client.execute(` + CREATE TABLE IF NOT EXISTS share_tokens ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + token text NOT NULL UNIQUE, + taken_by text NOT NULL, + schedule_days integer NOT NULL DEFAULT 30, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + expires_at integer, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='share_tokens'" + ); + expect(tables.rows).toHaveLength(1); + }); + + it("should create dose_tracking table", async () => { + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local' + ) + `); + + await client.execute(` + CREATE TABLE IF NOT EXISTS dose_tracking ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + dose_id text NOT NULL, + taken_at integer NOT NULL DEFAULT (strftime('%s','now')), + marked_by text, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='dose_tracking'" + ); + expect(tables.rows).toHaveLength(1); + }); + + it("should enforce unique constraint on username", async () => { + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local' + ) + `); + + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + + await expect( + client.execute("INSERT INTO users (username) VALUES ('testuser')") + ).rejects.toThrow(); + }); + + it("should enforce unique constraint on refresh token_id", async () => { + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local' + ) + `); + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + + await client.execute(` + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + token_id text NOT NULL UNIQUE, + expires_at integer NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + await client.execute( + "INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)" + ); + + await expect( + client.execute( + "INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)" + ) + ).rejects.toThrow(); + }); + }); + + describe("Default Values", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local', + is_active integer NOT NULL DEFAULT 1, + created_at integer NOT NULL DEFAULT (strftime('%s','now')) + ) + `); + }); + + it("should use default values for auth_provider", async () => { + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + + const result = await client.execute("SELECT auth_provider FROM users WHERE username = 'testuser'"); + expect(result.rows[0].auth_provider).toBe("local"); + }); + + it("should use default values for is_active", async () => { + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + + const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'"); + expect(result.rows[0].is_active).toBe(1); + }); + + it("should generate created_at timestamp", async () => { + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + + const result = await client.execute("SELECT created_at FROM users WHERE username = 'testuser'"); + expect(typeof result.rows[0].created_at).toBe("number"); + // Should be a reasonable Unix timestamp (after year 2020) + expect(Number(result.rows[0].created_at)).toBeGreaterThan(1577836800); + }); + }); + + describe("User Settings Defaults", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local' + ) + `); + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + + await client.execute(` + CREATE TABLE IF NOT EXISTS user_settings ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL UNIQUE, + email_enabled integer NOT NULL DEFAULT 0, + shoutrrr_enabled integer NOT NULL DEFAULT 0, + reminder_days_before integer NOT NULL DEFAULT 7, + repeat_daily_reminders integer NOT NULL DEFAULT 0, + low_stock_days integer NOT NULL DEFAULT 30, + normal_stock_days integer NOT NULL DEFAULT 90, + high_stock_days integer NOT NULL DEFAULT 180, + expiry_warning_days integer NOT NULL DEFAULT 90, + language text NOT NULL DEFAULT 'en', + stock_calculation_mode text NOT NULL DEFAULT 'automatic', + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + }); + + it("should use default notification settings", async () => { + await client.execute("INSERT INTO user_settings (user_id) VALUES (1)"); + + const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1"); + expect(result.rows[0].email_enabled).toBe(0); + expect(result.rows[0].shoutrrr_enabled).toBe(0); + }); + + it("should use default stock threshold settings", async () => { + await client.execute("INSERT INTO user_settings (user_id) VALUES (1)"); + + const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1"); + expect(result.rows[0].low_stock_days).toBe(30); + expect(result.rows[0].normal_stock_days).toBe(90); + expect(result.rows[0].high_stock_days).toBe(180); + expect(result.rows[0].expiry_warning_days).toBe(90); + }); + + it("should use default language (en)", async () => { + await client.execute("INSERT INTO user_settings (user_id) VALUES (1)"); + + const result = await client.execute("SELECT language FROM user_settings WHERE user_id = 1"); + expect(result.rows[0].language).toBe("en"); + }); + + it("should use default stock_calculation_mode (automatic)", async () => { + await client.execute("INSERT INTO user_settings (user_id) VALUES (1)"); + + const result = await client.execute("SELECT stock_calculation_mode FROM user_settings WHERE user_id = 1"); + expect(result.rows[0].stock_calculation_mode).toBe("automatic"); + }); + + it("should use default reminder_days_before (7)", async () => { + await client.execute("INSERT INTO user_settings (user_id) VALUES (1)"); + + const result = await client.execute("SELECT reminder_days_before FROM user_settings WHERE user_id = 1"); + expect(result.rows[0].reminder_days_before).toBe(7); + }); + }); + + describe("Medication Defaults", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local' + ) + `); + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + + await client.execute(` + CREATE TABLE IF NOT EXISTS medications ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + name text NOT NULL, + taken_by_json text NOT NULL DEFAULT '[]', + pack_count integer NOT NULL DEFAULT 1, + blisters_per_pack integer NOT NULL DEFAULT 1, + pills_per_blister integer NOT NULL DEFAULT 1, + loose_tablets integer NOT NULL DEFAULT 0, + usage_json text NOT NULL DEFAULT '[]', + every_json text NOT NULL DEFAULT '[]', + start_json text NOT NULL DEFAULT '[]', + intake_reminders_enabled integer NOT NULL DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + }); + + it("should use default inventory values", async () => { + await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')"); + + const result = await client.execute("SELECT * FROM medications WHERE name = 'Test Med'"); + expect(result.rows[0].pack_count).toBe(1); + expect(result.rows[0].blisters_per_pack).toBe(1); + expect(result.rows[0].pills_per_blister).toBe(1); + expect(result.rows[0].loose_tablets).toBe(0); + }); + + it("should use default JSON arrays for schedules", async () => { + await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')"); + + const result = await client.execute("SELECT * FROM medications WHERE name = 'Test Med'"); + expect(result.rows[0].taken_by_json).toBe("[]"); + expect(result.rows[0].usage_json).toBe("[]"); + expect(result.rows[0].every_json).toBe("[]"); + expect(result.rows[0].start_json).toBe("[]"); + }); + + it("should default intake_reminders_enabled to false (0)", async () => { + await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')"); + + const result = await client.execute("SELECT intake_reminders_enabled FROM medications WHERE name = 'Test Med'"); + expect(result.rows[0].intake_reminders_enabled).toBe(0); + }); + }); + + describe("Foreign Key Constraints", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + // Enable foreign keys + await client.execute("PRAGMA foreign_keys = ON"); + + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE + ) + `); + await client.execute(` + CREATE TABLE IF NOT EXISTS medications ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + name text NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + }); + + it("should cascade delete medications when user is deleted", async () => { + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Med1')"); + await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Med2')"); + + // Verify medications exist + let meds = await client.execute("SELECT * FROM medications"); + expect(meds.rows).toHaveLength(2); + + // Delete user + await client.execute("DELETE FROM users WHERE id = 1"); + + // Medications should be deleted too + meds = await client.execute("SELECT * FROM medications"); + expect(meds.rows).toHaveLength(0); + }); + }); + + describe("Default User Creation (Auth Disabled)", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + auth_provider text NOT NULL DEFAULT 'local' + ) + `); + }); + + it("should be able to create a default user with ID 1", async () => { + // This mimics the auth-disabled mode behavior + const result = await client.execute("SELECT id FROM users WHERE id = 1"); + + if (result.rows.length === 0) { + await client.execute( + "INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')" + ); + } + + const user = await client.execute("SELECT * FROM users WHERE id = 1"); + expect(user.rows).toHaveLength(1); + expect(user.rows[0].username).toBe("default"); + expect(user.rows[0].auth_provider).toBe("local"); + }); + + it("should not duplicate default user if already exists", async () => { + await client.execute( + "INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')" + ); + + // Check if exists before insert (mimics runtime behavior) + const result = await client.execute("SELECT id FROM users WHERE id = 1"); + + if (result.rows.length === 0) { + await client.execute( + "INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')" + ); + } + + // Should still have only one user + const users = await client.execute("SELECT * FROM users"); + expect(users.rows).toHaveLength(1); + }); + }); +}); diff --git a/backend/src/test/doses.test.ts b/backend/src/test/doses.test.ts new file mode 100644 index 0000000..7e3e4c0 --- /dev/null +++ b/backend/src/test/doses.test.ts @@ -0,0 +1,364 @@ +/** + * Tests for /doses/taken API endpoints. + * Tests marking doses as taken, listing taken doses, and unmarking. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + buildTestApp, + closeTestApp, + clearTestData, + createTestUser, + createTestMedication, + TestContext, +} from "./setup.js"; + +// ============================================================================= +// Route Registration +// Since we can't easily import routes that depend on the global db, +// we'll create simplified route handlers for testing the core logic. +// ============================================================================= + +async function registerDoseRoutes(ctx: TestContext) { + const { app, client } = ctx; + + // GET /doses/taken - List all taken doses + app.get("/doses/taken", async (request, reply) => { + // In test mode, use user ID 1 (will be created in tests) + const userId = 1; + + const result = await client.execute({ + sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`, + args: [userId], + }); + + return { + doses: result.rows.map((d) => ({ + doseId: d.dose_id, + takenAt: (d.taken_at as number) * 1000, // Convert to ms + markedBy: d.marked_by, + })), + }; + }); + + // POST /doses/taken - Mark a dose as taken + app.post<{ Body: { doseId: string } }>("/doses/taken", async (request, reply) => { + const userId = 1; + const { doseId } = request.body || {}; + + if (!doseId || typeof doseId !== "string" || doseId.length === 0) { + return reply.status(400).send({ error: "doseId is required" }); + } + + // Check if already marked + const existing = await client.execute({ + sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + args: [userId, doseId], + }); + + if (existing.rows.length > 0) { + return { success: true, message: "Already marked" }; + } + + // Insert new record + await client.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, NULL)`, + args: [userId, doseId], + }); + + return { success: true }; + }); + + // DELETE /doses/taken/:doseId - Unmark a dose + app.delete<{ Params: { doseId: string } }>("/doses/taken/:doseId", async (request, reply) => { + const userId = 1; + const { doseId } = request.params; + + await client.execute({ + sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + args: [userId, doseId], + }); + + return { success: true }; + }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("Dose Tracking API", () => { + let ctx: TestContext; + let userId: number; + + beforeAll(async () => { + ctx = await buildTestApp(); + await registerDoseRoutes(ctx); + await ctx.app.ready(); + }); + + afterAll(async () => { + await closeTestApp(ctx); + }); + + beforeEach(async () => { + await clearTestData(ctx.client); + // Create test user - will get ID 1 since table is cleared + userId = await createTestUser(ctx.client, { username: "testuser" }); + // Reset SQLite autoincrement so user gets ID 1 + await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'"); + await clearTestData(ctx.client); + userId = await createTestUser(ctx.client, { username: "testuser" }); + }); + + // --------------------------------------------------------------------------- + // POST /doses/taken + // --------------------------------------------------------------------------- + + describe("POST /doses/taken", () => { + it("should mark a dose as taken", async () => { + const doseId = "1-0-1735344000000"; + + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify in database + const result = await ctx.client.execute({ + sql: `SELECT dose_id, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + args: [userId, doseId], + }); + expect(result.rows.length).toBe(1); + expect(result.rows[0].dose_id).toBe(doseId); + expect(result.rows[0].marked_by).toBeNull(); + }); + + it("should return idempotent response when dose already marked", async () => { + const doseId = "1-0-1735344000000"; + + // Mark once + await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + // Mark again + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, message: "Already marked" }); + + // Should still only have one record + const result = await ctx.client.execute({ + sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + args: [userId, doseId], + }); + expect(result.rows[0].count).toBe(1); + }); + + it("should reject request without doseId", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: {}, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "doseId is required" }); + }); + + it("should reject request with empty doseId", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId: "" }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "doseId is required" }); + }); + }); + + // --------------------------------------------------------------------------- + // GET /doses/taken + // --------------------------------------------------------------------------- + + describe("GET /doses/taken", () => { + it("should return empty array when no doses taken", async () => { + const response = await ctx.app.inject({ + method: "GET", + url: "/doses/taken", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ doses: [] }); + }); + + it("should return list of taken doses", async () => { + const doseId1 = "1-0-1735344000000"; + const doseId2 = "1-0-1735430400000"; + + // Mark two doses + await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId: doseId1 }, + }); + await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId: doseId2 }, + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/doses/taken", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.doses).toHaveLength(2); + expect(data.doses.map((d: any) => d.doseId).sort()).toEqual([doseId1, doseId2].sort()); + // Each dose should have a takenAt timestamp + for (const dose of data.doses) { + expect(dose.takenAt).toBeTypeOf("number"); + expect(dose.takenAt).toBeGreaterThan(0); + expect(dose.markedBy).toBeNull(); + } + }); + + it("should include markedBy when present", async () => { + const doseId = "1-0-1735344000000"; + + // Insert directly with markedBy + await ctx.client.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, + args: [userId, doseId, "Daniel"], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/doses/taken", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.doses).toHaveLength(1); + expect(data.doses[0].markedBy).toBe("Daniel"); + }); + }); + + // --------------------------------------------------------------------------- + // DELETE /doses/taken/:doseId + // --------------------------------------------------------------------------- + + describe("DELETE /doses/taken/:doseId", () => { + it("should unmark a dose", async () => { + const doseId = "1-0-1735344000000"; + + // Mark first + await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + // Verify marked + let result = await ctx.client.execute({ + sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`, + args: [doseId], + }); + expect(result.rows[0].count).toBe(1); + + // Unmark + const response = await ctx.app.inject({ + method: "DELETE", + url: `/doses/taken/${encodeURIComponent(doseId)}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify unmarked + result = await ctx.client.execute({ + sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`, + args: [doseId], + }); + expect(result.rows[0].count).toBe(0); + }); + + it("should succeed even if dose was not marked", async () => { + const doseId = "nonexistent-dose-id"; + + const response = await ctx.app.inject({ + method: "DELETE", + url: `/doses/taken/${encodeURIComponent(doseId)}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + }); + }); + + // --------------------------------------------------------------------------- + // Dose ID Format Tests + // --------------------------------------------------------------------------- + + describe("Dose ID Format", () => { + it("should handle standard dose ID format: {medId}-{blisterIdx}-{timestamp}", async () => { + const doseId = "5-0-1735344000000"; + + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + }); + + it("should handle dose ID with person: {medId}-{blisterIdx}-{timestamp}-{person}", async () => { + const doseId = "5-0-1735344000000-Daniel"; + + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + }); + + it("should handle special characters in dose ID", async () => { + // Dose ID with URL-unsafe characters (edge case) + const doseId = "5-0-1735344000000-Max Müller"; + + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + + // Can retrieve it + const getResponse = await ctx.app.inject({ + method: "GET", + url: "/doses/taken", + }); + + expect(getResponse.json().doses[0].doseId).toBe(doseId); + }); + }); +}); diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts new file mode 100644 index 0000000..52c16f0 --- /dev/null +++ b/backend/src/test/e2e-routes.test.ts @@ -0,0 +1,1523 @@ +/** + * E2E Tests using the real routes against in-memory SQLite. + * These tests import the actual route handlers for real coverage. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import Fastify, { FastifyInstance } from "fastify"; +import cookie from "@fastify/cookie"; +import jwt from "@fastify/jwt"; +import sensible from "@fastify/sensible"; +import fastifyMultipart from "@fastify/multipart"; +import { createClient, Client } from "@libsql/client"; +import { drizzle, LibSQLDatabase } from "drizzle-orm/libsql"; + +// Use vi.hoisted to create the db BEFORE mocks are set up +const { testClient, testDb } = vi.hoisted(() => { + // Dynamic import inside hoisted block + const { createClient } = require("@libsql/client"); + const { drizzle } = require("drizzle-orm/libsql"); + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + return { testClient: client, testDb: db }; +}); + +// Mock modules using the hoisted db +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); + +vi.mock("../plugins/env.js", () => ({ + env: { + AUTH_ENABLED: false, + NODE_ENV: "test", + LOG_LEVEL: "silent", + PORT: 3000, + CORS_ORIGINS: "*", + JWT_SECRET: "test-secret", + REFRESH_SECRET: "test-refresh-secret", + COOKIE_SECRET: "test-cookie-secret", + ACCESS_TOKEN_TTL_MINUTES: 15, + REFRESH_TOKEN_TTL_DAYS: 7, + }, +})); + +// Mock auth plugin +vi.mock("../plugins/auth.js", () => ({ + requireAuth: async () => {}, + getAnonymousUserId: () => 999999999, +})); + +// Now import routes AFTER mocking +const { doseRoutes } = await import("../routes/doses.js"); +const { shareRoutes } = await import("../routes/share.js"); +const { medicationRoutes } = await import("../routes/medications.js"); +const { settingsRoutes } = await import("../routes/settings.js"); +const { healthRoutes } = await import("../routes/health.js"); + +// ============================================================================= +// Test Setup +// ============================================================================= + +async function createSchema(client: Client) { + const tableCreations = [ + `CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + password_hash text, + avatar_url text, + auth_provider text NOT NULL DEFAULT 'local', + oidc_subject text, + is_active integer NOT NULL DEFAULT 1, + last_login_at integer, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + updated_at integer NOT NULL DEFAULT (strftime('%s','now')) + )`, + `CREATE TABLE IF NOT EXISTS medications ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + name text NOT NULL, + generic_name text, + taken_by_json text NOT NULL DEFAULT '[]', + pack_count integer NOT NULL DEFAULT 1, + blisters_per_pack integer NOT NULL DEFAULT 1, + pills_per_blister integer NOT NULL DEFAULT 1, + loose_tablets integer NOT NULL DEFAULT 0, + pill_weight_mg integer, + usage_json text NOT NULL DEFAULT '[]', + every_json text NOT NULL DEFAULT '[]', + start_json text NOT NULL DEFAULT '[]', + image_url text, + expiry_date text, + notes text, + intake_reminders_enabled integer NOT NULL DEFAULT 0, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS user_settings ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL UNIQUE, + email_enabled integer NOT NULL DEFAULT 0, + notification_email text, + email_stock_reminders integer NOT NULL DEFAULT 1, + email_intake_reminders integer NOT NULL DEFAULT 1, + shoutrrr_enabled integer NOT NULL DEFAULT 0, + shoutrrr_url text, + shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, + shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, + reminder_days_before integer NOT NULL DEFAULT 7, + repeat_daily_reminders integer NOT NULL DEFAULT 0, + low_stock_days integer NOT NULL DEFAULT 30, + normal_stock_days integer NOT NULL DEFAULT 90, + high_stock_days integer NOT NULL DEFAULT 180, + expiry_warning_days integer NOT NULL DEFAULT 90, + language text NOT NULL DEFAULT 'en', + stock_calculation_mode text NOT NULL DEFAULT 'automatic', + last_auto_email_sent text, + last_notification_type text, + last_notification_channel text, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS share_tokens ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + token text NOT NULL UNIQUE, + taken_by text NOT NULL, + schedule_days integer NOT NULL DEFAULT 30, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + expires_at integer, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS dose_tracking ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + dose_id text NOT NULL, + taken_at integer NOT NULL DEFAULT (strftime('%s','now')), + marked_by text, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + ]; + + for (const sql of tableCreations) { + await client.execute(sql); + } +} + +async function clearData(client: Client) { + await client.execute("DELETE FROM dose_tracking"); + await client.execute("DELETE FROM share_tokens"); + await client.execute("DELETE FROM user_settings"); + await client.execute("DELETE FROM medications"); + await client.execute("DELETE FROM users"); + await client.execute("DELETE FROM sqlite_sequence"); +} + +async function createUser(client: Client, username: string): Promise { + const result = await client.execute({ + sql: `INSERT INTO users (username, auth_provider) VALUES (?, 'local') RETURNING id`, + args: [username], + }); + return result.rows[0].id as number; +} + +async function createMedication( + client: Client, + userId: number, + name: string, + takenBy: string[] +): Promise { + const result = await client.execute({ + sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json) + VALUES (?, ?, ?, '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]') RETURNING id`, + args: [userId, name, JSON.stringify(takenBy)], + }); + return result.rows[0].id as number; +} + +async function createShareToken( + client: Client, + userId: number, + takenBy: string, + token: string +): Promise { + await client.execute({ + sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)`, + args: [userId, token, takenBy], + }); +} + +// ============================================================================= +// E2E Tests with Real Routes +// ============================================================================= + +describe("E2E Tests with Real Routes", () => { + let app: FastifyInstance; + let userId: number; + + beforeAll(async () => { + // Create schema + await createSchema(testClient); + + // Build app with real routes + app = Fastify({ logger: false }); + + await app.register(sensible); + await app.register(cookie, { secret: "test-cookie-secret" }); + await app.register(jwt, { + secret: "test-jwt-secret", + cookie: { cookieName: "access_token", signed: false }, + }); + await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); + + app.decorate("config", { + accessSecret: "test-jwt-secret", + refreshSecret: "test-refresh-secret", + accessTtl: 15, + refreshTtl: 7, + cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" }, + refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" }, + }); + + // Register REAL routes + await app.register(doseRoutes); + await app.register(shareRoutes); + await app.register(medicationRoutes); + await app.register(settingsRoutes); + await app.register(healthRoutes); + + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + testClient.close(); + }); + + beforeEach(async () => { + await clearData(testClient); + // Create anonymous user with fixed ID for auth-disabled mode + await testClient.execute( + "INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')" + ); + userId = 999999999; + }); + + // --------------------------------------------------------------------------- + // Real Dose Routes Tests + // --------------------------------------------------------------------------- + + describe("Real /doses/taken routes", () => { + it("should mark a dose using real route", async () => { + const doseId = "1-0-1735344000000"; + + const response = await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify in database + const result = await testClient.execute({ + sql: `SELECT dose_id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + args: [userId, doseId], + }); + expect(result.rows.length).toBe(1); + }); + + it("should get taken doses using real route", async () => { + // Insert dose directly + await testClient.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id) VALUES (?, ?)`, + args: [userId, "1-0-1735344000000"], + }); + + const response = await app.inject({ + method: "GET", + url: "/doses/taken", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.doses).toHaveLength(1); + expect(data.doses[0].doseId).toBe("1-0-1735344000000"); + }); + + it("should delete dose using real route", async () => { + const doseId = "1-0-1735344000000"; + + // Insert first + await testClient.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id) VALUES (?, ?)`, + args: [userId, doseId], + }); + + const response = await app.inject({ + method: "DELETE", + url: `/doses/taken/${encodeURIComponent(doseId)}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify deleted + const result = await testClient.execute({ + sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`, + args: [doseId], + }); + expect(result.rows[0].count).toBe(0); + }); + }); + + // --------------------------------------------------------------------------- + // Real Share Routes Tests + // --------------------------------------------------------------------------- + + describe("Real /share routes", () => { + it("should create share token using real route", async () => { + // Create medication with takenBy + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + + const response = await app.inject({ + method: "POST", + url: "/share", + payload: { takenBy: "Daniel", scheduleDays: 30 }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.token).toBeDefined(); + expect(data.shareUrl).toContain("/share/"); + }); + + it("should get shared schedule using real route", async () => { + // Create medication + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + + // Create share token + const token = "test_share_token_123"; + await createShareToken(testClient, userId, "Daniel", token); + + const response = await app.inject({ + method: "GET", + url: `/share/${token}`, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.takenBy).toBe("Daniel"); + expect(data.medications).toHaveLength(1); + expect(data.medications[0].name).toBe("Aspirin"); + }); + + it("should mark dose via share link using real route", async () => { + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + + const token = "test_share_token_456"; + await createShareToken(testClient, userId, "Daniel", token); + + const doseId = "1-0-1735344000000"; + const response = await app.inject({ + method: "POST", + url: `/share/${token}/doses`, + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify markedBy is set + const result = await testClient.execute({ + sql: `SELECT marked_by FROM dose_tracking WHERE dose_id = ?`, + args: [doseId], + }); + expect(result.rows[0].marked_by).toBe("Daniel"); + }); + + it("should return 404 for invalid share token", async () => { + const response = await app.inject({ + method: "GET", + url: "/share/invalid_token", + }); + + expect(response.statusCode).toBe(404); + }); + }); + + // --------------------------------------------------------------------------- + // Real Medication Routes Tests + // --------------------------------------------------------------------------- + + describe("Real /medications routes", () => { + const validMedication = { + name: "Aspirin", + genericName: "Acetylsalicylic acid", + takenBy: ["Daniel"], + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + pillWeightMg: 500, + expiryDate: "2026-12-31", + notes: "Take with food", + intakeRemindersEnabled: true, + blisters: [ + { usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, + ], + }; + + it("should create medication using real route", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications", + payload: validMedication, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.id).toBeDefined(); + expect(data.name).toBe("Aspirin"); + expect(data.genericName).toBe("Acetylsalicylic acid"); + expect(data.takenBy).toEqual(["Daniel"]); + expect(data.packCount).toBe(2); + expect(data.blistersPerPack).toBe(3); + expect(data.pillsPerBlister).toBe(10); + expect(data.looseTablets).toBe(5); + expect(data.pillWeightMg).toBe(500); + expect(data.blisters).toHaveLength(1); + }); + + it("should list medications using real route", async () => { + // Create medication first + await app.inject({ + method: "POST", + url: "/medications", + payload: validMedication, + }); + + const response = await app.inject({ + method: "GET", + url: "/medications", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data).toHaveLength(1); + expect(data[0].name).toBe("Aspirin"); + }); + + it("should update medication using real route", async () => { + // Create medication first + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: validMedication, + }); + const medId = createResponse.json().id; + + // Update it + const updatedMed = { + ...validMedication, + name: "Aspirin Extra", + looseTablets: 10, + }; + + const response = await app.inject({ + method: "PUT", + url: `/medications/${medId}`, + payload: updatedMed, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.name).toBe("Aspirin Extra"); + expect(data.looseTablets).toBe(10); + }); + + it("should delete medication using real route", async () => { + // Create medication first + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: validMedication, + }); + const medId = createResponse.json().id; + + // Delete it + const response = await app.inject({ + method: "DELETE", + url: `/medications/${medId}`, + }); + + expect(response.statusCode).toBe(204); + + // Verify deleted + const listResponse = await app.inject({ + method: "GET", + url: "/medications", + }); + expect(listResponse.json()).toHaveLength(0); + }); + + it("should return 400 for invalid medication data", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications", + payload: { name: "" }, // Invalid - empty name and no blisters + }); + + expect(response.statusCode).toBe(400); + }); + + it("should return 404 for non-existent medication", async () => { + const response = await app.inject({ + method: "PUT", + url: "/medications/99999", + payload: validMedication, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should create medication with multiple intake schedules", async () => { + const multiBlisterMed = { + ...validMedication, + blisters: [ + { usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, + { usage: 0.5, every: 1, start: "2025-01-01T20:00:00.000Z" }, + ], + }; + + const response = await app.inject({ + method: "POST", + url: "/medications", + payload: multiBlisterMed, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.blisters).toHaveLength(2); + expect(data.blisters[0].usage).toBe(1); + expect(data.blisters[1].usage).toBe(0.5); + }); + }); + + // --------------------------------------------------------------------------- + // Real Settings Routes Tests + // --------------------------------------------------------------------------- + + describe("Real /settings routes", () => { + it("should get default settings using real route", async () => { + const response = await app.inject({ + method: "GET", + url: "/settings", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + // Check default values + expect(data.emailEnabled).toBe(false); + expect(data.lowStockDays).toBe(30); + expect(data.normalStockDays).toBe(90); + expect(data.highStockDays).toBe(180); + expect(data.language).toBe("en"); + expect(data.stockCalculationMode).toBe("automatic"); + }); + + it("should update settings using real route", async () => { + const newSettings = { + emailEnabled: true, + notificationEmail: "test@example.com", + reminderDaysBefore: 14, + repeatDailyReminders: false, + lowStockDays: 14, + normalStockDays: 60, + highStockDays: 120, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + language: "de", + stockCalculationMode: "manual", + }; + + const response = await app.inject({ + method: "PUT", + url: "/settings", + payload: newSettings, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify settings were saved + const getResponse = await app.inject({ + method: "GET", + url: "/settings", + }); + + const data = getResponse.json(); + expect(data.emailEnabled).toBe(true); + expect(data.notificationEmail).toBe("test@example.com"); + expect(data.lowStockDays).toBe(14); + expect(data.language).toBe("de"); + expect(data.stockCalculationMode).toBe("manual"); + }); + + it("should update existing settings using real route", async () => { + // First update + await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: true, + notificationEmail: "first@example.com", + reminderDaysBefore: 7, + repeatDailyReminders: false, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + language: "en", + stockCalculationMode: "automatic", + }, + }); + + // Second update + const response = await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: false, + notificationEmail: "second@example.com", + reminderDaysBefore: 14, + repeatDailyReminders: true, + lowStockDays: 20, + normalStockDays: 60, + highStockDays: 120, + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://localhost/alerts", + emailStockReminders: false, + emailIntakeReminders: false, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + language: "de", + stockCalculationMode: "manual", + }, + }); + + expect(response.statusCode).toBe(200); + + // Verify updated + const getResponse = await app.inject({ + method: "GET", + url: "/settings", + }); + + const data = getResponse.json(); + expect(data.emailEnabled).toBe(false); + expect(data.notificationEmail).toBe("second@example.com"); + expect(data.shoutrrrEnabled).toBe(true); + expect(data.shoutrrrUrl).toBe("ntfy://localhost/alerts"); + expect(data.stockCalculationMode).toBe("manual"); + }); + + it("should disable repeatDailyReminders when no stock reminders configured", async () => { + const response = await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: false, // No email + notificationEmail: "", + reminderDaysBefore: 7, + repeatDailyReminders: true, // User tries to enable + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + shoutrrrEnabled: false, // No shoutrrr + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + language: "en", + stockCalculationMode: "automatic", + }, + }); + + expect(response.statusCode).toBe(200); + + // Verify repeatDailyReminders is false + const getResponse = await app.inject({ + method: "GET", + url: "/settings", + }); + + const data = getResponse.json(); + expect(data.repeatDailyReminders).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Health Route Tests + // --------------------------------------------------------------------------- + + describe("Real /health route", () => { + it("should return health status", async () => { + const response = await app.inject({ + method: "GET", + url: "/health", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ status: "ok" }); + }); + }); + + // --------------------------------------------------------------------------- + // Additional Share Routes Tests (edge cases) + // --------------------------------------------------------------------------- + + describe("Real /share routes - edge cases", () => { + it("should get list of people with medications", async () => { + // Create medications for different people + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + await createMedication(testClient, userId, "Ibuprofen", ["Anna"]); + await createMedication(testClient, userId, "Paracetamol", ["Daniel", "Anna"]); + + const response = await app.inject({ + method: "GET", + url: "/share/people", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.people).toContain("Daniel"); + expect(data.people).toContain("Anna"); + expect(data.people).toHaveLength(2); + }); + + it("should return error when creating share for person with no meds", async () => { + // Create medication for a different person + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + + const response = await app.inject({ + method: "POST", + url: "/share", + payload: { takenBy: "Unknown", scheduleDays: 30 }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().code).toBe("NO_MEDICATIONS"); + }); + + it("should unmark dose via share link", async () => { + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + + const token = "test_delete_dose_token"; + await createShareToken(testClient, userId, "Daniel", token); + + // First mark the dose + const doseId = "1-0-1735344000000"; + await testClient.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, + args: [userId, doseId, "Daniel"], + }); + + // Now unmark via share link + const response = await app.inject({ + method: "DELETE", + url: `/share/${token}/doses/${encodeURIComponent(doseId)}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify deleted + const result = await testClient.execute({ + sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`, + args: [doseId], + }); + expect(result.rows[0].count).toBe(0); + }); + + it("should return 410 for expired share token", async () => { + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + + // Create expired token + const token = "expired_token_123"; + const expiredAt = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + await testClient.execute({ + sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, 30, ?)`, + args: [userId, token, "Daniel", expiredAt], + }); + + const response = await app.inject({ + method: "GET", + url: `/share/${token}`, + }); + + expect(response.statusCode).toBe(410); + expect(response.json().code).toBe("EXPIRED"); + }); + + it("should return already marked message for duplicate dose", async () => { + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + + const token = "test_duplicate_token"; + await createShareToken(testClient, userId, "Daniel", token); + + const doseId = "1-0-1735344000000"; + + // Mark the dose first time + await app.inject({ + method: "POST", + url: `/share/${token}/doses`, + payload: { doseId }, + }); + + // Try to mark again + const response = await app.inject({ + method: "POST", + url: `/share/${token}/doses`, + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().message).toBe("Already marked"); + }); + }); + + // --------------------------------------------------------------------------- + // Additional Dose Routes Tests (edge cases) + // --------------------------------------------------------------------------- + + describe("Real /doses/taken routes - edge cases", () => { + it("should return already marked message for duplicate dose", async () => { + const doseId = "1-0-1735344000000"; + + // Mark first time + await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + // Mark second time + const response = await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().message).toBe("Already marked"); + }); + + it("should handle doses with person name in doseId", async () => { + const doseId = "1-0-1735344000000-Daniel"; + + const response = await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify in database + const result = await testClient.execute({ + sql: `SELECT dose_id FROM dose_tracking WHERE dose_id = ?`, + args: [doseId], + }); + expect(result.rows.length).toBe(1); + }); + }); + + // --------------------------------------------------------------------------- + // Additional Medication Routes Tests (edge cases) + // --------------------------------------------------------------------------- + + describe("Real /medications routes - edge cases", () => { + const validMedication = { + name: "Aspirin", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }; + + it("should return 404 when deleting non-existent medication", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/medications/99999", + }); + + expect(response.statusCode).toBe(404); + }); + + it("should handle medication with all optional fields", async () => { + const fullMedication = { + name: "Complete Med", + genericName: "Generic Complete", + takenBy: ["Person1", "Person2"], + packCount: 5, + blistersPerPack: 4, + pillsPerBlister: 20, + looseTablets: 10, + pillWeightMg: 250, + expiryDate: "2026-06-30", + notes: "Some important notes about this medication", + intakeRemindersEnabled: true, + blisters: [ + { usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }, + { usage: 1, every: 1, start: "2025-01-01T20:00:00.000Z" }, + ], + }; + + const response = await app.inject({ + method: "POST", + url: "/medications", + payload: fullMedication, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.genericName).toBe("Generic Complete"); + expect(data.takenBy).toEqual(["Person1", "Person2"]); + expect(data.packCount).toBe(5); + expect(data.blistersPerPack).toBe(4); + expect(data.pillsPerBlister).toBe(20); + expect(data.looseTablets).toBe(10); + expect(data.pillWeightMg).toBe(250); + expect(data.expiryDate).toBe("2026-06-30"); + expect(data.notes).toBe("Some important notes about this medication"); + expect(data.intakeRemindersEnabled).toBe(true); + expect(data.blisters).toHaveLength(2); + }); + + it("should update medication with partial fields", async () => { + // Create medication first + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { name: "Original Med", blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }] }, + }); + const medId = createResponse.json().id; + + // Update with partial fields + const response = await app.inject({ + method: "PUT", + url: `/medications/${medId}`, + payload: { + name: "Updated Med", + genericName: "New Generic", + notes: "Updated notes", + blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().name).toBe("Updated Med"); + expect(response.json().genericName).toBe("New Generic"); + expect(response.json().notes).toBe("Updated notes"); + }); + + it("should handle string takenBy conversion", async () => { + // Test with takenBy as array (expected format) + const response = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Array TakenBy Med", + takenBy: ["SinglePerson"], + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().takenBy).toEqual(["SinglePerson"]); + }); + }); + + // --------------------------------------------------------------------------- + // Test Email/Shoutrrr Validation (settings.ts - uncovered paths) + // --------------------------------------------------------------------------- + + describe("Real /settings test routes", () => { + it("should reject test-email when SMTP is not configured", async () => { + const response = await app.inject({ + method: "POST", + url: "/settings/test-email", + payload: { email: "test@example.com" }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("SMTP not configured"); + }); + + it("should reject test-shoutrrr without URL", async () => { + const response = await app.inject({ + method: "POST", + url: "/settings/test-shoutrrr", + payload: { url: "" }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("Notification URL is required"); + }); + + it("should reject test-shoutrrr with unsupported URL format", async () => { + const response = await app.inject({ + method: "POST", + url: "/settings/test-shoutrrr", + payload: { url: "ftp://invalid.com/topic" }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json().error).toContain("Unsupported URL format"); + }); + }); + + // --------------------------------------------------------------------------- + // Additional Doses Routes Tests + // --------------------------------------------------------------------------- + + describe("Real /doses routes - more coverage", () => { + it("should return 400 when doseId is missing", async () => { + const response = await app.inject({ + method: "POST", + url: "/doses/taken", + payload: {}, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should handle dose marking and get taken doses", async () => { + const doseId = "99-0-1735344000099"; + + // Mark the dose + const markResponse = await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + expect(markResponse.statusCode).toBe(200); + expect(markResponse.json()).toEqual({ success: true }); + + // The GET returns doses for current user (anonymous in test) + // Each beforeEach clears data, so we just verify POST works correctly + }); + + it("should handle cleaning old doses for future date range", async () => { + // Create a medication first + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "CleanTest Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createResponse.json().id; + + // Mark some doses + await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId: `${medId}-0-1735344000000` }, + }); + + // Update medication with new start date + await app.inject({ + method: "PUT", + url: `/medications/${medId}`, + payload: { + name: "CleanTest Med", + blisters: [{ usage: 1, every: 1, start: "2025-06-01T08:00:00.000Z" }], // Future start + }, + }); + + // The dose tracking for the old period should be cleaned up + // This is handled by the medications route internally + }); + }); + + // --------------------------------------------------------------------------- + // Health Check Tests + // --------------------------------------------------------------------------- + + describe("Real /health routes", () => { + it("should return health status", async () => { + const response = await app.inject({ + method: "GET", + url: "/health", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ status: "ok" }); + }); + }); + + // --------------------------------------------------------------------------- + // Medication Delete Cascade Tests + // --------------------------------------------------------------------------- + + describe("Medication deletion with dose tracking", () => { + it("should handle medication deletion that has tracked doses", async () => { + // Create medication + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Test Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createResponse.json().id; + + // Mark a dose for this medication + await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId: `${medId}-0-1735344000000` }, + }); + + // Delete medication - should succeed even with tracked doses + const deleteResponse = await app.inject({ + method: "DELETE", + url: `/medications/${medId}`, + }); + + expect(deleteResponse.statusCode).toBe(204); + }); + }); + + // --------------------------------------------------------------------------- + // Settings Edge Cases + // --------------------------------------------------------------------------- + + describe("Settings edge cases", () => { + it("should handle settings with all reminder options enabled", async () => { + const response = await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: true, + notificationEmail: "test@example.com", + reminderDaysBefore: 7, + repeatDailyReminders: true, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://localhost/test", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + language: "en", + stockCalculationMode: "automatic", + }, + }); + + expect(response.statusCode).toBe(200); + + // Verify repeatDailyReminders is preserved when notifications are enabled + const getResponse = await app.inject({ + method: "GET", + url: "/settings", + }); + + const data = getResponse.json(); + expect(data.repeatDailyReminders).toBe(true); + expect(data.emailEnabled).toBe(true); + expect(data.shoutrrrEnabled).toBe(true); + }); + + it("should handle expiry warning days setting", async () => { + const response = await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: false, + notificationEmail: "", + reminderDaysBefore: 14, + repeatDailyReminders: false, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + expiryWarningDays: 60, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + language: "en", + stockCalculationMode: "automatic", + }, + }); + + expect(response.statusCode).toBe(200); + }); + }); + + // --------------------------------------------------------------------------- + // Share Token Management + // --------------------------------------------------------------------------- + + describe("Share token management", () => { + it("should create share token with custom scheduleDays", async () => { + await createMedication(testClient, userId, "Med1", ["Daniel"]); + + const response = await app.inject({ + method: "POST", + url: "/share", + payload: { + takenBy: "Daniel", + scheduleDays: 90, + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.token).toBeDefined(); + expect(data.expiresAt).toBeDefined(); + }); + + it("should return validation error for invalid scheduleDays", async () => { + await createMedication(testClient, userId, "Med1", ["Daniel"]); + + const response = await app.inject({ + method: "POST", + url: "/share", + payload: { + takenBy: "Daniel", + scheduleDays: 500, // Too high, max is 365 + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should return validation error for missing takenBy", async () => { + const response = await app.inject({ + method: "POST", + url: "/share", + payload: { + scheduleDays: 30, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().code).toBe("VALIDATION_ERROR"); + }); + + it("should get people list with multiple persons", async () => { + await createMedication(testClient, userId, "Med1", ["Daniel"]); + await createMedication(testClient, userId, "Med2", ["Anna"]); + await createMedication(testClient, userId, "Med3", ["Daniel", "Anna"]); + + const response = await app.inject({ + method: "GET", + url: "/share/people", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.people).toContain("Daniel"); + expect(data.people).toContain("Anna"); + }); + }); + + // --------------------------------------------------------------------------- + // Dose validation tests + // --------------------------------------------------------------------------- + + describe("Dose validation", () => { + it("should reject invalid doseId format in POST", async () => { + const response = await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId: null }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should handle empty string doseId", async () => { + const response = await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId: "" }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + // --------------------------------------------------------------------------- + // Medication validation edge cases + // --------------------------------------------------------------------------- + + describe("Medication validation edge cases", () => { + it("should reject medication without blisters", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications", + payload: { name: "No Blisters Med" }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject medication with empty blisters array", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications", + payload: { name: "Empty Blisters Med", blisters: [] }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject medication with invalid blister data", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Invalid Blister Med", + blisters: [{ usage: -1, every: 0, start: "invalid-date" }], + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should handle medication with minimal valid data", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Minimal Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.name).toBe("Minimal Med"); + // Check defaults + expect(data.packCount).toBe(1); + expect(data.blistersPerPack).toBe(1); + expect(data.pillsPerBlister).toBe(1); + expect(data.looseTablets).toBe(0); + expect(data.takenBy).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // Share Token Dose Routes (via share link) + // --------------------------------------------------------------------------- + + describe("Share token dose routes", () => { + it("should get taken doses via share link", async () => { + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + const token = "get-doses-token"; + await createShareToken(testClient, userId, "Daniel", token); + + // Insert a dose directly + await testClient.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, + args: [userId, "1-0-1735344000000", "Daniel"], + }); + + const response = await app.inject({ + method: "GET", + url: `/share/${token}/doses`, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.doses).toHaveLength(1); + expect(data.doses[0].doseId).toBe("1-0-1735344000000"); + expect(data.doses[0].markedBy).toBe("Daniel"); + }); + + it("should return 404 for get doses with invalid share token", async () => { + const response = await app.inject({ + method: "GET", + url: "/share/invalid-token/doses", + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return 404 for mark dose with invalid share token", async () => { + const response = await app.inject({ + method: "POST", + url: "/share/invalid-token/doses", + payload: { doseId: "1-0-1735344000000" }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return 404 for unmark dose with invalid share token", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/share/invalid-token/doses/1-0-1735344000000", + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return validation error for empty doseId in share route", async () => { + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + const token = "validation-test-token"; + await createShareToken(testClient, userId, "Daniel", token); + + const response = await app.inject({ + method: "POST", + url: `/share/${token}/doses`, + payload: { doseId: "" }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + // --------------------------------------------------------------------------- + // Medication Image Routes + // --------------------------------------------------------------------------- + + describe("Medication image routes", () => { + it("should return 400 for invalid medication id in image upload", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications/invalid/image", + }); + + expect(response.statusCode).toBe(400); + }); + + it("should return 404 for image upload to non-existent medication", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications/99999/image", + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return error for image upload without file", async () => { + // Create medication first + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Image Test Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createResponse.json().id; + + const response = await app.inject({ + method: "POST", + url: `/medications/${medId}/image`, + }); + + // 406 Not Acceptable when no multipart content + expect([400, 406]).toContain(response.statusCode); + }); + + it("should return 400 for invalid medication id in image delete", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/medications/invalid/image", + }); + + expect(response.statusCode).toBe(400); + }); + + it("should return 404 for image delete on non-existent medication", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/medications/99999/image", + }); + + expect(response.statusCode).toBe(404); + }); + + it("should handle image delete when no image exists", async () => { + // Create medication first (without image) + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "No Image Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createResponse.json().id; + + const response = await app.inject({ + method: "DELETE", + url: `/medications/${medId}/image`, + }); + + // Returns 204 No Content + expect(response.statusCode).toBe(204); + }); + }); +}); diff --git a/backend/src/test/env.test.ts b/backend/src/test/env.test.ts new file mode 100644 index 0000000..3b6f223 --- /dev/null +++ b/backend/src/test/env.test.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { z } from "zod"; + +// Mock process.exit to prevent tests from exiting +const mockExit = vi.fn(); +vi.spyOn(process, "exit").mockImplementation(mockExit as any); + +// Re-create the schema from env.ts for testing +const EnvSchema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("production"), + PORT: z.string().transform((v) => parseInt(v, 10)).default("3000"), + CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"), + LOG_LEVEL: z.string().default("info"), + AUTH_ENABLED: z.string().transform((v) => v === "true").default("false"), + REGISTRATION_ENABLED: z.string().transform((v) => v === "true").default("false"), + JWT_SECRET: z.string().min(10).optional(), + REFRESH_SECRET: z.string().min(10).optional(), + COOKIE_SECRET: z.string().min(10).optional(), + ACCESS_TOKEN_TTL_MINUTES: z.string().transform((v) => parseInt(v, 10)).default("15"), + REFRESH_TOKEN_TTL_DAYS: z.string().transform((v) => parseInt(v, 10)).default("7"), + OIDC_ENABLED: z.string().transform((v) => v === "true").default("false"), + OIDC_ISSUER_URL: z.string().url().optional(), + OIDC_CLIENT_ID: z.string().optional(), + OIDC_CLIENT_SECRET: z.string().optional(), + OIDC_REDIRECT_URI: z.string().url().optional(), + OIDC_SCOPES: z.string().default("openid profile email"), + OIDC_AUTO_CREATE_USERS: z.string().transform((v) => v === "true").default("true"), + OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), + OIDC_PROVIDER_NAME: z.string().default("SSO"), +}); + +// Validation functions from env.ts +function validateAuthSecrets(parsed: z.infer): string[] { + const missing: string[] = []; + if (parsed.AUTH_ENABLED) { + if (!parsed.JWT_SECRET) missing.push("JWT_SECRET"); + if (!parsed.REFRESH_SECRET) missing.push("REFRESH_SECRET"); + if (!parsed.COOKIE_SECRET) missing.push("COOKIE_SECRET"); + } + return missing; +} + +function validateOidcConfig(parsed: z.infer): string[] { + const missing: string[] = []; + if (parsed.OIDC_ENABLED) { + if (!parsed.OIDC_ISSUER_URL) missing.push("OIDC_ISSUER_URL"); + if (!parsed.OIDC_CLIENT_ID) missing.push("OIDC_CLIENT_ID"); + if (!parsed.OIDC_CLIENT_SECRET) missing.push("OIDC_CLIENT_SECRET"); + if (!parsed.OIDC_REDIRECT_URI) missing.push("OIDC_REDIRECT_URI"); + } + return missing; +} + +describe("EnvSchema", () => { + describe("default values", () => { + it("should use default values when env vars are empty", () => { + const result = EnvSchema.parse({}); + + expect(result.NODE_ENV).toBe("production"); + expect(result.PORT).toBe(3000); + expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173"); + expect(result.LOG_LEVEL).toBe("info"); + expect(result.AUTH_ENABLED).toBe(false); + expect(result.REGISTRATION_ENABLED).toBe(false); + expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15); + expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(7); + expect(result.OIDC_ENABLED).toBe(false); + expect(result.OIDC_SCOPES).toBe("openid profile email"); + expect(result.OIDC_AUTO_CREATE_USERS).toBe(true); + expect(result.OIDC_USERNAME_CLAIM).toBe("preferred_username"); + expect(result.OIDC_PROVIDER_NAME).toBe("SSO"); + }); + }); + + describe("NODE_ENV validation", () => { + it("should accept development", () => { + const result = EnvSchema.parse({ NODE_ENV: "development" }); + expect(result.NODE_ENV).toBe("development"); + }); + + it("should accept production", () => { + const result = EnvSchema.parse({ NODE_ENV: "production" }); + expect(result.NODE_ENV).toBe("production"); + }); + + it("should accept test", () => { + const result = EnvSchema.parse({ NODE_ENV: "test" }); + expect(result.NODE_ENV).toBe("test"); + }); + + it("should reject invalid NODE_ENV values", () => { + expect(() => EnvSchema.parse({ NODE_ENV: "staging" })).toThrow(); + expect(() => EnvSchema.parse({ NODE_ENV: "invalid" })).toThrow(); + }); + }); + + describe("PORT transformation", () => { + it("should transform string PORT to number", () => { + const result = EnvSchema.parse({ PORT: "8080" }); + expect(result.PORT).toBe(8080); + }); + + it("should use default port when not provided", () => { + const result = EnvSchema.parse({}); + expect(result.PORT).toBe(3000); + }); + }); + + describe("boolean transformations", () => { + it("should transform AUTH_ENABLED=true to boolean true", () => { + const result = EnvSchema.parse({ AUTH_ENABLED: "true" }); + expect(result.AUTH_ENABLED).toBe(true); + }); + + it("should transform AUTH_ENABLED=false to boolean false", () => { + const result = EnvSchema.parse({ AUTH_ENABLED: "false" }); + expect(result.AUTH_ENABLED).toBe(false); + }); + + it("should treat non-true string as false", () => { + const result = EnvSchema.parse({ AUTH_ENABLED: "yes" }); + expect(result.AUTH_ENABLED).toBe(false); + }); + + it("should transform REGISTRATION_ENABLED correctly", () => { + expect(EnvSchema.parse({ REGISTRATION_ENABLED: "true" }).REGISTRATION_ENABLED).toBe(true); + expect(EnvSchema.parse({ REGISTRATION_ENABLED: "false" }).REGISTRATION_ENABLED).toBe(false); + }); + + it("should transform OIDC_ENABLED correctly", () => { + expect(EnvSchema.parse({ OIDC_ENABLED: "true" }).OIDC_ENABLED).toBe(true); + expect(EnvSchema.parse({ OIDC_ENABLED: "false" }).OIDC_ENABLED).toBe(false); + }); + + it("should transform OIDC_AUTO_CREATE_USERS correctly", () => { + expect(EnvSchema.parse({ OIDC_AUTO_CREATE_USERS: "true" }).OIDC_AUTO_CREATE_USERS).toBe(true); + expect(EnvSchema.parse({ OIDC_AUTO_CREATE_USERS: "false" }).OIDC_AUTO_CREATE_USERS).toBe(false); + }); + }); + + describe("JWT secret validation", () => { + it("should accept JWT_SECRET with 10+ characters", () => { + const result = EnvSchema.parse({ JWT_SECRET: "1234567890" }); + expect(result.JWT_SECRET).toBe("1234567890"); + }); + + it("should reject JWT_SECRET with less than 10 characters", () => { + expect(() => EnvSchema.parse({ JWT_SECRET: "123456789" })).toThrow(); + }); + + it("should allow optional JWT_SECRET", () => { + const result = EnvSchema.parse({}); + expect(result.JWT_SECRET).toBeUndefined(); + }); + }); + + describe("TTL transformations", () => { + it("should transform ACCESS_TOKEN_TTL_MINUTES to number", () => { + const result = EnvSchema.parse({ ACCESS_TOKEN_TTL_MINUTES: "30" }); + expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(30); + }); + + it("should transform REFRESH_TOKEN_TTL_DAYS to number", () => { + const result = EnvSchema.parse({ REFRESH_TOKEN_TTL_DAYS: "14" }); + expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(14); + }); + }); + + describe("OIDC URL validation", () => { + it("should accept valid OIDC_ISSUER_URL", () => { + const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" }); + expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com"); + }); + + it("should reject invalid OIDC_ISSUER_URL", () => { + expect(() => EnvSchema.parse({ OIDC_ISSUER_URL: "not-a-url" })).toThrow(); + }); + + it("should accept valid OIDC_REDIRECT_URI", () => { + const result = EnvSchema.parse({ OIDC_REDIRECT_URI: "https://app.example.com/callback" }); + expect(result.OIDC_REDIRECT_URI).toBe("https://app.example.com/callback"); + }); + + it("should reject invalid OIDC_REDIRECT_URI", () => { + expect(() => EnvSchema.parse({ OIDC_REDIRECT_URI: "invalid" })).toThrow(); + }); + }); + + describe("CORS_ORIGINS parsing", () => { + it("should accept comma-separated origins", () => { + const result = EnvSchema.parse({ CORS_ORIGINS: "http://a.com,http://b.com" }); + expect(result.CORS_ORIGINS).toBe("http://a.com,http://b.com"); + }); + + it("should accept single origin", () => { + const result = EnvSchema.parse({ CORS_ORIGINS: "http://localhost:3000" }); + expect(result.CORS_ORIGINS).toBe("http://localhost:3000"); + }); + }); +}); + +describe("Auth validation", () => { + it("should require secrets when AUTH_ENABLED=true", () => { + const parsed = EnvSchema.parse({ AUTH_ENABLED: "true" }); + const missing = validateAuthSecrets(parsed); + expect(missing).toContain("JWT_SECRET"); + expect(missing).toContain("REFRESH_SECRET"); + expect(missing).toContain("COOKIE_SECRET"); + }); + + it("should not require secrets when AUTH_ENABLED=false", () => { + const parsed = EnvSchema.parse({ AUTH_ENABLED: "false" }); + const missing = validateAuthSecrets(parsed); + expect(missing).toHaveLength(0); + }); + + it("should pass validation with all secrets provided", () => { + const parsed = EnvSchema.parse({ + AUTH_ENABLED: "true", + JWT_SECRET: "super-secret-jwt-key-12345", + REFRESH_SECRET: "super-secret-refresh-key-12345", + COOKIE_SECRET: "super-secret-cookie-key-12345", + }); + const missing = validateAuthSecrets(parsed); + expect(missing).toHaveLength(0); + }); + + it("should identify which specific secrets are missing", () => { + const parsed = EnvSchema.parse({ + AUTH_ENABLED: "true", + JWT_SECRET: "super-secret-jwt-key-12345", + // REFRESH_SECRET missing + COOKIE_SECRET: "super-secret-cookie-key-12345", + }); + const missing = validateAuthSecrets(parsed); + expect(missing).toHaveLength(1); + expect(missing).toContain("REFRESH_SECRET"); + }); +}); + +describe("OIDC validation", () => { + it("should require all OIDC settings when OIDC_ENABLED=true", () => { + const parsed = EnvSchema.parse({ OIDC_ENABLED: "true" }); + const missing = validateOidcConfig(parsed); + expect(missing).toContain("OIDC_ISSUER_URL"); + expect(missing).toContain("OIDC_CLIENT_ID"); + expect(missing).toContain("OIDC_CLIENT_SECRET"); + expect(missing).toContain("OIDC_REDIRECT_URI"); + }); + + it("should not require OIDC settings when OIDC_ENABLED=false", () => { + const parsed = EnvSchema.parse({ OIDC_ENABLED: "false" }); + const missing = validateOidcConfig(parsed); + expect(missing).toHaveLength(0); + }); + + it("should pass validation with all OIDC settings provided", () => { + const parsed = EnvSchema.parse({ + OIDC_ENABLED: "true", + OIDC_ISSUER_URL: "https://auth.example.com", + OIDC_CLIENT_ID: "my-client-id", + OIDC_CLIENT_SECRET: "my-client-secret", + OIDC_REDIRECT_URI: "https://app.example.com/callback", + }); + const missing = validateOidcConfig(parsed); + expect(missing).toHaveLength(0); + }); + + it("should identify which specific OIDC settings are missing", () => { + const parsed = EnvSchema.parse({ + OIDC_ENABLED: "true", + OIDC_ISSUER_URL: "https://auth.example.com", + OIDC_CLIENT_ID: "my-client-id", + // OIDC_CLIENT_SECRET missing + // OIDC_REDIRECT_URI missing + }); + const missing = validateOidcConfig(parsed); + expect(missing).toHaveLength(2); + expect(missing).toContain("OIDC_CLIENT_SECRET"); + expect(missing).toContain("OIDC_REDIRECT_URI"); + }); +}); + +describe("Full configuration scenarios", () => { + it("should parse minimal config (auth disabled)", () => { + const result = EnvSchema.parse({}); + expect(result.AUTH_ENABLED).toBe(false); + expect(result.OIDC_ENABLED).toBe(false); + }); + + it("should parse full production config with auth enabled", () => { + const env = { + NODE_ENV: "production", + PORT: "8080", + CORS_ORIGINS: "https://myapp.com", + LOG_LEVEL: "warn", + AUTH_ENABLED: "true", + REGISTRATION_ENABLED: "false", + JWT_SECRET: "production-jwt-secret-key-12345", + REFRESH_SECRET: "production-refresh-secret-key-12345", + COOKIE_SECRET: "production-cookie-secret-key-12345", + ACCESS_TOKEN_TTL_MINUTES: "30", + REFRESH_TOKEN_TTL_DAYS: "14", + }; + + const result = EnvSchema.parse(env); + + expect(result.NODE_ENV).toBe("production"); + expect(result.PORT).toBe(8080); + expect(result.CORS_ORIGINS).toBe("https://myapp.com"); + expect(result.LOG_LEVEL).toBe("warn"); + expect(result.AUTH_ENABLED).toBe(true); + expect(result.REGISTRATION_ENABLED).toBe(false); + expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(30); + expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(14); + + // Should pass auth validation + const missing = validateAuthSecrets(result); + expect(missing).toHaveLength(0); + }); + + it("should parse config with OIDC SSO enabled", () => { + const env = { + AUTH_ENABLED: "true", + JWT_SECRET: "production-jwt-secret-key-12345", + REFRESH_SECRET: "production-refresh-secret-key-12345", + COOKIE_SECRET: "production-cookie-secret-key-12345", + OIDC_ENABLED: "true", + OIDC_ISSUER_URL: "https://authelia.example.com", + OIDC_CLIENT_ID: "medassist", + OIDC_CLIENT_SECRET: "super-secret-oidc-secret", + OIDC_REDIRECT_URI: "https://medassist.example.com/api/auth/oidc/callback", + OIDC_SCOPES: "openid profile email groups", + OIDC_USERNAME_CLAIM: "email", + OIDC_PROVIDER_NAME: "Authelia", + }; + + const result = EnvSchema.parse(env); + + expect(result.OIDC_ENABLED).toBe(true); + expect(result.OIDC_ISSUER_URL).toBe("https://authelia.example.com"); + expect(result.OIDC_SCOPES).toBe("openid profile email groups"); + expect(result.OIDC_USERNAME_CLAIM).toBe("email"); + expect(result.OIDC_PROVIDER_NAME).toBe("Authelia"); + + // Should pass both validations + expect(validateAuthSecrets(result)).toHaveLength(0); + expect(validateOidcConfig(result)).toHaveLength(0); + }); + + it("should parse development config", () => { + const env = { + NODE_ENV: "development", + PORT: "3000", + LOG_LEVEL: "debug", + AUTH_ENABLED: "false", + }; + + const result = EnvSchema.parse(env); + + expect(result.NODE_ENV).toBe("development"); + expect(result.LOG_LEVEL).toBe("debug"); + expect(result.AUTH_ENABLED).toBe(false); + }); +}); diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts new file mode 100644 index 0000000..d564f70 --- /dev/null +++ b/backend/src/test/integration.test.ts @@ -0,0 +1,932 @@ +/** + * Integration Tests - Testing interactions between multiple routes/features + * These tests verify critical app behavior that spans multiple components. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import Fastify, { FastifyInstance } from "fastify"; +import cookie from "@fastify/cookie"; +import jwt from "@fastify/jwt"; +import sensible from "@fastify/sensible"; +import fastifyMultipart from "@fastify/multipart"; +import { createClient, Client } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; + +// Use vi.hoisted to create the db BEFORE mocks are set up +const { testClient, testDb } = vi.hoisted(() => { + const { createClient } = require("@libsql/client"); + const { drizzle } = require("drizzle-orm/libsql"); + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + return { testClient: client, testDb: db }; +}); + +// Mock modules +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); + +vi.mock("../plugins/env.js", () => ({ + env: { + AUTH_ENABLED: false, + NODE_ENV: "test", + LOG_LEVEL: "silent", + PORT: 3000, + CORS_ORIGINS: "*", + JWT_SECRET: "test-secret", + REFRESH_SECRET: "test-refresh-secret", + COOKIE_SECRET: "test-cookie-secret", + ACCESS_TOKEN_TTL_MINUTES: 15, + REFRESH_TOKEN_TTL_DAYS: 7, + }, +})); + +vi.mock("../plugins/auth.js", () => ({ + requireAuth: async () => {}, + getAnonymousUserId: () => 999999999, +})); + +// Import routes +const { doseRoutes } = await import("../routes/doses.js"); +const { shareRoutes } = await import("../routes/share.js"); +const { medicationRoutes } = await import("../routes/medications.js"); +const { settingsRoutes } = await import("../routes/settings.js"); + +// ============================================================================= +// Schema & Setup +// ============================================================================= + +async function createSchema(client: Client) { + const tables = [ + `CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + password_hash text, + avatar_url text, + auth_provider text NOT NULL DEFAULT 'local', + oidc_subject text, + is_active integer NOT NULL DEFAULT 1, + last_login_at integer, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + updated_at integer NOT NULL DEFAULT (strftime('%s','now')) + )`, + `CREATE TABLE IF NOT EXISTS medications ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + name text NOT NULL, + generic_name text, + taken_by_json text NOT NULL DEFAULT '[]', + pack_count integer NOT NULL DEFAULT 1, + blisters_per_pack integer NOT NULL DEFAULT 1, + pills_per_blister integer NOT NULL DEFAULT 1, + loose_tablets integer NOT NULL DEFAULT 0, + pill_weight_mg integer, + usage_json text NOT NULL DEFAULT '[]', + every_json text NOT NULL DEFAULT '[]', + start_json text NOT NULL DEFAULT '[]', + image_url text, + expiry_date text, + notes text, + intake_reminders_enabled integer NOT NULL DEFAULT 0, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS user_settings ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL UNIQUE, + email_enabled integer NOT NULL DEFAULT 0, + notification_email text, + email_stock_reminders integer NOT NULL DEFAULT 1, + email_intake_reminders integer NOT NULL DEFAULT 1, + shoutrrr_enabled integer NOT NULL DEFAULT 0, + shoutrrr_url text, + shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, + shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, + reminder_days_before integer NOT NULL DEFAULT 7, + repeat_daily_reminders integer NOT NULL DEFAULT 0, + low_stock_days integer NOT NULL DEFAULT 30, + normal_stock_days integer NOT NULL DEFAULT 90, + high_stock_days integer NOT NULL DEFAULT 180, + expiry_warning_days integer NOT NULL DEFAULT 90, + language text NOT NULL DEFAULT 'en', + stock_calculation_mode text NOT NULL DEFAULT 'automatic', + last_auto_email_sent text, + last_notification_type text, + last_notification_channel text, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS share_tokens ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + token text NOT NULL UNIQUE, + taken_by text NOT NULL, + schedule_days integer NOT NULL DEFAULT 30, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + expires_at integer, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS dose_tracking ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + dose_id text NOT NULL, + taken_at integer NOT NULL DEFAULT (strftime('%s','now')), + marked_by text, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + ]; + + for (const sql of tables) { + await client.execute(sql); + } +} + +async function clearData(client: Client) { + await client.execute("DELETE FROM dose_tracking"); + await client.execute("DELETE FROM share_tokens"); + await client.execute("DELETE FROM user_settings"); + await client.execute("DELETE FROM medications"); + await client.execute("DELETE FROM users"); + await client.execute("DELETE FROM sqlite_sequence"); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("Integration Tests", () => { + let app: FastifyInstance; + const userId = 999999999; + + beforeAll(async () => { + await createSchema(testClient); + + app = Fastify({ logger: false }); + await app.register(sensible); + await app.register(cookie, { secret: "test-cookie-secret" }); + await app.register(jwt, { + secret: "test-jwt-secret", + cookie: { cookieName: "access_token", signed: false }, + }); + await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); + + app.decorate("config", { + accessSecret: "test-jwt-secret", + refreshSecret: "test-refresh-secret", + accessTtl: 15, + refreshTtl: 7, + cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" }, + refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" }, + }); + + await app.register(doseRoutes); + await app.register(shareRoutes); + await app.register(medicationRoutes); + await app.register(settingsRoutes); + + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + testClient.close(); + }); + + beforeEach(async () => { + await clearData(testClient); + await testClient.execute( + "INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')" + ); + }); + + // --------------------------------------------------------------------------- + // Medication Update + Dose Tracking Cleanup + // --------------------------------------------------------------------------- + + describe("Medication Update cleans up old dose tracking", () => { + it("should delete doses before new start date when start date is moved forward", async () => { + // Create medication starting Jan 1 + const createRes = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Test Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createRes.json().id; + + // Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10) + const jan1 = new Date("2025-01-01T08:00:00.000Z").getTime(); + const jan2 = new Date("2025-01-02T08:00:00.000Z").getTime(); + const jan5 = new Date("2025-01-05T08:00:00.000Z").getTime(); + const jan10 = new Date("2025-01-10T08:00:00.000Z").getTime(); + + for (const ts of [jan1, jan2, jan5, jan10]) { + await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId: `${medId}-0-${ts}` }, + }); + } + + // Verify 4 doses exist + const beforeUpdate = await testClient.execute({ + sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id LIKE ?`, + args: [`${medId}-%`], + }); + expect(beforeUpdate.rows[0].count).toBe(4); + + // Update medication to start Jan 5 (should delete Jan 1 and Jan 2 doses) + await app.inject({ + method: "PUT", + url: `/medications/${medId}`, + payload: { + name: "Test Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-05T08:00:00.000Z" }], + }, + }); + + // Verify only 2 doses remain (Jan 5 and Jan 10) + const afterUpdate = await testClient.execute({ + sql: `SELECT dose_id FROM dose_tracking WHERE dose_id LIKE ? ORDER BY dose_id`, + args: [`${medId}-%`], + }); + expect(afterUpdate.rows.length).toBe(2); + expect(afterUpdate.rows[0].dose_id).toContain(String(jan5)); + expect(afterUpdate.rows[1].dose_id).toContain(String(jan10)); + }); + + it("should keep all doses when start date is moved backward", async () => { + // Create medication starting Jan 10 + const createRes = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Test Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-10T08:00:00.000Z" }], + }, + }); + const medId = createRes.json().id; + + // Mark dose on Jan 10 + const jan10 = new Date("2025-01-10T08:00:00.000Z").getTime(); + await app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId: `${medId}-0-${jan10}` }, + }); + + // Update to start Jan 1 (earlier) + await app.inject({ + method: "PUT", + url: `/medications/${medId}`, + payload: { + name: "Test Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + // Dose should still exist + const afterUpdate = await testClient.execute({ + sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id LIKE ?`, + args: [`${medId}-%`], + }); + expect(afterUpdate.rows[0].count).toBe(1); + }); + + it("should handle multiple blisters with different start dates", async () => { + // Create medication with 2 schedules: Jan 1 morning and Jan 5 evening + const createRes = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Test Med", + blisters: [ + { usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, + { usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" }, + ], + }, + }); + const medId = createRes.json().id; + + // Mark doses for both schedules + const jan1_8am = new Date("2025-01-01T08:00:00.000Z").getTime(); + const jan3_8am = new Date("2025-01-03T08:00:00.000Z").getTime(); + const jan5_8pm = new Date("2025-01-05T20:00:00.000Z").getTime(); + const jan6_8pm = new Date("2025-01-06T20:00:00.000Z").getTime(); + + await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-0-${jan1_8am}` } }); + await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-0-${jan3_8am}` } }); + await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-1-${jan5_8pm}` } }); + await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-1-${jan6_8pm}` } }); + + // 4 doses total + const before = await testClient.execute({ + sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id LIKE ?`, + args: [`${medId}-%`], + }); + expect(before.rows[0].count).toBe(4); + + // Update: move first schedule to Jan 4 + // Earliest start is now Jan 4, so Jan 1 and Jan 3 doses should be deleted + await app.inject({ + method: "PUT", + url: `/medications/${medId}`, + payload: { + name: "Test Med", + blisters: [ + { usage: 1, every: 1, start: "2025-01-04T08:00:00.000Z" }, + { usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" }, + ], + }, + }); + + // Should have 2 doses left (Jan 5 and Jan 6 evening doses) + const after = await testClient.execute({ + sql: `SELECT dose_id FROM dose_tracking WHERE dose_id LIKE ?`, + args: [`${medId}-%`], + }); + expect(after.rows.length).toBe(2); + }); + }); + + // --------------------------------------------------------------------------- + // Share Link + Dose Tracking Integration + // --------------------------------------------------------------------------- + + describe("Share links and dose tracking integration", () => { + it("should allow marking/unmarking doses via share link with correct markedBy", async () => { + // Create medication for Daniel + const createRes = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Aspirin", + takenBy: ["Daniel"], + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createRes.json().id; + + // Create share token for Daniel + const shareRes = await app.inject({ + method: "POST", + url: "/share", + payload: { takenBy: "Daniel", scheduleDays: 30 }, + }); + const token = shareRes.json().token; + + // Mark dose via share link + const doseId = `${medId}-0-${new Date("2025-01-01T08:00:00.000Z").getTime()}`; + await app.inject({ + method: "POST", + url: `/share/${token}/doses`, + payload: { doseId }, + }); + + // Verify markedBy is "Daniel" + const result = await testClient.execute({ + sql: `SELECT marked_by FROM dose_tracking WHERE dose_id = ?`, + args: [doseId], + }); + expect(result.rows[0].marked_by).toBe("Daniel"); + + // Unmark via share link + await app.inject({ + method: "DELETE", + url: `/share/${token}/doses/${encodeURIComponent(doseId)}`, + }); + + // Verify deleted + const afterDelete = await testClient.execute({ + sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`, + args: [doseId], + }); + expect(afterDelete.rows[0].count).toBe(0); + }); + + it("should show medication in shared schedule after marking dose", async () => { + // Create medication + const createRes = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Vitamin D", + takenBy: ["Anna"], + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createRes.json().id; + + // Create share token + const shareRes = await app.inject({ + method: "POST", + url: "/share", + payload: { takenBy: "Anna", scheduleDays: 30 }, + }); + const token = shareRes.json().token; + + // Mark a dose + const doseId = `${medId}-0-${new Date("2025-01-05T08:00:00.000Z").getTime()}`; + await app.inject({ + method: "POST", + url: `/share/${token}/doses`, + payload: { doseId }, + }); + + // Get shared schedule + const scheduleRes = await app.inject({ + method: "GET", + url: `/share/${token}`, + }); + + const data = scheduleRes.json(); + expect(data.takenBy).toBe("Anna"); + expect(data.medications).toHaveLength(1); + expect(data.medications[0].name).toBe("Vitamin D"); + }); + }); + + // --------------------------------------------------------------------------- + // Settings + Stock Calculation Mode + // --------------------------------------------------------------------------- + + describe("Settings affect stock calculation", () => { + it("should persist stock calculation mode across requests", async () => { + // Set to manual mode + await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: false, + notificationEmail: "", + reminderDaysBefore: 7, + repeatDailyReminders: false, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + language: "en", + stockCalculationMode: "manual", + }, + }); + + // Verify it's saved + const getRes = await app.inject({ + method: "GET", + url: "/settings", + }); + expect(getRes.json().stockCalculationMode).toBe("manual"); + + // Change to automatic + await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: false, + notificationEmail: "", + reminderDaysBefore: 7, + repeatDailyReminders: false, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + language: "en", + stockCalculationMode: "automatic", + }, + }); + + const getRes2 = await app.inject({ + method: "GET", + url: "/settings", + }); + expect(getRes2.json().stockCalculationMode).toBe("automatic"); + }); + }); + + // --------------------------------------------------------------------------- + // Multi-Person Medication Scenarios + // --------------------------------------------------------------------------- + + describe("Multi-person medication scenarios", () => { + it("should create separate share links for different people", async () => { + // Create medication for multiple people + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Family Vitamins", + takenBy: ["Daniel", "Anna", "Max"], + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + // Create share links for each person + const danielShare = await app.inject({ + method: "POST", + url: "/share", + payload: { takenBy: "Daniel", scheduleDays: 30 }, + }); + + const annaShare = await app.inject({ + method: "POST", + url: "/share", + payload: { takenBy: "Anna", scheduleDays: 30 }, + }); + + // Both should succeed with different tokens + expect(danielShare.statusCode).toBe(200); + expect(annaShare.statusCode).toBe(200); + expect(danielShare.json().token).not.toBe(annaShare.json().token); + + // Each share link should show correct person + const danielSchedule = await app.inject({ + method: "GET", + url: `/share/${danielShare.json().token}`, + }); + expect(danielSchedule.json().takenBy).toBe("Daniel"); + + const annaSchedule = await app.inject({ + method: "GET", + url: `/share/${annaShare.json().token}`, + }); + expect(annaSchedule.json().takenBy).toBe("Anna"); + }); + + it("should list all people correctly via /share/people", async () => { + // Create multiple medications + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Med 1", + takenBy: ["Daniel", "Anna"], + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Med 2", + takenBy: ["Max"], + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Med 3", + takenBy: ["Daniel"], // Daniel again + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + // Get all people + const peopleRes = await app.inject({ + method: "GET", + url: "/share/people", + }); + + const people = peopleRes.json().people; + expect(people).toContain("Daniel"); + expect(people).toContain("Anna"); + expect(people).toContain("Max"); + expect(people.length).toBe(3); // No duplicates + }); + }); + + // --------------------------------------------------------------------------- + // Edge Cases + // --------------------------------------------------------------------------- + + describe("Edge cases", () => { + it("should handle medication with 0 stock correctly", async () => { + const createRes = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Empty Med", + packCount: 0, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(createRes.statusCode).toBe(200); + expect(createRes.json().packCount).toBe(0); + }); + + it("should handle medication with very high pill count", async () => { + const createRes = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Bulk Med", + packCount: 100, + blistersPerPack: 10, + pillsPerBlister: 100, + looseTablets: 500, + blisters: [{ usage: 0.5, every: 7, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(createRes.statusCode).toBe(200); + // Total: 100 * 10 * 100 + 500 = 100500 pills + }); + + it("should handle fractional pill usage", async () => { + const createRes = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Half-Pill Med", + blisters: [ + { usage: 0.5, every: 1, start: "2025-01-01T08:00:00.000Z" }, + { usage: 0.25, every: 1, start: "2025-01-01T20:00:00.000Z" }, + ], + }, + }); + + expect(createRes.statusCode).toBe(200); + expect(createRes.json().blisters[0].usage).toBe(0.5); + expect(createRes.json().blisters[1].usage).toBe(0.25); + }); + + it("should handle weekly medication schedule", async () => { + const createRes = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Weekly Med", + blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(createRes.statusCode).toBe(200); + expect(createRes.json().blisters[0].every).toBe(7); + }); + }); + + // --------------------------------------------------------------------------- + // Planner Usage Calculation - POST /medications/usage + // This is a CRITICAL feature for the app - calculates if stock is enough + // --------------------------------------------------------------------------- + + describe("Planner usage calculation", () => { + it("should calculate correct usage for daily medication", async () => { + // Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total + // Schedule: 1 pill daily starting Jan 1 + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Daily Med", + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 0, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + // Calculate usage for Jan 1-10 (10 days = 10 pills needed) + const response = await app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-11T00:00:00.000Z", // 10 days + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data).toHaveLength(1); + expect(data[0].medicationName).toBe("Daily Med"); + expect(data[0].plannerUsage).toBe(10); // 10 days × 1 pill + // Note: 'enough' depends on current stock after consumption since start date + // Since test runs ~364 days after Jan 1, most pills are consumed + }); + + it("should detect insufficient stock", async () => { + // Create medication: 1 pack × 1 blister × 5 pills = 5 pills total + // Schedule: 1 pill daily + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Low Stock Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 5, + looseTablets: 0, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + // Calculate usage for 10 days (needs 10 pills, only have 5 originally) + const response = await app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-11T00:00:00.000Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].plannerUsage).toBe(10); + expect(data[0].enough).toBe(false); // Not enough! + }); + + it("should calculate weekly medication usage correctly", async () => { + // Create medication: 10 pills total + // Schedule: 1 pill every 7 days starting Jan 1 + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Weekly Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + // Calculate usage for 30 days (should need ~4-5 pills) + const response = await app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-31T00:00:00.000Z", // 30 days + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + // Jan 1, 8, 15, 22, 29 = 5 doses + expect(data[0].plannerUsage).toBe(5); + }); + + it("should handle multiple intake schedules per medication", async () => { + // Create medication with morning and evening doses + // 30 pills total, 1.5 pills per day (1 morning + 0.5 evening) + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Twice Daily Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + blisters: [ + { usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, // Morning: 1 pill + { usage: 0.5, every: 1, start: "2025-01-01T20:00:00.000Z" }, // Evening: 0.5 pill + ], + }, + }); + + // Calculate for 10 days + const response = await app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-11T00:00:00.000Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + // 10 days × (1 + 0.5) = 15 pills + expect(data[0].plannerUsage).toBe(15); + }); + + it("should calculate correct blisters needed", async () => { + // 10 pills per blister, need 25 pills → need 3 blisters + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Blister Med", + packCount: 5, + blistersPerPack: 1, + pillsPerBlister: 10, + blisters: [{ usage: 2.5, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + // 10 days × 2.5 pills = 25 pills needed + const response = await app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-11T00:00:00.000Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].plannerUsage).toBe(25); + expect(data[0].blistersNeeded).toBe(3); // ceil(25/10) + expect(data[0].blisterSize).toBe(10); + }); + + it("should reject invalid date range", async () => { + // End before start + const response = await app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-15T00:00:00.000Z", + endDate: "2025-01-01T00:00:00.000Z", + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should handle medication not yet started", async () => { + // Medication starts in the future + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Future Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + blisters: [{ usage: 1, every: 1, start: "2025-06-01T08:00:00.000Z" }], // Starts June + }, + }); + + // Query for January (before start) + const response = await app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-31T00:00:00.000Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].plannerUsage).toBe(0); // No usage before start + }); + + it("should return correct totalPills based on current stock", async () => { + // Fresh medication with future start date = no consumption yet + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Fresh Med", + packCount: 2, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 5, + // Start in far future so no consumption + blisters: [{ usage: 1, every: 1, start: "2030-01-01T08:00:00.000Z" }], + }, + }); + + const response = await app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2030-01-01T00:00:00.000Z", + endDate: "2030-01-11T00:00:00.000Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + // Total: 2 packs × 2 blisters × 10 pills + 5 loose = 45 pills + expect(data[0].totalPills).toBe(45); + expect(data[0].plannerUsage).toBe(10); + expect(data[0].enough).toBe(true); // 45 > 10 + }); + }); +}); diff --git a/backend/src/test/medications.test.ts b/backend/src/test/medications.test.ts new file mode 100644 index 0000000..9d875e7 --- /dev/null +++ b/backend/src/test/medications.test.ts @@ -0,0 +1,672 @@ +/** + * Tests for /medications API endpoints. + * Tests CRUD operations for medications. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + buildTestApp, + closeTestApp, + clearTestData, + createTestUser, + createTestMedication, + TestContext, +} from "./setup.js"; + +// ============================================================================= +// Route Registration +// ============================================================================= + +async function registerMedicationRoutes(ctx: TestContext) { + const { app, client } = ctx; + + // GET /medications - List all medications + app.get("/medications", async (request, reply) => { + const userId = 1; + + const result = await client.execute({ + sql: `SELECT * FROM medications WHERE user_id = ? ORDER BY name`, + args: [userId], + }); + + return result.rows.map((m) => ({ + id: m.id, + name: m.name, + genericName: m.generic_name, + takenBy: JSON.parse((m.taken_by_json as string) || "[]"), + packCount: m.pack_count, + blistersPerPack: m.blisters_per_pack, + pillsPerBlister: m.pills_per_blister, + looseTablets: m.loose_tablets, + pillWeightMg: m.pill_weight_mg, + imageUrl: m.image_url, + expiryDate: m.expiry_date, + notes: m.notes, + intakeRemindersEnabled: Boolean(m.intake_reminders_enabled), + blisters: (() => { + const usage: number[] = JSON.parse((m.usage_json as string) || "[]"); + const every: number[] = JSON.parse((m.every_json as string) || "[]"); + const start: string[] = JSON.parse((m.start_json as string) || "[]"); + return usage.map((u, i) => ({ + usage: u, + every: every[i] || 1, + start: start[i] || new Date().toISOString(), + })); + })(), + })); + }); + + // POST /medications - Create medication + app.post<{ + Body: { + name: string; + genericName?: string; + takenBy?: string[]; + packCount?: number; + blistersPerPack?: number; + pillsPerBlister?: number; + looseTablets?: number; + pillWeightMg?: number; + expiryDate?: string; + notes?: string; + intakeRemindersEnabled?: boolean; + blisters: Array<{ usage: number; every: number; start: string }>; + }; + }>("/medications", async (request, reply) => { + const userId = 1; + const body = request.body || {}; + + // Validation + if (!body.name || body.name.length === 0) { + return reply.status(400).send({ error: "Name is required" }); + } + if (body.name.length > 100) { + return reply.status(400).send({ error: "Name must be 100 characters or less" }); + } + if (!body.blisters || body.blisters.length === 0) { + return reply.status(400).send({ error: "At least one intake schedule is required" }); + } + if (body.blisters.length > 12) { + return reply.status(400).send({ error: "Maximum 12 intake schedules allowed" }); + } + + const usageJson = JSON.stringify(body.blisters.map((b) => b.usage)); + const everyJson = JSON.stringify(body.blisters.map((b) => b.every)); + const startJson = JSON.stringify(body.blisters.map((b) => b.start)); + const takenByJson = JSON.stringify(body.takenBy || []); + + const result = await client.execute({ + sql: `INSERT INTO medications ( + user_id, name, generic_name, taken_by_json, + pack_count, blisters_per_pack, pills_per_blister, loose_tablets, + pill_weight_mg, expiry_date, notes, intake_reminders_enabled, + usage_json, every_json, start_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + args: [ + userId, + body.name, + body.genericName || null, + takenByJson, + body.packCount ?? 1, + body.blistersPerPack ?? 1, + body.pillsPerBlister ?? 1, + body.looseTablets ?? 0, + body.pillWeightMg ?? null, + body.expiryDate || null, + body.notes || null, + body.intakeRemindersEnabled ? 1 : 0, + usageJson, + everyJson, + startJson, + ], + }); + + return { id: result.rows[0].id, success: true }; + }); + + // PUT /medications/:id - Update medication + app.put<{ + Params: { id: string }; + Body: { + name: string; + genericName?: string; + takenBy?: string[]; + packCount?: number; + blistersPerPack?: number; + pillsPerBlister?: number; + looseTablets?: number; + pillWeightMg?: number; + expiryDate?: string; + notes?: string; + intakeRemindersEnabled?: boolean; + blisters: Array<{ usage: number; every: number; start: string }>; + }; + }>("/medications/:id", async (request, reply) => { + const userId = 1; + const medId = parseInt(request.params.id, 10); + const body = request.body || {}; + + // Check ownership + const existing = await client.execute({ + sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`, + args: [medId, userId], + }); + + if (existing.rows.length === 0) { + return reply.status(404).send({ error: "Medication not found" }); + } + + // Validation + if (!body.name || body.name.length === 0) { + return reply.status(400).send({ error: "Name is required" }); + } + if (!body.blisters || body.blisters.length === 0) { + return reply.status(400).send({ error: "At least one intake schedule is required" }); + } + + const usageJson = JSON.stringify(body.blisters.map((b) => b.usage)); + const everyJson = JSON.stringify(body.blisters.map((b) => b.every)); + const startJson = JSON.stringify(body.blisters.map((b) => b.start)); + const takenByJson = JSON.stringify(body.takenBy || []); + + await client.execute({ + sql: `UPDATE medications SET + name = ?, generic_name = ?, taken_by_json = ?, + pack_count = ?, blisters_per_pack = ?, pills_per_blister = ?, loose_tablets = ?, + pill_weight_mg = ?, expiry_date = ?, notes = ?, intake_reminders_enabled = ?, + usage_json = ?, every_json = ?, start_json = ?, + updated_at = strftime('%s','now') + WHERE id = ? AND user_id = ?`, + args: [ + body.name, + body.genericName || null, + takenByJson, + body.packCount ?? 1, + body.blistersPerPack ?? 1, + body.pillsPerBlister ?? 1, + body.looseTablets ?? 0, + body.pillWeightMg ?? null, + body.expiryDate || null, + body.notes || null, + body.intakeRemindersEnabled ? 1 : 0, + usageJson, + everyJson, + startJson, + medId, + userId, + ], + }); + + return { success: true }; + }); + + // DELETE /medications/:id - Delete medication + app.delete<{ Params: { id: string } }>("/medications/:id", async (request, reply) => { + const userId = 1; + const medId = parseInt(request.params.id, 10); + + // Check ownership + const existing = await client.execute({ + sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`, + args: [medId, userId], + }); + + if (existing.rows.length === 0) { + return reply.status(404).send({ error: "Medication not found" }); + } + + await client.execute({ + sql: `DELETE FROM medications WHERE id = ? AND user_id = ?`, + args: [medId, userId], + }); + + return { success: true }; + }); + + // GET /medications/:id - Get single medication + app.get<{ Params: { id: string } }>("/medications/:id", async (request, reply) => { + const userId = 1; + const medId = parseInt(request.params.id, 10); + + const result = await client.execute({ + sql: `SELECT * FROM medications WHERE id = ? AND user_id = ?`, + args: [medId, userId], + }); + + if (result.rows.length === 0) { + return reply.status(404).send({ error: "Medication not found" }); + } + + const m = result.rows[0]; + return { + id: m.id, + name: m.name, + genericName: m.generic_name, + takenBy: JSON.parse((m.taken_by_json as string) || "[]"), + packCount: m.pack_count, + blistersPerPack: m.blisters_per_pack, + pillsPerBlister: m.pills_per_blister, + looseTablets: m.loose_tablets, + pillWeightMg: m.pill_weight_mg, + imageUrl: m.image_url, + expiryDate: m.expiry_date, + notes: m.notes, + intakeRemindersEnabled: Boolean(m.intake_reminders_enabled), + blisters: (() => { + const usage: number[] = JSON.parse((m.usage_json as string) || "[]"); + const every: number[] = JSON.parse((m.every_json as string) || "[]"); + const start: string[] = JSON.parse((m.start_json as string) || "[]"); + return usage.map((u, i) => ({ + usage: u, + every: every[i] || 1, + start: start[i] || new Date().toISOString(), + })); + })(), + }; + }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("Medications API", () => { + let ctx: TestContext; + let userId: number; + + beforeAll(async () => { + ctx = await buildTestApp(); + await registerMedicationRoutes(ctx); + await ctx.app.ready(); + }); + + afterAll(async () => { + await closeTestApp(ctx); + }); + + beforeEach(async () => { + await clearTestData(ctx.client); + await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'"); + await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='medications'"); + userId = await createTestUser(ctx.client, { username: "testuser" }); + }); + + // --------------------------------------------------------------------------- + // GET /medications + // --------------------------------------------------------------------------- + + describe("GET /medications", () => { + it("should return empty array when no medications", async () => { + const response = await ctx.app.inject({ + method: "GET", + url: "/medications", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual([]); + }); + + it("should return list of medications", async () => { + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + genericName: "Acetylsalicylic acid", + takenBy: ["Daniel"], + packCount: 2, + pillsPerBlister: 10, + }); + await createTestMedication(ctx.client, { + userId, + name: "Ibuprofen", + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/medications", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data).toHaveLength(2); + // Sorted by name + expect(data[0].name).toBe("Aspirin"); + expect(data[0].genericName).toBe("Acetylsalicylic acid"); + expect(data[0].takenBy).toEqual(["Daniel"]); + expect(data[1].name).toBe("Ibuprofen"); + }); + + it("should return medication with all fields", async () => { + const startDate = "2025-01-01T08:00:00.000Z"; + await createTestMedication(ctx.client, { + userId, + name: "Test Med", + genericName: "Generic Name", + takenBy: ["Person1", "Person2"], + packCount: 3, + blistersPerPack: 2, + pillsPerBlister: 14, + looseTablets: 5, + pillWeightMg: 500, + blisters: [ + { usage: 1, every: 1, start: startDate }, + { usage: 2, every: 2, start: startDate }, + ], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/medications", + }); + + expect(response.statusCode).toBe(200); + const [med] = response.json(); + expect(med.name).toBe("Test Med"); + expect(med.genericName).toBe("Generic Name"); + expect(med.takenBy).toEqual(["Person1", "Person2"]); + expect(med.packCount).toBe(3); + expect(med.blistersPerPack).toBe(2); + expect(med.pillsPerBlister).toBe(14); + expect(med.looseTablets).toBe(5); + expect(med.pillWeightMg).toBe(500); + expect(med.blisters).toHaveLength(2); + expect(med.blisters[0]).toEqual({ usage: 1, every: 1, start: startDate }); + expect(med.blisters[1]).toEqual({ usage: 2, every: 2, start: startDate }); + }); + }); + + // --------------------------------------------------------------------------- + // POST /medications + // --------------------------------------------------------------------------- + + describe("POST /medications", () => { + it("should create a medication", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "New Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.success).toBe(true); + expect(data.id).toBeDefined(); + + // Verify in database + const result = await ctx.client.execute({ + sql: `SELECT name FROM medications WHERE id = ?`, + args: [data.id], + }); + expect(result.rows[0].name).toBe("New Med"); + }); + + it("should create medication with all fields", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Full Med", + genericName: "Generic", + takenBy: ["Alice", "Bob"], + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + pillWeightMg: 250, + expiryDate: "2026-12-31", + notes: "Take with food", + intakeRemindersEnabled: true, + blisters: [ + { usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, + { usage: 2, every: 1, start: "2025-01-01T20:00:00.000Z" }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + + // Verify + const medId = response.json().id; + const result = await ctx.client.execute({ + sql: `SELECT * FROM medications WHERE id = ?`, + args: [medId], + }); + const med = result.rows[0]; + expect(med.name).toBe("Full Med"); + expect(med.generic_name).toBe("Generic"); + expect(JSON.parse(med.taken_by_json as string)).toEqual(["Alice", "Bob"]); + expect(med.pack_count).toBe(2); + expect(med.blisters_per_pack).toBe(3); + expect(med.pills_per_blister).toBe(10); + expect(med.loose_tablets).toBe(5); + expect(med.pill_weight_mg).toBe(250); + expect(med.expiry_date).toBe("2026-12-31"); + expect(med.notes).toBe("Take with food"); + expect(med.intake_reminders_enabled).toBe(1); + }); + + it("should reject request without name", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/medications", + payload: { + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("Name is required"); + }); + + it("should reject request without blisters", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Test", + blisters: [], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("At least one intake schedule is required"); + }); + + it("should reject name over 100 characters", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "A".repeat(101), + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("Name must be 100 characters or less"); + }); + + it("should reject more than 12 blisters", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Test", + blisters: Array(13).fill({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }), + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("Maximum 12 intake schedules allowed"); + }); + }); + + // --------------------------------------------------------------------------- + // PUT /medications/:id + // --------------------------------------------------------------------------- + + describe("PUT /medications/:id", () => { + it("should update a medication", async () => { + const medId = await createTestMedication(ctx.client, { + userId, + name: "Old Name", + }); + + const response = await ctx.app.inject({ + method: "PUT", + url: `/medications/${medId}`, + payload: { + name: "New Name", + blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify + const result = await ctx.client.execute({ + sql: `SELECT name, usage_json FROM medications WHERE id = ?`, + args: [medId], + }); + expect(result.rows[0].name).toBe("New Name"); + expect(JSON.parse(result.rows[0].usage_json as string)).toEqual([2]); + }); + + it("should return 404 for non-existent medication", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/medications/99999", + payload: { + name: "Test", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json().error).toBe("Medication not found"); + }); + + it("should not update medication of another user", async () => { + // Create another user + const otherUserId = await createTestUser(ctx.client, { username: "other" }); + const medId = await createTestMedication(ctx.client, { + userId: otherUserId, + name: "Other Med", + }); + + const response = await ctx.app.inject({ + method: "PUT", + url: `/medications/${medId}`, + payload: { + name: "Hacked", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + // --------------------------------------------------------------------------- + // DELETE /medications/:id + // --------------------------------------------------------------------------- + + describe("DELETE /medications/:id", () => { + it("should delete a medication", async () => { + const medId = await createTestMedication(ctx.client, { + userId, + name: "To Delete", + }); + + const response = await ctx.app.inject({ + method: "DELETE", + url: `/medications/${medId}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify deleted + const result = await ctx.client.execute({ + sql: `SELECT COUNT(*) as count FROM medications WHERE id = ?`, + args: [medId], + }); + expect(result.rows[0].count).toBe(0); + }); + + it("should return 404 for non-existent medication", async () => { + const response = await ctx.app.inject({ + method: "DELETE", + url: "/medications/99999", + }); + + expect(response.statusCode).toBe(404); + }); + }); + + // --------------------------------------------------------------------------- + // GET /medications/:id + // --------------------------------------------------------------------------- + + describe("GET /medications/:id", () => { + it("should return single medication", async () => { + const medId = await createTestMedication(ctx.client, { + userId, + name: "Single Med", + genericName: "Generic", + takenBy: ["Daniel"], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: `/medications/${medId}`, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.id).toBe(medId); + expect(data.name).toBe("Single Med"); + expect(data.genericName).toBe("Generic"); + expect(data.takenBy).toEqual(["Daniel"]); + }); + + it("should return 404 for non-existent medication", async () => { + const response = await ctx.app.inject({ + method: "GET", + url: "/medications/99999", + }); + + expect(response.statusCode).toBe(404); + }); + }); + + // --------------------------------------------------------------------------- + // Stock Calculation Tests + // --------------------------------------------------------------------------- + + describe("Stock Calculation", () => { + it("should calculate total pills correctly", async () => { + await createTestMedication(ctx.client, { + userId, + name: "Stock Test", + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/medications", + }); + + const [med] = response.json(); + // Total = (2 packs × 3 blisters × 10 pills) + 5 loose = 65 + const totalPills = + med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets; + expect(totalPills).toBe(65); + }); + }); +}); diff --git a/backend/src/test/planner.test.ts b/backend/src/test/planner.test.ts new file mode 100644 index 0000000..a07f5d8 --- /dev/null +++ b/backend/src/test/planner.test.ts @@ -0,0 +1,706 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import Fastify, { FastifyInstance } from "fastify"; +import { createClient, Client } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; + +// Create test database and mocks before anything else (hoisted) +const { testClient, testDb, mockSendMail, mockSendShoutrrr, mockUpdateReminderSentTime, mockUpdateUserReminderSentTime } = vi.hoisted(() => { + const { createClient } = require("@libsql/client"); + const { drizzle } = require("drizzle-orm/libsql"); + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + return { + testClient: client, + testDb: db, + mockSendMail: vi.fn(), + mockSendShoutrrr: vi.fn(), + mockUpdateReminderSentTime: vi.fn(), + mockUpdateUserReminderSentTime: vi.fn(), + }; +}); + +// Mock nodemailer +vi.mock("nodemailer", () => ({ + default: { + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), + }, +})); + +// Mock the db module +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); + +// Mock env to disable auth +vi.mock("../plugins/env.js", () => ({ + env: { + AUTH_ENABLED: false, + JWT_SECRET: "test-secret-key-for-testing", + JWT_REFRESH_SECRET: "test-refresh-secret-key", + }, +})); + +// Mock auth plugin +vi.mock("../plugins/auth.js", () => ({ + requireAuth: async () => {}, + getAnonymousUserId: () => 999999999, +})); + +// Mock reminder-scheduler +vi.mock("../services/reminder-scheduler.js", () => ({ + updateReminderSentTime: mockUpdateReminderSentTime, + updateUserReminderSentTime: mockUpdateUserReminderSentTime, +})); + +// Mock sendShoutrrrNotification from settings +vi.mock("../routes/settings.js", async (importOriginal) => { + const original = await importOriginal() as any; + return { + ...original, + sendShoutrrrNotification: mockSendShoutrrr, + }; +}); + +import { plannerRoutes } from "../routes/planner.js"; + +// ============================================================================= +// Test Setup +// ============================================================================= + +async function createSchema(client: Client) { + const tableCreations = [ + `CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + password_hash text, + auth_provider text NOT NULL DEFAULT 'local', + is_active integer NOT NULL DEFAULT 1, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + updated_at integer NOT NULL DEFAULT (strftime('%s','now')) + )`, + `CREATE TABLE IF NOT EXISTS user_settings ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL UNIQUE, + email_enabled integer NOT NULL DEFAULT 0, + notification_email text, + email_stock_reminders integer NOT NULL DEFAULT 1, + email_intake_reminders integer NOT NULL DEFAULT 1, + shoutrrr_enabled integer NOT NULL DEFAULT 0, + shoutrrr_url text, + shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, + shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, + reminder_days_before integer NOT NULL DEFAULT 7, + repeat_daily_reminders integer NOT NULL DEFAULT 0, + low_stock_days integer NOT NULL DEFAULT 30, + normal_stock_days integer NOT NULL DEFAULT 90, + high_stock_days integer NOT NULL DEFAULT 180, + expiry_warning_days integer NOT NULL DEFAULT 90, + language text NOT NULL DEFAULT 'en', + stock_calculation_mode text NOT NULL DEFAULT 'automatic', + last_auto_email_sent text, + last_notification_type text, + last_notification_channel text, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + ]; + + for (const sql of tableCreations) { + await client.execute(sql); + } +} + +async function clearData(client: Client) { + await client.execute("DELETE FROM user_settings"); + await client.execute("DELETE FROM users"); + await client.execute("DELETE FROM sqlite_sequence"); +} + +describe("Planner Routes", () => { + let app: FastifyInstance; + + beforeAll(async () => { + await createSchema(testClient); + }); + + beforeEach(async () => { + await clearData(testClient); + + // Create anonymous user + await testClient.execute( + "INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')" + ); + + app = Fastify({ logger: false }); + await app.register(plannerRoutes); + await app.ready(); + + vi.clearAllMocks(); + mockSendMail.mockReset(); + mockSendShoutrrr.mockReset(); + }); + + afterAll(async () => { + await app?.close(); + testClient.close(); + }); + + describe("POST /planner/send-email", () => { + it("should reject request with missing email", async () => { + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + from: "2025-01-01", + until: "2025-01-31", + rows: [{ medicationName: "Test", totalPills: 10, plannerUsage: 5, enough: true }], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "Missing email or planner data" }); + }); + + it("should reject request with missing rows", async () => { + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + from: "2025-01-01", + until: "2025-01-31", + rows: [], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "Missing email or planner data" }); + }); + + it("should reject when SMTP is not configured", async () => { + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + from: "2025-01-01", + until: "2025-01-31", + rows: [ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 30, + plannerUsage: 10, + blisterSize: 10, + blistersNeeded: 1, + fullBlisters: 3, + loosePills: 0, + enough: true, + }, + ], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "SMTP not configured" }); + }); + + it("should send email successfully when SMTP is configured", async () => { + // Set SMTP env vars + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + from: "2025-01-01", + until: "2025-01-31", + language: "en", + rows: [ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 30, + plannerUsage: 10, + blisterSize: 10, + blistersNeeded: 1, + fullBlisters: 3, + loosePills: 0, + enough: true, + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, message: "Email sent successfully" }); + expect(mockSendMail).toHaveBeenCalledTimes(1); + + // Cleanup + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should handle email with out of stock medications", async () => { + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + from: "2025-01-01", + until: "2025-01-31", + rows: [ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 5, + plannerUsage: 30, + blisterSize: 10, + blistersNeeded: 3, + fullBlisters: 0, + loosePills: 5, + enough: false, + }, + { + medicationId: 2, + medicationName: "Ibuprofen", + totalPills: 100, + plannerUsage: 10, + blisterSize: 10, + blistersNeeded: 1, + fullBlisters: 10, + loosePills: 0, + enough: true, + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(mockSendMail).toHaveBeenCalledTimes(1); + + // Check that HTML contains out of stock warning + const mailCall = mockSendMail.mock.calls[0][0]; + expect(mailCall.html).toContain("Out of Stock"); + expect(mailCall.html).toContain("1 medication"); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should handle SMTP error gracefully", async () => { + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + mockSendMail.mockRejectedValueOnce(new Error("Connection refused")); + + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + from: "2025-01-01", + until: "2025-01-31", + rows: [ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 30, + plannerUsage: 10, + blisterSize: 10, + blistersNeeded: 1, + fullBlisters: 3, + loosePills: 0, + enough: true, + }, + ], + }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json().error).toContain("Failed to send email"); + expect(response.json().error).toContain("Connection refused"); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should use German locale when language is de", async () => { + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + from: "2025-01-15", + until: "2025-02-15", + language: "de", + rows: [ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 30, + plannerUsage: 10, + blisterSize: 10, + blistersNeeded: 1, + fullBlisters: 3, + loosePills: 0, + enough: true, + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + + // German date format should be used + const mailCall = mockSendMail.mock.calls[0][0]; + expect(mailCall.subject).toContain("Supply Overview"); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + }); + + describe("POST /reminder/send-email", () => { + it("should reject request with missing lowStock data", async () => { + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "Missing low stock data" }); + }); + + it("should reject request with no lowStock array", async () => { + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "Missing low stock data" }); + }); + + it("should return error when no notification channels configured", async () => { + // User settings exist but email/shoutrrr disabled + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 0, 0, 'en')`, + args: [999999999], + }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, + ], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "No notification channels configured" }); + }); + + it("should send email reminder when email is enabled", async () => { + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + // Enable email in user settings + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, + args: [999999999], + }); + + mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, message: "Reminder sent via email" }); + expect(mockSendMail).toHaveBeenCalledTimes(1); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should handle empty medications (medsLeft <= 0)", async () => { + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, + args: [999999999], + }); + + mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null }, + { name: "Ibuprofen", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + + // Check email contains EMPTY warning + const mailCall = mockSendMail.mock.calls[0][0]; + expect(mailCall.subject).toContain("Empty"); + expect(mailCall.html).toContain("EMPTY"); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should handle mixed empty and low stock medications", async () => { + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, + args: [999999999], + }); + + mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null }, + { name: "Ibuprofen", medsLeft: 10, daysLeft: 5, depletionDate: "2025-01-05" }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + + const mailCall = mockSendMail.mock.calls[0][0]; + expect(mailCall.subject).toContain("Empty"); + expect(mailCall.subject).toContain("Running Low"); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should handle email error gracefully", async () => { + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, + args: [999999999], + }); + + mockSendMail.mockRejectedValueOnce(new Error("SMTP error")); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, + ], + }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json().error).toContain("Email:"); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should send push notification when shoutrrr is enabled", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, + args: [999999999], + }); + + mockSendShoutrrr.mockResolvedValueOnce({ success: true }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, message: "Reminder sent via push" }); + expect(mockSendShoutrrr).toHaveBeenCalledTimes(1); + }); + + it("should send both email and push when both enabled", async () => { + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 1, 1, 'ntfy://localhost/test', 'en')`, + args: [999999999], + }); + + mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendShoutrrr.mockResolvedValueOnce({ success: true }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, message: "Reminder sent via email and push" }); + expect(mockSendMail).toHaveBeenCalledTimes(1); + expect(mockSendShoutrrr).toHaveBeenCalledTimes(1); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should handle push notification error gracefully", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, + args: [999999999], + }); + + mockSendShoutrrr.mockResolvedValueOnce({ success: false, error: "Connection failed" }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, + ], + }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json().error).toContain("Push:"); + }); + + it("should handle push with empty meds using German translations", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'de')`, + args: [999999999], + }); + + mockSendShoutrrr.mockResolvedValueOnce({ success: true }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(mockSendShoutrrr).toHaveBeenCalledTimes(1); + + // Check German translations are used + const [title, message] = mockSendShoutrrr.mock.calls[0].slice(1); + expect(title).toContain("Leer"); + }); + + it("should handle push exception gracefully", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, + args: [999999999], + }); + + mockSendShoutrrr.mockRejectedValueOnce(new Error("Network error")); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, + ], + }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json().error).toContain("Push:"); + expect(response.json().error).toContain("Network error"); + }); + }); +}); diff --git a/backend/src/test/server.test.ts b/backend/src/test/server.test.ts new file mode 100644 index 0000000..23f33be --- /dev/null +++ b/backend/src/test/server.test.ts @@ -0,0 +1,499 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import sensible from "@fastify/sensible"; +import cookie from "@fastify/cookie"; +import { mkdirSync, rmSync, existsSync } from "fs"; +import { resolve } from "path"; +import { tmpdir } from "os"; + +// Import from utils to avoid index.ts import side effects (server start) +import { + parseCorsOrigins, + buildBaseCookieOptions, + buildRefreshCookieOptions, + buildAppConfig, + ensureImagesDirectory, + getJwtConfig, +} from "../utils/server-config.js"; + +describe("Index.ts Utility Functions", () => { + describe("parseCorsOrigins", () => { + it("should parse comma-separated origins", () => { + const origins = parseCorsOrigins("http://localhost:5173,http://localhost:4173"); + expect(origins).toHaveLength(2); + expect(origins[0]).toBe("http://localhost:5173"); + expect(origins[1]).toBe("http://localhost:4173"); + }); + + it("should handle single origin", () => { + const origins = parseCorsOrigins("https://myapp.example.com"); + expect(origins).toHaveLength(1); + expect(origins[0]).toBe("https://myapp.example.com"); + }); + + it("should filter out empty strings", () => { + const origins = parseCorsOrigins("http://localhost:5173,,http://localhost:4173,"); + expect(origins).toHaveLength(2); + }); + + it("should trim whitespace", () => { + const origins = parseCorsOrigins(" http://localhost:5173 , http://localhost:4173 "); + expect(origins).toEqual(["http://localhost:5173", "http://localhost:4173"]); + }); + + it("should return empty array for empty string", () => { + const origins = parseCorsOrigins(""); + expect(origins).toHaveLength(0); + }); + }); + + describe("buildBaseCookieOptions", () => { + it("should set secure=true in production", () => { + const options = buildBaseCookieOptions(15, true); + expect(options.secure).toBe(true); + expect(options.httpOnly).toBe(true); + expect(options.sameSite).toBe("lax"); + expect(options.path).toBe("/"); + }); + + it("should set secure=false in development", () => { + const options = buildBaseCookieOptions(15, false); + expect(options.secure).toBe(false); + }); + + it("should calculate maxAge in seconds from minutes", () => { + const options = buildBaseCookieOptions(15, false); + expect(options.maxAge).toBe(15 * 60); // 900 seconds + }); + + it("should handle custom TTL values", () => { + const options = buildBaseCookieOptions(30, false); + expect(options.maxAge).toBe(30 * 60); // 1800 seconds + }); + }); + + describe("buildRefreshCookieOptions", () => { + it("should extend base options with longer maxAge", () => { + const base = buildBaseCookieOptions(15, false); + const refresh = buildRefreshCookieOptions(base, 7); + + expect(refresh.httpOnly).toBe(true); + expect(refresh.sameSite).toBe("lax"); + expect(refresh.maxAge).toBe(7 * 24 * 60 * 60); // 7 days in seconds + }); + + it("should calculate 14 days correctly", () => { + const base = buildBaseCookieOptions(15, false); + const refresh = buildRefreshCookieOptions(base, 14); + expect(refresh.maxAge).toBe(14 * 24 * 60 * 60); // 1209600 seconds + }); + + it("should preserve secure flag from base", () => { + const base = buildBaseCookieOptions(15, true); + const refresh = buildRefreshCookieOptions(base, 7); + expect(refresh.secure).toBe(true); + }); + }); + + describe("buildAppConfig", () => { + it("should build complete config object", () => { + const config = buildAppConfig({ + jwtSecret: "test-jwt-secret", + refreshSecret: "test-refresh-secret", + accessTtlMinutes: 15, + refreshTtlDays: 7, + isProduction: false, + }); + + expect(config.accessSecret).toBe("test-jwt-secret"); + expect(config.refreshSecret).toBe("test-refresh-secret"); + expect(config.accessTtl).toBe(15); + expect(config.refreshTtl).toBe(7); + expect(config.cookieOptions).toBeDefined(); + expect(config.refreshCookieOptions).toBeDefined(); + }); + + it("should use empty strings for missing secrets", () => { + const config = buildAppConfig({ + accessTtlMinutes: 15, + refreshTtlDays: 7, + isProduction: false, + }); + + expect(config.accessSecret).toBe(""); + expect(config.refreshSecret).toBe(""); + }); + + it("should set secure cookies in production", () => { + const config = buildAppConfig({ + accessTtlMinutes: 15, + refreshTtlDays: 7, + isProduction: true, + }); + + expect(config.cookieOptions.secure).toBe(true); + expect(config.refreshCookieOptions.secure).toBe(true); + }); + }); + + describe("ensureImagesDirectory", () => { + const testDir = resolve(tmpdir(), `test-images-dir-${Date.now()}`); + + afterEach(() => { + try { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + } catch { + // ignore cleanup errors + } + }); + + it("should create directory if it does not exist", () => { + const imagesDir = ensureImagesDirectory(testDir); + expect(existsSync(imagesDir)).toBe(true); + expect(imagesDir).toContain("data/images"); + }); + + it("should return path if directory already exists", () => { + const firstCall = ensureImagesDirectory(testDir); + const secondCall = ensureImagesDirectory(testDir); + expect(firstCall).toBe(secondCall); + }); + }); + + describe("getJwtConfig", () => { + it("should return real secret when auth enabled with secret", () => { + const config = getJwtConfig(true, "my-super-secret"); + expect(config.secret).toBe("my-super-secret"); + expect(config.cookie.cookieName).toBe("access_token"); + expect(config.cookie.signed).toBe(false); + }); + + it("should return dummy secret when auth disabled", () => { + const config = getJwtConfig(false, undefined); + expect(config.secret).toBe("auth-disabled-no-secret-needed"); + }); + + it("should return dummy secret when auth enabled but no secret", () => { + const config = getJwtConfig(true, undefined); + expect(config.secret).toBe("auth-disabled-no-secret-needed"); + }); + + it("should return dummy secret when auth enabled with empty secret", () => { + const config = getJwtConfig(true, ""); + expect(config.secret).toBe("auth-disabled-no-secret-needed"); + }); + }); +}); + +// Test the server bootstrap logic without starting the actual server + +describe("Server Bootstrap", () => { + describe("Fastify App Configuration", () => { + it("should create a Fastify instance with logger", async () => { + const app = Fastify({ + logger: { + level: "silent", // Disable logging for tests + }, + }); + + expect(app).toBeDefined(); + expect(app.log).toBeDefined(); + + await app.close(); + }); + + it("should register sensible plugin", async () => { + const app = Fastify({ logger: false }); + await app.register(sensible); + + // Sensible adds error helpers + expect(app.httpErrors).toBeDefined(); + expect(app.httpErrors.notFound).toBeDefined(); + + await app.close(); + }); + + it("should register cors plugin with multiple origins", async () => { + const origins = ["http://localhost:5173", "http://localhost:4173"]; + + const app = Fastify({ logger: false }); + await app.register(cors, { origin: origins, credentials: true }); + + // Add a test route + app.get("/test", async () => ({ ok: true })); + + await app.ready(); + + // Test CORS headers + const response = await app.inject({ + method: "GET", + url: "/test", + headers: { + origin: "http://localhost:5173", + }, + }); + + expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:5173"); + expect(response.headers["access-control-allow-credentials"]).toBe("true"); + + await app.close(); + }); + + it("should register cookie plugin", async () => { + const app = Fastify({ logger: false }); + await app.register(cookie, { secret: "test-cookie-secret" }); + + // Add a test route that sets a cookie + app.get("/set-cookie", async (request, reply) => { + reply.setCookie("test", "value", { path: "/" }); + return { ok: true }; + }); + + await app.ready(); + + const response = await app.inject({ + method: "GET", + url: "/set-cookie", + }); + + expect(response.headers["set-cookie"]).toBeDefined(); + + await app.close(); + }); + }); + + describe("Config Decorator", () => { + it("should create config with auth settings", async () => { + const app = Fastify({ logger: false }); + + const accessTtlMinutes = 15; + const refreshTtlDays = 7; + + const baseCookieOptions = { + httpOnly: true, + sameSite: "lax" as const, + secure: false, // test environment + path: "/", + maxAge: accessTtlMinutes * 60, + }; + + const refreshCookieOptions = { + ...baseCookieOptions, + maxAge: refreshTtlDays * 24 * 60 * 60, + }; + + app.decorate("config", { + accessSecret: "test-jwt-secret", + refreshSecret: "test-refresh-secret", + accessTtl: accessTtlMinutes, + refreshTtl: refreshTtlDays, + cookieOptions: baseCookieOptions, + refreshCookieOptions, + }); + + expect((app as any).config.accessTtl).toBe(15); + expect((app as any).config.refreshTtl).toBe(7); + expect((app as any).config.cookieOptions.httpOnly).toBe(true); + expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60); + + await app.close(); + }); + + it("should calculate cookie maxAge correctly", () => { + const accessTtlMinutes = 30; + const refreshTtlDays = 14; + + const accessMaxAge = accessTtlMinutes * 60; + const refreshMaxAge = refreshTtlDays * 24 * 60 * 60; + + expect(accessMaxAge).toBe(1800); // 30 minutes in seconds + expect(refreshMaxAge).toBe(1209600); // 14 days in seconds + }); + }); + + describe("CORS Origins Parsing", () => { + it("should parse comma-separated origins", () => { + const originsEnv = "http://localhost:5173,http://localhost:4173"; + const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean); + + expect(origins).toHaveLength(2); + expect(origins[0]).toBe("http://localhost:5173"); + expect(origins[1]).toBe("http://localhost:4173"); + }); + + it("should handle single origin", () => { + const originsEnv = "https://myapp.example.com"; + const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean); + + expect(origins).toHaveLength(1); + expect(origins[0]).toBe("https://myapp.example.com"); + }); + + it("should filter out empty strings", () => { + const originsEnv = "http://localhost:5173,,http://localhost:4173,"; + const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean); + + expect(origins).toHaveLength(2); + }); + + it("should trim whitespace", () => { + const originsEnv = " http://localhost:5173 , http://localhost:4173 "; + const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean); + + expect(origins).toEqual(["http://localhost:5173", "http://localhost:4173"]); + }); + }); + + describe("Route Registration", () => { + it("should register multiple route plugins", async () => { + const app = Fastify({ logger: false }); + + // Mock route plugins + const healthRoutes = async (app: any) => { + app.get("/health", async () => ({ status: "ok" })); + }; + + const authRoutes = async (app: any) => { + app.post("/auth/login", async () => ({ token: "mock" })); + }; + + const medicationRoutes = async (app: any) => { + app.get("/medications", async () => []); + }; + + await app.register(healthRoutes); + await app.register(authRoutes); + await app.register(medicationRoutes); + + await app.ready(); + + // Verify routes are registered + const routes = app.printRoutes(); + expect(routes).toContain("health"); + expect(routes).toContain("auth/login"); + expect(routes).toContain("medications"); + + await app.close(); + }); + }); + + describe("Server Startup", () => { + it("should listen on specified port", async () => { + const app = Fastify({ logger: false }); + + app.get("/test", async () => ({ ok: true })); + + // Use port 0 to get a random available port + const address = await app.listen({ port: 0, host: "127.0.0.1" }); + + expect(address).toContain("127.0.0.1"); + + await app.close(); + }); + + it("should handle listen errors gracefully", async () => { + const app = Fastify({ logger: false }); + + // Try to listen on an invalid port + await expect( + app.listen({ port: -1, host: "127.0.0.1" }) + ).rejects.toThrow(); + + await app.close(); + }); + }); + + describe("Images Directory", () => { + it("should construct images directory path correctly", () => { + const resolve = (base: string, ...paths: string[]) => { + return [base, ...paths].join("/").replace(/\/+/g, "/"); + }; + + const cwd = "/app"; + const imagesDir = resolve(cwd, "data/images"); + + expect(imagesDir).toBe("/app/data/images"); + }); + }); +}); + +describe("Cookie Options", () => { + describe("Production vs Development", () => { + it("should set secure=true in production", () => { + const isProduction = true; + + const cookieOptions = { + httpOnly: true, + sameSite: "lax" as const, + secure: isProduction, + path: "/", + }; + + expect(cookieOptions.secure).toBe(true); + }); + + it("should set secure=false in development", () => { + const isProduction = false; + + const cookieOptions = { + httpOnly: true, + sameSite: "lax" as const, + secure: isProduction, + path: "/", + }; + + expect(cookieOptions.secure).toBe(false); + }); + }); +}); + +describe("Rate Limiting", () => { + it("should configure rate limit settings", () => { + const rateLimitConfig = { + max: 100, + timeWindow: "1 minute", + }; + + expect(rateLimitConfig.max).toBe(100); + expect(rateLimitConfig.timeWindow).toBe("1 minute"); + }); +}); + +describe("JWT Configuration", () => { + it("should configure JWT with auth enabled", () => { + const authEnabled = true; + const jwtSecret = "my-super-secret-jwt-key"; + + const jwtConfig = { + secret: authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed", + cookie: { cookieName: "access_token", signed: false }, + }; + + expect(jwtConfig.secret).toBe(jwtSecret); + expect(jwtConfig.cookie.cookieName).toBe("access_token"); + expect(jwtConfig.cookie.signed).toBe(false); + }); + + it("should use dummy secret with auth disabled", () => { + const authEnabled = false; + const jwtSecret = undefined; + + const jwtConfig = { + secret: authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed", + cookie: { cookieName: "access_token", signed: false }, + }; + + expect(jwtConfig.secret).toBe("auth-disabled-no-secret-needed"); + }); +}); + +describe("Multipart Configuration", () => { + it("should set file size limit to 10MB", () => { + const fileSizeLimit = 10 * 1024 * 1024; + + expect(fileSizeLimit).toBe(10485760); + }); +}); diff --git a/backend/src/test/services.test.ts b/backend/src/test/services.test.ts new file mode 100644 index 0000000..09174e8 --- /dev/null +++ b/backend/src/test/services.test.ts @@ -0,0 +1,500 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs"; +import { resolve } from "path"; +import { tmpdir } from "os"; + +// Import actual utility functions from scheduler-utils +import { + getTimezone, + formatInTimezone, + getCurrentHourInTimezone, + getTodayInTimezone, + getNextScheduledTime, + getMsUntilNextCheck, + parseBlisters, + parseTakenByJson, + calculateDailyUsage, + calculateDepletionInfo, + getUpcomingIntakes, + createDefaultReminderState, + createDefaultIntakeReminderState, + parseReminderState, + parseIntakeReminderState, + cleanOldIntakeReminders, + type Blister, + type ReminderState, + type IntakeReminderState, + type UpcomingIntake, +} from "../utils/scheduler-utils.js"; + +describe("Scheduler Utils - Timezone Functions", () => { + let originalTz: string | undefined; + + beforeEach(() => { + originalTz = process.env.TZ; + }); + + afterEach(() => { + if (originalTz !== undefined) { + process.env.TZ = originalTz; + } else { + delete process.env.TZ; + } + }); + + describe("getTimezone", () => { + it("should return TZ env variable when set", () => { + process.env.TZ = "America/New_York"; + expect(getTimezone()).toBe("America/New_York"); + }); + + it("should return UTC when TZ not set", () => { + delete process.env.TZ; + expect(getTimezone()).toBe("UTC"); + }); + + it("should handle Europe/Berlin timezone", () => { + process.env.TZ = "Europe/Berlin"; + expect(getTimezone()).toBe("Europe/Berlin"); + }); + }); + + describe("formatInTimezone", () => { + it("should format date in given timezone", () => { + const date = new Date("2025-12-30T12:00:00.000Z"); + const formatted = formatInTimezone(date, "UTC"); + expect(formatted).toContain("30"); + expect(formatted).toContain("12"); + }); + + it("should use process.env.TZ when no tz provided", () => { + process.env.TZ = "UTC"; + const date = new Date("2025-12-30T15:30:00.000Z"); + const formatted = formatInTimezone(date); + expect(formatted).toContain("15:30"); + }); + }); + + describe("getCurrentHourInTimezone", () => { + it("should return a valid hour (0-23)", () => { + process.env.TZ = "UTC"; + const hour = getCurrentHourInTimezone(); + expect(hour).toBeGreaterThanOrEqual(0); + expect(hour).toBeLessThanOrEqual(23); + }); + + it("should respect timezone parameter", () => { + const hourUtc = getCurrentHourInTimezone("UTC"); + expect(hourUtc).toBeGreaterThanOrEqual(0); + expect(hourUtc).toBeLessThanOrEqual(23); + }); + }); + + describe("getTodayInTimezone", () => { + it("should return date in YYYY-MM-DD format", () => { + process.env.TZ = "UTC"; + const today = getTodayInTimezone(); + expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("should return a valid date", () => { + process.env.TZ = "UTC"; + const today = getTodayInTimezone(); + const date = new Date(today); + expect(date.toString()).not.toBe("Invalid Date"); + }); + + it("should respect timezone parameter", () => { + const today = getTodayInTimezone("UTC"); + expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + }); + + describe("getNextScheduledTime", () => { + it("should return a Date object", () => { + const next = getNextScheduledTime(6, "UTC"); + expect(next).toBeInstanceOf(Date); + }); + + it("should return a time in the future", () => { + // Use hour 0 to minimize chance of being exactly at that hour + const next = getNextScheduledTime(0, "UTC"); + expect(next.getTime()).toBeGreaterThan(Date.now() - 60 * 60 * 1000); // Within 1 hour of now or future + }); + + it("should schedule for the given hour", () => { + const next = getNextScheduledTime(10, "UTC"); + const hourInUtc = parseInt(next.toLocaleString("en-US", { timeZone: "UTC", hour: "numeric", hour12: false }), 10); + expect(hourInUtc).toBe(10); + }); + }); + + describe("getMsUntilNextCheck", () => { + it("should return a positive number (or very small negative within tolerance)", () => { + const ms = getMsUntilNextCheck(6, "UTC"); + // Could be slightly negative if we're right at the scheduled time + expect(ms).toBeGreaterThan(-60000); + }); + + it("should be less than or equal to 24 hours", () => { + const ms = getMsUntilNextCheck(6, "UTC"); + const maxMs = 24 * 60 * 60 * 1000 + 60000; // 24h + 1min tolerance + expect(ms).toBeLessThanOrEqual(maxMs); + }); + }); +}); + +describe("Scheduler Utils - Blister Parsing", () => { + describe("parseBlisters", () => { + it("should parse valid blister JSON arrays", () => { + const row = { + usageJson: "[1, 2, 0.5]", + everyJson: "[1, 2, 7]", + startJson: '["2025-01-01T08:00", "2025-01-01T20:00", "2025-01-01T12:00"]', + }; + + const blisters = parseBlisters(row); + + expect(blisters).toHaveLength(3); + expect(blisters[0]).toEqual({ usage: 1, every: 1, start: "2025-01-01T08:00" }); + expect(blisters[1]).toEqual({ usage: 2, every: 2, start: "2025-01-01T20:00" }); + expect(blisters[2]).toEqual({ usage: 0.5, every: 7, start: "2025-01-01T12:00" }); + }); + + it("should handle arrays of different lengths (use shortest)", () => { + const row = { + usageJson: "[1, 2]", + everyJson: "[1]", + startJson: '["2025-01-01T08:00", "2025-01-01T20:00", "2025-01-01T12:00"]', + }; + + const blisters = parseBlisters(row); + + expect(blisters).toHaveLength(1); + expect(blisters[0]).toEqual({ usage: 1, every: 1, start: "2025-01-01T08:00" }); + }); + + it("should return empty array for empty JSON arrays", () => { + const row = { + usageJson: "[]", + everyJson: "[]", + startJson: "[]", + }; + + const blisters = parseBlisters(row); + expect(blisters).toHaveLength(0); + }); + + it("should return empty array for invalid JSON", () => { + const row = { + usageJson: "invalid", + everyJson: "[1]", + startJson: '["2025-01-01T08:00"]', + }; + + const blisters = parseBlisters(row); + expect(blisters).toHaveLength(0); + }); + + it("should return empty array for non-array JSON", () => { + const row = { + usageJson: '{"usage": 1}', + everyJson: "[1]", + startJson: '["2025-01-01T08:00"]', + }; + + const blisters = parseBlisters(row); + expect(blisters).toHaveLength(0); + }); + }); + + describe("parseTakenByJson", () => { + it("should return empty array for null input", () => { + expect(parseTakenByJson(null)).toEqual([]); + }); + + it("should return empty array for undefined input", () => { + expect(parseTakenByJson(undefined)).toEqual([]); + }); + + it("should return empty array for empty string", () => { + expect(parseTakenByJson("")).toEqual([]); + }); + + it("should parse valid JSON array of strings", () => { + expect(parseTakenByJson('["Alice", "Bob"]')).toEqual(["Alice", "Bob"]); + }); + + it("should return empty array for empty JSON array", () => { + expect(parseTakenByJson("[]")).toEqual([]); + }); + + it("should filter out non-string values", () => { + expect(parseTakenByJson('[1, "Alice", null, "Bob", true]')).toEqual(["Alice", "Bob"]); + }); + + it("should filter out empty strings", () => { + expect(parseTakenByJson('["Alice", "", "Bob", " "]')).toEqual(["Alice", "Bob"]); + }); + + it("should return empty array for invalid JSON", () => { + expect(parseTakenByJson("invalid json")).toEqual([]); + }); + + it("should return empty array for non-array JSON", () => { + expect(parseTakenByJson('{"name": "Alice"}')).toEqual([]); + expect(parseTakenByJson('"Alice"')).toEqual([]); + expect(parseTakenByJson("123")).toEqual([]); + }); + }); +}); + +describe("Scheduler Utils - Daily Usage Calculation", () => { + describe("calculateDailyUsage", () => { + it("should calculate daily usage for single daily dose", () => { + const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }]; + expect(calculateDailyUsage(blisters)).toBe(1); + }); + + it("should calculate daily usage for twice daily dose", () => { + const blisters: Blister[] = [ + { usage: 1, every: 1, start: "2025-01-01T08:00" }, + { usage: 1, every: 1, start: "2025-01-01T20:00" }, + ]; + expect(calculateDailyUsage(blisters)).toBe(2); + }); + + it("should calculate daily usage for weekly dose", () => { + const blisters: Blister[] = [{ usage: 1, every: 7, start: "2025-01-01T08:00" }]; + expect(calculateDailyUsage(blisters)).toBeCloseTo(1/7, 5); + }); + + it("should calculate daily usage for mixed schedules", () => { + const blisters: Blister[] = [ + { usage: 2, every: 1, start: "2025-01-01T08:00" }, // 2 per day + { usage: 1, every: 2, start: "2025-01-01T20:00" }, // 0.5 per day + ]; + expect(calculateDailyUsage(blisters)).toBe(2.5); + }); + + it("should return 0 for empty blisters", () => { + expect(calculateDailyUsage([])).toBe(0); + }); + + it("should handle fractional usage amounts", () => { + const blisters: Blister[] = [{ usage: 0.5, every: 1, start: "2025-01-01T08:00" }]; + expect(calculateDailyUsage(blisters)).toBe(0.5); + }); + }); +}); + +describe("Scheduler Utils - Depletion Calculation", () => { + describe("calculateDepletionInfo", () => { + it("should calculate days left correctly", () => { + const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }]; + const result = calculateDepletionInfo({ count: 30, blisters }, "en"); + expect(result.daysLeft).toBe(30); + expect(result.depletionDate).toBeTruthy(); + }); + + it("should calculate days left with multiple doses per day", () => { + const blisters: Blister[] = [ + { usage: 1, every: 1, start: "2025-01-01T08:00" }, + { usage: 1, every: 1, start: "2025-01-01T20:00" }, + ]; + const result = calculateDepletionInfo({ count: 30, blisters }, "en"); + expect(result.daysLeft).toBe(15); + }); + + it("should return null when no blisters configured", () => { + const result = calculateDepletionInfo({ count: 30, blisters: [] }, "en"); + expect(result.daysLeft).toBeNull(); + expect(result.depletionDate).toBeNull(); + }); + + it("should return null when usage is zero", () => { + const blisters: Blister[] = [{ usage: 0, every: 1, start: "2025-01-01T08:00" }]; + const result = calculateDepletionInfo({ count: 30, blisters }, "en"); + expect(result.daysLeft).toBeNull(); + }); + + it("should floor the days left", () => { + // 10 pills / 3 per day = 3.33... days -> floors to 3 + const blisters: Blister[] = [{ usage: 3, every: 1, start: "2025-01-01T08:00" }]; + const result = calculateDepletionInfo({ count: 10, blisters }, "en"); + expect(result.daysLeft).toBe(3); + }); + + it("should handle German language", () => { + const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }]; + const result = calculateDepletionInfo({ count: 10, blisters }, "de"); + expect(result.depletionDate).toBeTruthy(); + // German locale should be used + }); + }); +}); + +describe("Scheduler Utils - Upcoming Intakes", () => { + describe("getUpcomingIntakes", () => { + it("should return empty array when no intakes in window", () => { + const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }]; + // Set "now" to a time far from any scheduled intake + const now = new Date("2025-01-01T12:00:00.000Z").getTime(); + + const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now); + expect(result).toEqual([]); + }); + + it("should find intake within reminder window", () => { + // Schedule intake at 08:00, check at 07:45 (15 minutes before) + const blisters: Blister[] = [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }]; + const now = new Date("2025-01-01T07:45:00.000Z").getTime(); + + const result = getUpcomingIntakes("TestMed", blisters, 15, ["Alice"], 500, "en-US", "UTC", now); + + expect(result).toHaveLength(1); + expect(result[0].medName).toBe("TestMed"); + expect(result[0].usage).toBe(2); + expect(result[0].takenBy).toEqual(["Alice"]); + expect(result[0].pillWeightMg).toBe(500); + }); + + it("should skip blisters with zero interval", () => { + const blisters: Blister[] = [{ usage: 1, every: 0, start: "2025-01-01T08:00:00.000Z" }]; + const now = new Date("2025-01-01T07:45:00.000Z").getTime(); + + const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now); + expect(result).toEqual([]); + }); + + it("should handle multiple blisters", () => { + // Two intakes at 08:00 and 08:01 + const blisters: Blister[] = [ + { usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, + { usage: 2, every: 1, start: "2025-01-01T08:01:00.000Z" }, + ]; + const now = new Date("2025-01-01T07:45:00.000Z").getTime(); + + const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now); + + // Both should be found as they're within the window + expect(result.length).toBeGreaterThanOrEqual(1); + }); + }); +}); + +describe("Scheduler Utils - State Management", () => { + describe("createDefaultReminderState", () => { + it("should create default reminder state", () => { + const state = createDefaultReminderState(); + expect(state.lastAutoEmailSent).toBeNull(); + expect(state.lastAutoEmailDate).toBeNull(); + expect(state.notifiedMedications).toEqual([]); + expect(state.nextScheduledCheck).toBeNull(); + expect(state.lastNotificationType).toBeNull(); + expect(state.lastNotificationChannel).toBeNull(); + }); + }); + + describe("createDefaultIntakeReminderState", () => { + it("should create default intake reminder state", () => { + const state = createDefaultIntakeReminderState(); + expect(state.sentReminders).toEqual([]); + }); + }); + + describe("parseReminderState", () => { + it("should parse valid JSON", () => { + const json = JSON.stringify({ + lastAutoEmailSent: "2025-12-30T10:00:00.000Z", + lastAutoEmailDate: "2025-12-30", + notifiedMedications: ["med1", "med2"], + nextScheduledCheck: "2025-12-31T06:00:00.000Z", + lastNotificationType: "stock", + lastNotificationChannel: "email", + }); + + const state = parseReminderState(json); + expect(state.lastAutoEmailSent).toBe("2025-12-30T10:00:00.000Z"); + expect(state.lastAutoEmailDate).toBe("2025-12-30"); + expect(state.notifiedMedications).toEqual(["med1", "med2"]); + expect(state.lastNotificationType).toBe("stock"); + expect(state.lastNotificationChannel).toBe("email"); + }); + + it("should handle partial state with defaults", () => { + const json = JSON.stringify({ lastAutoEmailSent: "2025-12-30T10:00:00.000Z" }); + + const state = parseReminderState(json); + expect(state.lastAutoEmailSent).toBe("2025-12-30T10:00:00.000Z"); + expect(state.lastAutoEmailDate).toBeNull(); + expect(state.notifiedMedications).toEqual([]); + }); + + it("should return defaults for invalid JSON", () => { + const state = parseReminderState("invalid json {{{"); + expect(state.lastAutoEmailSent).toBeNull(); + expect(state.notifiedMedications).toEqual([]); + }); + }); + + describe("parseIntakeReminderState", () => { + it("should parse valid JSON", () => { + const json = JSON.stringify({ sentReminders: ["med1:123", "med2:456"] }); + + const state = parseIntakeReminderState(json); + expect(state.sentReminders).toEqual(["med1:123", "med2:456"]); + }); + + it("should return defaults for invalid JSON", () => { + const state = parseIntakeReminderState("invalid"); + expect(state.sentReminders).toEqual([]); + }); + + it("should handle missing sentReminders", () => { + const state = parseIntakeReminderState("{}"); + expect(state.sentReminders).toEqual([]); + }); + }); + + describe("cleanOldIntakeReminders", () => { + it("should remove entries older than maxAgeMs", () => { + const now = Date.now(); + const oldTimestamp = now - 25 * 60 * 60 * 1000; // 25 hours ago + const recentTimestamp = now - 1 * 60 * 60 * 1000; // 1 hour ago + + const reminders = [ + `med1:${oldTimestamp}`, + `med2:${recentTimestamp}`, + ]; + + const cleaned = cleanOldIntakeReminders(reminders, 24 * 60 * 60 * 1000); + + expect(cleaned).toHaveLength(1); + expect(cleaned[0]).toContain("med2"); + }); + + it("should keep all entries if none are old", () => { + const now = Date.now(); + const reminders = [ + `med1:${now - 1000}`, + `med2:${now - 2000}`, + ]; + + const cleaned = cleanOldIntakeReminders(reminders); + expect(cleaned).toHaveLength(2); + }); + + it("should handle empty array", () => { + const cleaned = cleanOldIntakeReminders([]); + expect(cleaned).toEqual([]); + }); + + it("should handle malformed entries (invalid timestamp)", () => { + const reminders = ["med1:invalid", "med2:notanumber"]; + const cleaned = cleanOldIntakeReminders(reminders); + // NaN from parseInt will cause these to be filtered out (0 < cutoff) + expect(cleaned).toEqual([]); + }); + }); +}); diff --git a/backend/src/test/settings.test.ts b/backend/src/test/settings.test.ts new file mode 100644 index 0000000..17fcb7b --- /dev/null +++ b/backend/src/test/settings.test.ts @@ -0,0 +1,510 @@ +/** + * Tests for /settings API endpoints. + * Tests user settings CRUD operations. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + buildTestApp, + closeTestApp, + clearTestData, + createTestUser, + setUserSettings, + TestContext, +} from "./setup.js"; + +// ============================================================================= +// Route Registration +// ============================================================================= + +async function registerSettingsRoutes(ctx: TestContext) { + const { app, client } = ctx; + + // GET /settings - Get user settings + app.get("/settings", async (request, reply) => { + const userId = 1; + + const result = await client.execute({ + sql: `SELECT * FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + + if (result.rows.length === 0) { + // Return defaults + return { + emailEnabled: false, + notificationEmail: "", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrEnabled: false, + shoutrrrUrl: "", + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + reminderDaysBefore: 7, + repeatDailyReminders: false, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + expiryWarningDays: 90, + language: "en", + stockCalculationMode: "automatic", + }; + } + + const s = result.rows[0]; + return { + emailEnabled: Boolean(s.email_enabled), + notificationEmail: s.notification_email || "", + emailStockReminders: Boolean(s.email_stock_reminders), + emailIntakeReminders: Boolean(s.email_intake_reminders), + shoutrrrEnabled: Boolean(s.shoutrrr_enabled), + shoutrrrUrl: s.shoutrrr_url || "", + shoutrrrStockReminders: Boolean(s.shoutrrr_stock_reminders), + shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders), + reminderDaysBefore: s.reminder_days_before, + repeatDailyReminders: Boolean(s.repeat_daily_reminders), + lowStockDays: s.low_stock_days, + normalStockDays: s.normal_stock_days, + highStockDays: s.high_stock_days, + expiryWarningDays: s.expiry_warning_days, + language: s.language, + stockCalculationMode: s.stock_calculation_mode, + }; + }); + + // PUT /settings - Update user settings + app.put<{ + Body: { + emailEnabled?: boolean; + notificationEmail?: string; + emailStockReminders?: boolean; + emailIntakeReminders?: boolean; + shoutrrrEnabled?: boolean; + shoutrrrUrl?: string; + shoutrrrStockReminders?: boolean; + shoutrrrIntakeReminders?: boolean; + reminderDaysBefore?: number; + repeatDailyReminders?: boolean; + lowStockDays?: number; + normalStockDays?: number; + highStockDays?: number; + expiryWarningDays?: number; + language?: string; + stockCalculationMode?: "automatic" | "manual"; + }; + }>("/settings", async (request, reply) => { + const userId = 1; + const body = request.body || {}; + + // Validation + if (body.emailEnabled && !body.notificationEmail) { + return reply.status(400).send({ error: "Email address required when email is enabled" }); + } + if (body.notificationEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.notificationEmail)) { + return reply.status(400).send({ error: "Invalid email address" }); + } + if (body.lowStockDays !== undefined && (body.lowStockDays < 1 || body.lowStockDays > 365)) { + return reply.status(400).send({ error: "lowStockDays must be between 1 and 365" }); + } + if (body.language && !["en", "de"].includes(body.language)) { + return reply.status(400).send({ error: "Language must be 'en' or 'de'" }); + } + if (body.stockCalculationMode && !["automatic", "manual"].includes(body.stockCalculationMode)) { + return reply.status(400).send({ error: "stockCalculationMode must be 'automatic' or 'manual'" }); + } + + // Check if settings exist + const existing = await client.execute({ + sql: `SELECT id FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + + if (existing.rows.length === 0) { + // Insert new settings + await client.execute({ + sql: `INSERT INTO user_settings ( + user_id, email_enabled, notification_email, + email_stock_reminders, email_intake_reminders, + shoutrrr_enabled, shoutrrr_url, + shoutrrr_stock_reminders, shoutrrr_intake_reminders, + reminder_days_before, repeat_daily_reminders, + low_stock_days, normal_stock_days, high_stock_days, + expiry_warning_days, language, stock_calculation_mode + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + userId, + body.emailEnabled ? 1 : 0, + body.notificationEmail || null, + body.emailStockReminders !== false ? 1 : 0, + body.emailIntakeReminders !== false ? 1 : 0, + body.shoutrrrEnabled ? 1 : 0, + body.shoutrrrUrl || null, + body.shoutrrrStockReminders !== false ? 1 : 0, + body.shoutrrrIntakeReminders !== false ? 1 : 0, + body.reminderDaysBefore ?? 7, + body.repeatDailyReminders ? 1 : 0, + body.lowStockDays ?? 30, + body.normalStockDays ?? 90, + body.highStockDays ?? 180, + body.expiryWarningDays ?? 90, + body.language || "en", + body.stockCalculationMode || "automatic", + ], + }); + } else { + // Update existing settings + await client.execute({ + sql: `UPDATE user_settings SET + email_enabled = ?, + notification_email = ?, + email_stock_reminders = ?, + email_intake_reminders = ?, + shoutrrr_enabled = ?, + shoutrrr_url = ?, + shoutrrr_stock_reminders = ?, + shoutrrr_intake_reminders = ?, + reminder_days_before = ?, + repeat_daily_reminders = ?, + low_stock_days = ?, + normal_stock_days = ?, + high_stock_days = ?, + expiry_warning_days = ?, + language = ?, + stock_calculation_mode = ?, + updated_at = strftime('%s','now') + WHERE user_id = ?`, + args: [ + body.emailEnabled ? 1 : 0, + body.notificationEmail || null, + body.emailStockReminders !== false ? 1 : 0, + body.emailIntakeReminders !== false ? 1 : 0, + body.shoutrrrEnabled ? 1 : 0, + body.shoutrrrUrl || null, + body.shoutrrrStockReminders !== false ? 1 : 0, + body.shoutrrrIntakeReminders !== false ? 1 : 0, + body.reminderDaysBefore ?? 7, + body.repeatDailyReminders ? 1 : 0, + body.lowStockDays ?? 30, + body.normalStockDays ?? 90, + body.highStockDays ?? 180, + body.expiryWarningDays ?? 90, + body.language || "en", + body.stockCalculationMode || "automatic", + userId, + ], + }); + } + + return { success: true }; + }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("Settings API", () => { + let ctx: TestContext; + let userId: number; + + beforeAll(async () => { + ctx = await buildTestApp(); + await registerSettingsRoutes(ctx); + await ctx.app.ready(); + }); + + afterAll(async () => { + await closeTestApp(ctx); + }); + + beforeEach(async () => { + await clearTestData(ctx.client); + await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'"); + userId = await createTestUser(ctx.client, { username: "testuser" }); + }); + + // --------------------------------------------------------------------------- + // GET /settings + // --------------------------------------------------------------------------- + + describe("GET /settings", () => { + it("should return default settings for new user", async () => { + const response = await ctx.app.inject({ + method: "GET", + url: "/settings", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.emailEnabled).toBe(false); + expect(data.lowStockDays).toBe(30); + expect(data.normalStockDays).toBe(90); + expect(data.highStockDays).toBe(180); + expect(data.language).toBe("en"); + expect(data.stockCalculationMode).toBe("automatic"); + }); + + it("should return saved settings", async () => { + // Create settings first + await setUserSettings(ctx.client, { + userId, + stockCalculationMode: "manual", + lowStockDays: 14, + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/settings", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.stockCalculationMode).toBe("manual"); + expect(data.lowStockDays).toBe(14); + }); + }); + + // --------------------------------------------------------------------------- + // PUT /settings + // --------------------------------------------------------------------------- + + describe("PUT /settings", () => { + it("should create settings for new user", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + language: "de", + lowStockDays: 14, + stockCalculationMode: "manual", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify + const result = await ctx.client.execute({ + sql: `SELECT language, low_stock_days, stock_calculation_mode FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + expect(result.rows[0].language).toBe("de"); + expect(result.rows[0].low_stock_days).toBe(14); + expect(result.rows[0].stock_calculation_mode).toBe("manual"); + }); + + it("should update existing settings", async () => { + // Create initial settings + await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { language: "en" }, + }); + + // Update + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { language: "de" }, + }); + + expect(response.statusCode).toBe(200); + + // Verify + const result = await ctx.client.execute({ + sql: `SELECT language FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + expect(result.rows[0].language).toBe("de"); + }); + + it("should enable email notifications", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: true, + notificationEmail: "test@example.com", + emailStockReminders: true, + emailIntakeReminders: false, + }, + }); + + expect(response.statusCode).toBe(200); + + // Verify + const result = await ctx.client.execute({ + sql: `SELECT email_enabled, notification_email, email_stock_reminders, email_intake_reminders + FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + expect(result.rows[0].email_enabled).toBe(1); + expect(result.rows[0].notification_email).toBe("test@example.com"); + expect(result.rows[0].email_stock_reminders).toBe(1); + expect(result.rows[0].email_intake_reminders).toBe(0); + }); + + it("should reject email enabled without email address", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: true, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("Email address required when email is enabled"); + }); + + it("should reject invalid email address", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + notificationEmail: "not-an-email", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("Invalid email address"); + }); + + it("should reject invalid lowStockDays", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + lowStockDays: 0, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("lowStockDays must be between 1 and 365"); + }); + + it("should reject invalid language", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + language: "fr", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("Language must be 'en' or 'de'"); + }); + + it("should reject invalid stockCalculationMode", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + stockCalculationMode: "invalid", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("stockCalculationMode must be 'automatic' or 'manual'"); + }); + + it("should enable shoutrrr notifications", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://ntfy.sh/mytopic", + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + }, + }); + + expect(response.statusCode).toBe(200); + + // Verify + const result = await ctx.client.execute({ + sql: `SELECT shoutrrr_enabled, shoutrrr_url FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + expect(result.rows[0].shoutrrr_enabled).toBe(1); + expect(result.rows[0].shoutrrr_url).toBe("ntfy://ntfy.sh/mytopic"); + }); + + it("should update threshold settings", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + lowStockDays: 14, + normalStockDays: 60, + highStockDays: 120, + expiryWarningDays: 30, + }, + }); + + expect(response.statusCode).toBe(200); + + // Verify + const result = await ctx.client.execute({ + sql: `SELECT low_stock_days, normal_stock_days, high_stock_days, expiry_warning_days + FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + expect(result.rows[0].low_stock_days).toBe(14); + expect(result.rows[0].normal_stock_days).toBe(60); + expect(result.rows[0].high_stock_days).toBe(120); + expect(result.rows[0].expiry_warning_days).toBe(30); + }); + }); + + // --------------------------------------------------------------------------- + // Stock Calculation Mode + // --------------------------------------------------------------------------- + + describe("Stock Calculation Mode", () => { + it("should switch to manual mode", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + stockCalculationMode: "manual", + }, + }); + + expect(response.statusCode).toBe(200); + + const getResponse = await ctx.app.inject({ + method: "GET", + url: "/settings", + }); + + expect(getResponse.json().stockCalculationMode).toBe("manual"); + }); + + it("should switch back to automatic mode", async () => { + // Set to manual first + await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { stockCalculationMode: "manual" }, + }); + + // Switch back + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { stockCalculationMode: "automatic" }, + }); + + expect(response.statusCode).toBe(200); + + const getResponse = await ctx.app.inject({ + method: "GET", + url: "/settings", + }); + + expect(getResponse.json().stockCalculationMode).toBe("automatic"); + }); + }); +}); diff --git a/backend/src/test/setup.ts b/backend/src/test/setup.ts new file mode 100644 index 0000000..70606d9 --- /dev/null +++ b/backend/src/test/setup.ts @@ -0,0 +1,376 @@ +/** + * Test setup and utilities for MedAssist backend API tests. + * Uses in-memory SQLite for isolation between test files. + */ +import Fastify, { FastifyInstance } from "fastify"; +import cookie from "@fastify/cookie"; +import jwt from "@fastify/jwt"; +import sensible from "@fastify/sensible"; +import fastifyMultipart from "@fastify/multipart"; +import { createClient, Client } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { beforeAll, afterAll, beforeEach } from "vitest"; + +// Type for our test database +export type TestDb = ReturnType; + +// ============================================================================= +// Test App Builder +// ============================================================================= +export interface TestContext { + app: FastifyInstance; + db: TestDb; + client: Client; +} + +/** + * Build a test Fastify app with in-memory SQLite. + * Each test file gets its own isolated database. + */ +export async function buildTestApp(): Promise { + // Create in-memory SQLite database + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + + // Run schema creation + await runTestMigrations(client); + + // Create Fastify app with minimal plugins + const app = Fastify({ logger: false }); + + await app.register(sensible); + await app.register(cookie, { secret: "test-cookie-secret" }); + await app.register(jwt, { + secret: "test-jwt-secret", + cookie: { cookieName: "access_token", signed: false }, + }); + await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); + + // Decorate config (matches index.ts structure) + app.decorate("config", { + accessSecret: "test-jwt-secret", + refreshSecret: "test-refresh-secret", + accessTtl: 15, + refreshTtl: 7, + cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" }, + refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" }, + }); + + return { app, db, client }; +} + +/** + * Create test database schema + */ +async function runTestMigrations(client: Client): Promise { + const tableCreations = [ + `CREATE TABLE IF NOT EXISTS users ( + id integer PRIMARY KEY AUTOINCREMENT, + username text NOT NULL UNIQUE, + password_hash text, + avatar_url text, + auth_provider text NOT NULL DEFAULT 'local', + oidc_subject text, + is_active integer NOT NULL DEFAULT 1, + last_login_at integer, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + updated_at integer NOT NULL DEFAULT (strftime('%s','now')) + )`, + `CREATE TABLE IF NOT EXISTS medications ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + name text NOT NULL, + generic_name text, + taken_by_json text NOT NULL DEFAULT '[]', + pack_count integer NOT NULL DEFAULT 1, + blisters_per_pack integer NOT NULL DEFAULT 1, + pills_per_blister integer NOT NULL DEFAULT 1, + loose_tablets integer NOT NULL DEFAULT 0, + pill_weight_mg integer, + usage_json text NOT NULL DEFAULT '[]', + every_json text NOT NULL DEFAULT '[]', + start_json text NOT NULL DEFAULT '[]', + image_url text, + expiry_date text, + notes text, + intake_reminders_enabled integer NOT NULL DEFAULT 0, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS user_settings ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL UNIQUE, + email_enabled integer NOT NULL DEFAULT 0, + notification_email text, + email_stock_reminders integer NOT NULL DEFAULT 1, + email_intake_reminders integer NOT NULL DEFAULT 1, + shoutrrr_enabled integer NOT NULL DEFAULT 0, + shoutrrr_url text, + shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, + shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, + reminder_days_before integer NOT NULL DEFAULT 7, + repeat_daily_reminders integer NOT NULL DEFAULT 0, + low_stock_days integer NOT NULL DEFAULT 30, + normal_stock_days integer NOT NULL DEFAULT 90, + high_stock_days integer NOT NULL DEFAULT 180, + expiry_warning_days integer NOT NULL DEFAULT 90, + language text NOT NULL DEFAULT 'en', + stock_calculation_mode text NOT NULL DEFAULT 'automatic', + last_auto_email_sent text, + last_notification_type text, + last_notification_channel text, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS refresh_tokens ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + token_id text NOT NULL UNIQUE, + expires_at integer NOT NULL, + rotated_at integer, + revoked integer NOT NULL DEFAULT 0, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS share_tokens ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + token text NOT NULL UNIQUE, + taken_by text NOT NULL, + schedule_days integer NOT NULL DEFAULT 30, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + expires_at integer, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS dose_tracking ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + dose_id text NOT NULL, + taken_at integer NOT NULL DEFAULT (strftime('%s','now')), + marked_by text, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + ]; + + for (const sql of tableCreations) { + await client.execute(sql); + } +} + +// ============================================================================= +// Factory Helpers +// ============================================================================= + +export interface CreateUserOptions { + username?: string; + authProvider?: string; +} + +/** + * Create a test user and return the ID + */ +export async function createTestUser( + client: Client, + options: CreateUserOptions = {} +): Promise { + const { username = `user_${Date.now()}`, authProvider = "local" } = options; + + const result = await client.execute({ + sql: `INSERT INTO users (username, auth_provider) VALUES (?, ?) RETURNING id`, + args: [username, authProvider], + }); + + return result.rows[0].id as number; +} + +export interface CreateMedicationOptions { + userId: number; + name?: string; + genericName?: string; + takenBy?: string[]; + packCount?: number; + blistersPerPack?: number; + pillsPerBlister?: number; + looseTablets?: number; + pillWeightMg?: number; + /** Array of { usage, every, start } for each blister schedule */ + blisters?: Array<{ usage: number; every: number; start: string }>; +} + +/** + * Create a test medication and return the ID + */ +export async function createTestMedication( + client: Client, + options: CreateMedicationOptions +): Promise { + const { + userId, + name = "Test Medication", + genericName = null, + takenBy = [], + packCount = 1, + blistersPerPack = 1, + pillsPerBlister = 10, + looseTablets = 0, + pillWeightMg = null, + blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }], + } = options; + + // Extract arrays from blisters + const usageJson = JSON.stringify(blisters.map((b) => b.usage)); + const everyJson = JSON.stringify(blisters.map((b) => b.every)); + const startJson = JSON.stringify(blisters.map((b) => b.start)); + const takenByJson = JSON.stringify(takenBy); + + const result = await client.execute({ + sql: `INSERT INTO medications ( + user_id, name, generic_name, taken_by_json, + pack_count, blisters_per_pack, pills_per_blister, loose_tablets, + pill_weight_mg, usage_json, every_json, start_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + args: [ + userId, + name, + genericName, + takenByJson, + packCount, + blistersPerPack, + pillsPerBlister, + looseTablets, + pillWeightMg, + usageJson, + everyJson, + startJson, + ], + }); + + return result.rows[0].id as number; +} + +export interface CreateShareTokenOptions { + userId: number; + takenBy: string; + token?: string; + scheduleDays?: number; + expiresAt?: number | null; +} + +/** + * Create a test share token and return the token string + */ +export async function createTestShareToken( + client: Client, + options: CreateShareTokenOptions +): Promise { + const { + userId, + takenBy, + token = `test_token_${Date.now()}`, + scheduleDays = 30, + expiresAt = null, + } = options; + + await client.execute({ + sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) + VALUES (?, ?, ?, ?, ?)`, + args: [userId, token, takenBy, scheduleDays, expiresAt], + }); + + return token; +} + +export interface CreateDoseTrackingOptions { + userId: number; + doseId: string; + markedBy?: string | null; + takenAt?: number; +} + +/** + * Create a dose tracking record + */ +export async function createTestDoseTracking( + client: Client, + options: CreateDoseTrackingOptions +): Promise { + const { + userId, + doseId, + markedBy = null, + takenAt = Math.floor(Date.now() / 1000), + } = options; + + await client.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, taken_at) + VALUES (?, ?, ?, ?)`, + args: [userId, doseId, markedBy, takenAt], + }); +} + +export interface UpdateUserSettingsOptions { + userId: number; + stockCalculationMode?: "automatic" | "manual"; + lowStockDays?: number; +} + +/** + * Create or update user settings + */ +export async function setUserSettings( + client: Client, + options: UpdateUserSettingsOptions +): Promise { + const { userId, stockCalculationMode = "automatic", lowStockDays = 30 } = options; + + // Check if settings exist + const existing = await client.execute({ + sql: `SELECT id FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + + if (existing.rows.length > 0) { + await client.execute({ + sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ? WHERE user_id = ?`, + args: [stockCalculationMode, lowStockDays, userId], + }); + } else { + await client.execute({ + sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days) VALUES (?, ?, ?)`, + args: [userId, stockCalculationMode, lowStockDays], + }); + } +} + +// ============================================================================= +// Test Cleanup +// ============================================================================= + +/** + * Close test app and database connections + */ +export async function closeTestApp(ctx: TestContext): Promise { + await ctx.app.close(); + ctx.client.close(); +} + +/** + * Clear all data from test database (between tests) + */ +export async function clearTestData(client: Client): Promise { + // Order matters due to foreign keys + await client.execute("DELETE FROM dose_tracking"); + await client.execute("DELETE FROM share_tokens"); + await client.execute("DELETE FROM refresh_tokens"); + await client.execute("DELETE FROM user_settings"); + await client.execute("DELETE FROM medications"); + await client.execute("DELETE FROM users"); +} + +// ============================================================================= +// Vitest Global Setup +// ============================================================================= + +// Set test environment +process.env.AUTH_ENABLED = "false"; +process.env.NODE_ENV = "test"; diff --git a/backend/src/test/share.test.ts b/backend/src/test/share.test.ts new file mode 100644 index 0000000..f466042 --- /dev/null +++ b/backend/src/test/share.test.ts @@ -0,0 +1,647 @@ +/** + * Tests for share link API endpoints. + * Tests creating share tokens, accessing shared schedules, and marking doses via share links. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + buildTestApp, + closeTestApp, + clearTestData, + createTestUser, + createTestMedication, + createTestShareToken, + TestContext, +} from "./setup.js"; + +// ============================================================================= +// Route Registration +// ============================================================================= + +async function registerShareRoutes(ctx: TestContext) { + const { app, client } = ctx; + + // POST /share - Create a share token + app.post<{ Body: { takenBy: string; scheduleDays?: number } }>("/share", async (request, reply) => { + const userId = 1; + const { takenBy, scheduleDays = 30 } = request.body || {}; + + if (!takenBy || typeof takenBy !== "string" || takenBy.length === 0) { + return reply.status(400).send({ error: "takenBy is required", code: "VALIDATION_ERROR" }); + } + + if (scheduleDays < 1 || scheduleDays > 365) { + return reply.status(400).send({ error: "scheduleDays must be 1-365", code: "VALIDATION_ERROR" }); + } + + // Check if user has medications for this person + const meds = await client.execute({ + sql: `SELECT id, taken_by_json FROM medications WHERE user_id = ?`, + args: [userId], + }); + + const hasMatchingMed = meds.rows.some((m) => { + const takenByList: string[] = JSON.parse(m.taken_by_json as string || "[]"); + return takenByList.includes(takenBy); + }); + + if (!hasMatchingMed) { + return reply.status(400).send({ error: "No medications found for this person", code: "NO_MEDICATIONS" }); + } + + // Generate token + const token = `share_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + const expiresAt = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; // 30 days + + await client.execute({ + sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) + VALUES (?, ?, ?, ?, ?)`, + args: [userId, token, takenBy, scheduleDays, expiresAt], + }); + + return { + token, + shareUrl: `/share/${token}`, + expiresAt: new Date(expiresAt * 1000).toISOString(), + }; + }); + + // GET /share/:token - Get shared schedule data + app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => { + const { token } = request.params; + + const shareResult = await client.execute({ + sql: `SELECT st.*, u.username as owner_username + FROM share_tokens st + JOIN users u ON st.user_id = u.id + WHERE st.token = ?`, + args: [token], + }); + + if (shareResult.rows.length === 0) { + return reply.status(404).send({ error: "Share link not found", code: "NOT_FOUND" }); + } + + const share = shareResult.rows[0]; + const now = Math.floor(Date.now() / 1000); + + // Check expiry + if (share.expires_at && (share.expires_at as number) < now) { + return reply.status(410).send({ + error: "Share link has expired", + code: "EXPIRED", + ownerUsername: share.owner_username, + takenBy: share.taken_by, + expiredAt: new Date((share.expires_at as number) * 1000).toISOString(), + }); + } + + // Get medications for this person + const medsResult = await client.execute({ + sql: `SELECT * FROM medications WHERE user_id = ?`, + args: [share.user_id], + }); + + const medications = medsResult.rows + .filter((m) => { + const takenByList: string[] = JSON.parse(m.taken_by_json as string || "[]"); + return takenByList.includes(share.taken_by as string); + }) + .map((m) => { + const usageArr: number[] = JSON.parse(m.usage_json as string || "[]"); + const everyArr: number[] = JSON.parse(m.every_json as string || "[]"); + const startArr: string[] = JSON.parse(m.start_json as string || "[]"); + + return { + id: m.id, + name: m.name, + genericName: m.generic_name, + pillWeightMg: m.pill_weight_mg, + imageUrl: m.image_url, + totalPills: + (m.pack_count as number) * + (m.blisters_per_pack as number) * + (m.pills_per_blister as number) + + (m.loose_tablets as number), + packCount: m.pack_count, + blistersPerPack: m.blisters_per_pack, + looseTablets: m.loose_tablets, + pillsPerBlister: m.pills_per_blister, + takenBy: JSON.parse(m.taken_by_json as string || "[]"), + blisters: usageArr.map((usage, i) => ({ + usage, + every: everyArr[i] || 1, + start: startArr[i] || new Date().toISOString(), + })), + }; + }); + + // Get settings + const settingsResult = await client.execute({ + sql: `SELECT low_stock_days FROM user_settings WHERE user_id = ?`, + args: [share.user_id], + }); + + const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30; + + return { + takenBy: share.taken_by, + sharedBy: share.owner_username, + scheduleDays: share.schedule_days, + medications, + stockThresholds: { + lowStockDays, + }, + }; + }); + + // GET /share/:token/doses - Get taken doses for share link + app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => { + const { token } = request.params; + + const shareResult = await client.execute({ + sql: `SELECT user_id FROM share_tokens WHERE token = ?`, + args: [token], + }); + + if (shareResult.rows.length === 0) { + return reply.status(404).send({ error: "Share link not found" }); + } + + const userId = shareResult.rows[0].user_id; + + const dosesResult = await client.execute({ + sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`, + args: [userId], + }); + + return { + doses: dosesResult.rows.map((d) => ({ + doseId: d.dose_id, + takenAt: (d.taken_at as number) * 1000, + markedBy: d.marked_by, + })), + }; + }); + + // POST /share/:token/doses - Mark dose via share link + app.post<{ Params: { token: string }; Body: { doseId: string } }>( + "/share/:token/doses", + async (request, reply) => { + const { token } = request.params; + const { doseId } = request.body || {}; + + if (!doseId) { + return reply.status(400).send({ error: "doseId is required" }); + } + + const shareResult = await client.execute({ + sql: `SELECT user_id, taken_by FROM share_tokens WHERE token = ?`, + args: [token], + }); + + if (shareResult.rows.length === 0) { + return reply.status(404).send({ error: "Share link not found" }); + } + + const { user_id: userId, taken_by: takenBy } = shareResult.rows[0]; + + // Check if already marked + const existing = await client.execute({ + sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + args: [userId, doseId], + }); + + if (existing.rows.length > 0) { + return { success: true, message: "Already marked" }; + } + + // Insert with markedBy = takenBy from share token + await client.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, + args: [userId, doseId, takenBy], + }); + + return { success: true }; + } + ); + + // DELETE /share/:token/doses/:doseId - Unmark dose via share link + app.delete<{ Params: { token: string; doseId: string } }>( + "/share/:token/doses/:doseId", + async (request, reply) => { + const { token, doseId } = request.params; + + const shareResult = await client.execute({ + sql: `SELECT user_id FROM share_tokens WHERE token = ?`, + args: [token], + }); + + if (shareResult.rows.length === 0) { + return reply.status(404).send({ error: "Share link not found" }); + } + + const userId = shareResult.rows[0].user_id; + + await client.execute({ + sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + args: [userId, doseId], + }); + + return { success: true }; + } + ); + + // GET /share/people - Get unique takenBy values + app.get("/share/people", async (request, reply) => { + const userId = 1; + + const result = await client.execute({ + sql: `SELECT taken_by_json FROM medications WHERE user_id = ?`, + args: [userId], + }); + + const peopleSet = new Set(); + for (const row of result.rows) { + const takenByList: string[] = JSON.parse(row.taken_by_json as string || "[]"); + takenByList.forEach((p) => peopleSet.add(p)); + } + + return { people: Array.from(peopleSet).sort() }; + }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("Share Link API", () => { + let ctx: TestContext; + let userId: number; + + beforeAll(async () => { + ctx = await buildTestApp(); + await registerShareRoutes(ctx); + await ctx.app.ready(); + }); + + afterAll(async () => { + await closeTestApp(ctx); + }); + + beforeEach(async () => { + await clearTestData(ctx.client); + // Reset SQLite autoincrement so user gets ID 1 + await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'"); + userId = await createTestUser(ctx.client, { username: "testuser" }); + }); + + // --------------------------------------------------------------------------- + // POST /share - Create share token + // --------------------------------------------------------------------------- + + describe("POST /share", () => { + it("should create a share token for a person", async () => { + // Create medication with takenBy + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + takenBy: ["Daniel"], + }); + + const response = await ctx.app.inject({ + method: "POST", + url: "/share", + payload: { takenBy: "Daniel", scheduleDays: 30 }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.token).toBeDefined(); + expect(data.token.length).toBeGreaterThan(10); + expect(data.shareUrl).toBe(`/share/${data.token}`); + expect(data.expiresAt).toBeDefined(); + }); + + it("should reject when no medications for person", async () => { + // Create medication with different takenBy + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + takenBy: ["Max"], + }); + + const response = await ctx.app.inject({ + method: "POST", + url: "/share", + payload: { takenBy: "Daniel", scheduleDays: 30 }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: "No medications found for this person", + code: "NO_MEDICATIONS", + }); + }); + + it("should reject request without takenBy", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/share", + payload: { scheduleDays: 30 }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: "takenBy is required", + code: "VALIDATION_ERROR", + }); + }); + + it("should use custom scheduleDays", async () => { + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + takenBy: ["Daniel"], + }); + + const response = await ctx.app.inject({ + method: "POST", + url: "/share", + payload: { takenBy: "Daniel", scheduleDays: 90 }, + }); + + expect(response.statusCode).toBe(200); + + // Verify in DB + const token = response.json().token; + const result = await ctx.client.execute({ + sql: `SELECT schedule_days FROM share_tokens WHERE token = ?`, + args: [token], + }); + expect(result.rows[0].schedule_days).toBe(90); + }); + }); + + // --------------------------------------------------------------------------- + // GET /share/:token - Access shared schedule + // --------------------------------------------------------------------------- + + describe("GET /share/:token", () => { + it("should return shared schedule data", async () => { + // Create medication + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + genericName: "Acetylsalicylic acid", + takenBy: ["Daniel"], + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }); + + // Create share token + const token = await createTestShareToken(ctx.client, { + userId, + takenBy: "Daniel", + scheduleDays: 30, + }); + + const response = await ctx.app.inject({ + method: "GET", + url: `/share/${token}`, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + + expect(data.takenBy).toBe("Daniel"); + expect(data.sharedBy).toBe("testuser"); + expect(data.scheduleDays).toBe(30); + expect(data.medications).toHaveLength(1); + + const med = data.medications[0]; + expect(med.name).toBe("Aspirin"); + expect(med.genericName).toBe("Acetylsalicylic acid"); + expect(med.totalPills).toBe(2 * 3 * 10 + 5); // 65 + expect(med.takenBy).toEqual(["Daniel"]); + expect(med.blisters).toHaveLength(1); + expect(med.blisters[0].usage).toBe(1); + expect(med.blisters[0].every).toBe(1); + }); + + it("should return 404 for invalid token", async () => { + const response = await ctx.app.inject({ + method: "GET", + url: "/share/invalid_token_123", + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ + error: "Share link not found", + code: "NOT_FOUND", + }); + }); + + it("should return 410 for expired token", async () => { + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + takenBy: ["Daniel"], + }); + + // Create expired token (expired 1 day ago) + const expiredAt = Math.floor(Date.now() / 1000) - 86400; + const token = await createTestShareToken(ctx.client, { + userId, + takenBy: "Daniel", + expiresAt: expiredAt, + }); + + const response = await ctx.app.inject({ + method: "GET", + url: `/share/${token}`, + }); + + expect(response.statusCode).toBe(410); + const data = response.json(); + expect(data.code).toBe("EXPIRED"); + expect(data.ownerUsername).toBe("testuser"); + expect(data.takenBy).toBe("Daniel"); + }); + + it("should filter medications to only those for takenBy person", async () => { + // Create two medications - one for Daniel, one for Max + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + takenBy: ["Daniel"], + }); + await createTestMedication(ctx.client, { + userId, + name: "Ibuprofen", + takenBy: ["Max"], + }); + + const token = await createTestShareToken(ctx.client, { + userId, + takenBy: "Daniel", + }); + + const response = await ctx.app.inject({ + method: "GET", + url: `/share/${token}`, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.medications).toHaveLength(1); + expect(data.medications[0].name).toBe("Aspirin"); + }); + }); + + // --------------------------------------------------------------------------- + // Share Token Dose Tracking + // --------------------------------------------------------------------------- + + describe("Share link dose tracking", () => { + it("POST /share/:token/doses should mark dose with markedBy", async () => { + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + takenBy: ["Daniel"], + }); + + const token = await createTestShareToken(ctx.client, { + userId, + takenBy: "Daniel", + }); + + const doseId = "1-0-1735344000000"; + const response = await ctx.app.inject({ + method: "POST", + url: `/share/${token}/doses`, + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify markedBy is set to takenBy from share token + const result = await ctx.client.execute({ + sql: `SELECT marked_by FROM dose_tracking WHERE dose_id = ?`, + args: [doseId], + }); + expect(result.rows[0].marked_by).toBe("Daniel"); + }); + + it("GET /share/:token/doses should return all doses for owner", async () => { + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + takenBy: ["Daniel"], + }); + + const token = await createTestShareToken(ctx.client, { + userId, + takenBy: "Daniel", + }); + + // Create some dose tracking records + await ctx.client.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, + args: [userId, "1-0-1735344000000", null], + }); + await ctx.client.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, + args: [userId, "1-0-1735430400000", "Daniel"], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: `/share/${token}/doses`, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.doses).toHaveLength(2); + }); + + it("DELETE /share/:token/doses/:doseId should unmark dose", async () => { + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + takenBy: ["Daniel"], + }); + + const token = await createTestShareToken(ctx.client, { + userId, + takenBy: "Daniel", + }); + + const doseId = "1-0-1735344000000"; + + // Mark dose first + await ctx.app.inject({ + method: "POST", + url: `/share/${token}/doses`, + payload: { doseId }, + }); + + // Unmark + const response = await ctx.app.inject({ + method: "DELETE", + url: `/share/${token}/doses/${encodeURIComponent(doseId)}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Verify deleted + const result = await ctx.client.execute({ + sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`, + args: [doseId], + }); + expect(result.rows[0].count).toBe(0); + }); + }); + + // --------------------------------------------------------------------------- + // GET /share/people + // --------------------------------------------------------------------------- + + describe("GET /share/people", () => { + it("should return unique takenBy values from all medications", async () => { + await createTestMedication(ctx.client, { + userId, + name: "Med 1", + takenBy: ["Daniel", "Max"], + }); + await createTestMedication(ctx.client, { + userId, + name: "Med 2", + takenBy: ["Daniel", "Lisa"], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/share/people", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.people).toEqual(["Daniel", "Lisa", "Max"]); // sorted + }); + + it("should return empty array when no medications", async () => { + const response = await ctx.app.inject({ + method: "GET", + url: "/share/people", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ people: [] }); + }); + }); +}); diff --git a/backend/src/test/stock-calculation.test.ts b/backend/src/test/stock-calculation.test.ts new file mode 100644 index 0000000..fd16de6 --- /dev/null +++ b/backend/src/test/stock-calculation.test.ts @@ -0,0 +1,635 @@ +/** + * Tests for stock calculation modes (automatic vs manual). + * Tests the /medications/usage endpoint with different settings. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + buildTestApp, + closeTestApp, + clearTestData, + createTestUser, + createTestMedication, + createTestDoseTracking, + setUserSettings, + TestContext, +} from "./setup.js"; + +// ============================================================================= +// Route Registration +// ============================================================================= + +async function registerUsageRoutes(ctx: TestContext) { + const { app, client } = ctx; + + // POST /medications/usage - Calculate medication usage for a date range + app.post<{ Body: { startDate: string; endDate: string } }>( + "/medications/usage", + async (request, reply) => { + const userId = 1; + const { startDate, endDate } = request.body || {}; + + if (!startDate || !endDate) { + return reply.status(400).send({ error: "startDate and endDate are required" }); + } + + const start = new Date(startDate); + const end = new Date(endDate); + + // Get user settings + const settingsResult = await client.execute({ + sql: `SELECT stock_calculation_mode FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + const stockMode = + settingsResult.rows.length > 0 + ? (settingsResult.rows[0].stock_calculation_mode as string) + : "automatic"; + + // Get all medications + const medsResult = await client.execute({ + sql: `SELECT * FROM medications WHERE user_id = ?`, + args: [userId], + }); + + const results = []; + + for (const med of medsResult.rows) { + const totalPills = + (med.pack_count as number) * + (med.blisters_per_pack as number) * + (med.pills_per_blister as number) + + (med.loose_tablets as number); + + const blisterSize = med.pills_per_blister as number; + + // Calculate usage based on schedule + const usageArr: number[] = JSON.parse((med.usage_json as string) || "[]"); + const everyArr: number[] = JSON.parse((med.every_json as string) || "[]"); + const startArr: string[] = JSON.parse((med.start_json as string) || "[]"); + + let plannerUsage = 0; + + if (stockMode === "automatic") { + // Automatic: Calculate from schedule + for (let i = 0; i < usageArr.length; i++) { + const usage = usageArr[i] || 0; + const every = everyArr[i] || 1; + const scheduleStart = new Date(startArr[i] || start); + + // Count doses from scheduleStart to end within the range + let current = new Date(scheduleStart); + while (current <= end) { + if (current >= start) { + plannerUsage += usage; + } + current.setDate(current.getDate() + every); + } + } + } else { + // Manual: Count only tracked doses in the date range + const dosesResult = await client.execute({ + sql: `SELECT dose_id FROM dose_tracking + WHERE user_id = ? + AND taken_at >= ? + AND taken_at <= ?`, + args: [ + userId, + Math.floor(start.getTime() / 1000), + Math.floor(end.getTime() / 1000), + ], + }); + + // Filter to doses for this medication + const medIdStr = `${med.id}-`; + for (const dose of dosesResult.rows) { + const doseId = dose.dose_id as string; + if (doseId.startsWith(medIdStr)) { + // Parse usage from the schedule based on blister index + const parts = doseId.split("-"); + if (parts.length >= 3) { + const blisterIdx = parseInt(parts[1], 10); + plannerUsage += usageArr[blisterIdx] || 1; + } + } + } + } + + // Calculate how many blisters/pills needed + const blistersNeeded = Math.ceil(plannerUsage / blisterSize); + const fullBlisters = Math.floor(plannerUsage / blisterSize); + const loosePills = plannerUsage % blisterSize; + + results.push({ + medicationId: med.id, + medicationName: med.name, + totalPills, + plannerUsage, + blisterSize, + blistersNeeded, + fullBlisters, + loosePills, + enough: totalPills >= plannerUsage, + }); + } + + return results; + } + ); + + // GET /medications - List medications (for checking stock) + app.get("/medications", async (request, reply) => { + const userId = 1; + + const result = await client.execute({ + sql: `SELECT * FROM medications WHERE user_id = ?`, + args: [userId], + }); + + return result.rows.map((m) => ({ + id: m.id, + name: m.name, + packCount: m.pack_count, + blistersPerPack: m.blisters_per_pack, + pillsPerBlister: m.pills_per_blister, + looseTablets: m.loose_tablets, + totalPills: + (m.pack_count as number) * + (m.blisters_per_pack as number) * + (m.pills_per_blister as number) + + (m.loose_tablets as number), + })); + }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("Stock Calculation API", () => { + let ctx: TestContext; + let userId: number; + + beforeAll(async () => { + ctx = await buildTestApp(); + await registerUsageRoutes(ctx); + await ctx.app.ready(); + }); + + afterAll(async () => { + await closeTestApp(ctx); + }); + + beforeEach(async () => { + await clearTestData(ctx.client); + // Reset SQLite autoincrement so user gets ID 1 + await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'"); + userId = await createTestUser(ctx.client, { username: "testuser" }); + }); + + // --------------------------------------------------------------------------- + // Automatic Mode Tests + // --------------------------------------------------------------------------- + + describe("Automatic mode", () => { + beforeEach(async () => { + await setUserSettings(ctx.client, { + userId, + stockCalculationMode: "automatic", + }); + }); + + it("should calculate usage from schedule", async () => { + // Medication: 1 pill daily starting Jan 1 + const start = new Date("2025-01-01T00:00:00.000Z"); + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + blisters: [{ usage: 1, every: 1, start: start.toISOString() }], + }); + + // Calculate usage for 10 days (Jan 1-10) + const response = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data).toHaveLength(1); + + const med = data[0]; + expect(med.medicationName).toBe("Aspirin"); + expect(med.totalPills).toBe(30); + expect(med.plannerUsage).toBe(10); // 10 days, 1 pill/day + expect(med.enough).toBe(true); + }); + + it("should handle every-other-day schedules", async () => { + const start = new Date("2025-01-01T00:00:00.000Z"); + await createTestMedication(ctx.client, { + userId, + name: "Med B", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 20, + blisters: [{ usage: 2, every: 2, start: start.toISOString() }], // 2 pills every 2 days + }); + + // 10 days: Jan 1, 3, 5, 7, 9 = 5 doses × 2 pills = 10 pills + const response = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].plannerUsage).toBe(10); + }); + + it("should handle multiple blisters (schedules)", async () => { + const start = new Date("2025-01-01T00:00:00.000Z"); + await createTestMedication(ctx.client, { + userId, + name: "Multi Schedule", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 50, + blisters: [ + { usage: 1, every: 1, start: start.toISOString() }, // Morning: 1/day + { usage: 1, every: 1, start: start.toISOString() }, // Evening: 1/day + ], + }); + + // 10 days: 2 schedules × 10 days × 1 pill = 20 pills + const response = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].plannerUsage).toBe(20); + }); + + it("should return enough=false when stock insufficient", async () => { + const start = new Date("2025-01-01T00:00:00.000Z"); + await createTestMedication(ctx.client, { + userId, + name: "Low Stock Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 5, // Only 5 pills + blisters: [{ usage: 1, every: 1, start: start.toISOString() }], + }); + + // Need 10 pills but only have 5 + const response = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].totalPills).toBe(5); + expect(data[0].plannerUsage).toBe(10); + expect(data[0].enough).toBe(false); + }); + + it("should calculate blister counts correctly", async () => { + const start = new Date("2025-01-01T00:00:00.000Z"); + await createTestMedication(ctx.client, { + userId, + name: "Blister Test", + packCount: 2, + blistersPerPack: 2, + pillsPerBlister: 10, // 4 blisters × 10 = 40 pills + blisters: [{ usage: 1, every: 1, start: start.toISOString() }], + }); + + // 25 days = 25 pills needed = 2 full blisters + 5 loose + const response = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-25T23:59:59.999Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].plannerUsage).toBe(25); + expect(data[0].blisterSize).toBe(10); + expect(data[0].blistersNeeded).toBe(3); // ceil(25/10) + expect(data[0].fullBlisters).toBe(2); // floor(25/10) + expect(data[0].loosePills).toBe(5); // 25 % 10 + }); + }); + + // --------------------------------------------------------------------------- + // Manual Mode Tests + // --------------------------------------------------------------------------- + + describe("Manual mode", () => { + beforeEach(async () => { + await setUserSettings(ctx.client, { + userId, + stockCalculationMode: "manual", + }); + }); + + it("should count only tracked doses", async () => { + const start = new Date("2025-01-01T00:00:00.000Z"); + const medId = await createTestMedication(ctx.client, { + userId, + name: "Manual Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + blisters: [{ usage: 1, every: 1, start: start.toISOString() }], + }); + + // In automatic mode this would count 10 doses + // In manual mode, only count tracked doses + // Track only 3 doses + const jan2 = Math.floor(new Date("2025-01-02T08:00:00.000Z").getTime() / 1000); + const jan5 = Math.floor(new Date("2025-01-05T08:00:00.000Z").getTime() / 1000); + const jan8 = Math.floor(new Date("2025-01-08T08:00:00.000Z").getTime() / 1000); + + await createTestDoseTracking(ctx.client, { + userId, + doseId: `${medId}-0-${jan2 * 1000}`, + takenAt: jan2, + }); + await createTestDoseTracking(ctx.client, { + userId, + doseId: `${medId}-0-${jan5 * 1000}`, + takenAt: jan5, + }); + await createTestDoseTracking(ctx.client, { + userId, + doseId: `${medId}-0-${jan8 * 1000}`, + takenAt: jan8, + }); + + const response = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].plannerUsage).toBe(3); // Only 3 tracked doses + }); + + it("should return 0 usage when no doses tracked", async () => { + const start = new Date("2025-01-01T00:00:00.000Z"); + await createTestMedication(ctx.client, { + userId, + name: "Untracked Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + blisters: [{ usage: 1, every: 1, start: start.toISOString() }], + }); + + // No dose tracking records + const response = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].plannerUsage).toBe(0); + expect(data[0].enough).toBe(true); + }); + + it("should only count doses within date range", async () => { + const start = new Date("2025-01-01T00:00:00.000Z"); + const medId = await createTestMedication(ctx.client, { + userId, + name: "Range Test", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + blisters: [{ usage: 1, every: 1, start: start.toISOString() }], + }); + + // Dose before range (Dec 31) + const dec31 = Math.floor(new Date("2024-12-31T08:00:00.000Z").getTime() / 1000); + await createTestDoseTracking(ctx.client, { + userId, + doseId: `${medId}-0-${dec31 * 1000}`, + takenAt: dec31, + }); + + // Dose in range (Jan 5) + const jan5 = Math.floor(new Date("2025-01-05T08:00:00.000Z").getTime() / 1000); + await createTestDoseTracking(ctx.client, { + userId, + doseId: `${medId}-0-${jan5 * 1000}`, + takenAt: jan5, + }); + + // Dose after range (Jan 15) + const jan15 = Math.floor(new Date("2025-01-15T08:00:00.000Z").getTime() / 1000); + await createTestDoseTracking(ctx.client, { + userId, + doseId: `${medId}-0-${jan15 * 1000}`, + takenAt: jan15, + }); + + const response = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].plannerUsage).toBe(1); // Only Jan 5 is in range + }); + + it("should handle multi-pill doses correctly", async () => { + const start = new Date("2025-01-01T00:00:00.000Z"); + const medId = await createTestMedication(ctx.client, { + userId, + name: "Multi-Pill", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + blisters: [{ usage: 2, every: 1, start: start.toISOString() }], // 2 pills per dose + }); + + const jan2 = Math.floor(new Date("2025-01-02T08:00:00.000Z").getTime() / 1000); + await createTestDoseTracking(ctx.client, { + userId, + doseId: `${medId}-0-${jan2 * 1000}`, // Blister index 0 has usage=2 + takenAt: jan2, + }); + + const response = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[0].plannerUsage).toBe(2); // 1 dose × 2 pills + }); + }); + + // --------------------------------------------------------------------------- + // Mode Comparison Tests + // --------------------------------------------------------------------------- + + describe("Automatic vs Manual mode comparison", () => { + it("should show different results for same medication", async () => { + const start = new Date("2025-01-01T00:00:00.000Z"); + const medId = await createTestMedication(ctx.client, { + userId, + name: "Comparison Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + blisters: [{ usage: 1, every: 1, start: start.toISOString() }], + }); + + // Track only 5 of the 10 scheduled doses + for (let day = 1; day <= 5; day++) { + const date = new Date(`2025-01-0${day}T08:00:00.000Z`); + const ts = Math.floor(date.getTime() / 1000); + await createTestDoseTracking(ctx.client, { + userId, + doseId: `${medId}-0-${ts * 1000}`, + takenAt: ts, + }); + } + + // Test automatic mode + await setUserSettings(ctx.client, { + userId, + stockCalculationMode: "automatic", + }); + + const autoResponse = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(autoResponse.statusCode).toBe(200); + const autoData = autoResponse.json(); + expect(autoData[0].plannerUsage).toBe(10); // Schedule says 10 doses + + // Test manual mode + await setUserSettings(ctx.client, { + userId, + stockCalculationMode: "manual", + }); + + const manualResponse = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(manualResponse.statusCode).toBe(200); + const manualData = manualResponse.json(); + expect(manualData[0].plannerUsage).toBe(5); // Only 5 actually tracked + }); + }); + + // --------------------------------------------------------------------------- + // Multiple Medications Tests + // --------------------------------------------------------------------------- + + describe("Multiple medications", () => { + it("should calculate usage for all medications", async () => { + const start = new Date("2025-01-01T00:00:00.000Z"); + + await createTestMedication(ctx.client, { + userId, + name: "Med A", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 20, + blisters: [{ usage: 1, every: 1, start: start.toISOString() }], + }); + + await createTestMedication(ctx.client, { + userId, + name: "Med B", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 20, + blisters: [{ usage: 2, every: 2, start: start.toISOString() }], + }); + + await setUserSettings(ctx.client, { + userId, + stockCalculationMode: "automatic", + }); + + const response = await ctx.app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-10T23:59:59.999Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data).toHaveLength(2); + + const medA = data.find((d: any) => d.medicationName === "Med A"); + const medB = data.find((d: any) => d.medicationName === "Med B"); + + expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill + expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills + }); + }); +}); diff --git a/backend/src/test/translations.test.ts b/backend/src/test/translations.test.ts new file mode 100644 index 0000000..1c35793 --- /dev/null +++ b/backend/src/test/translations.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for translations module + */ +import { describe, it, expect } from "vitest"; +import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js"; + +describe("Translations Module", () => { + describe("getTranslations", () => { + it("should return English translations for 'en'", () => { + const translations = getTranslations("en"); + expect(translations.stockReminder.title).toContain("MedAssist-ng"); + expect(translations.common.pills).toBe("pills"); + }); + + it("should return German translations for 'de'", () => { + const translations = getTranslations("de"); + expect(translations.stockReminder.title).toContain("MedAssist-ng"); + expect(translations.common.pills).toBe("Tabletten"); + }); + + it("should fallback to English for unknown language", () => { + const translations = getTranslations("fr" as Language); + expect(translations.common.pills).toBe("pills"); + }); + + it("should have all required keys in English", () => { + const translations = getTranslations("en"); + + // Stock reminder keys + expect(translations.stockReminder.subject).toBeDefined(); + expect(translations.stockReminder.title).toBeDefined(); + expect(translations.stockReminder.description).toBeDefined(); + expect(translations.stockReminder.tableHeaders.medication).toBeDefined(); + + // Intake reminder keys + expect(translations.intakeReminder.subject).toBeDefined(); + expect(translations.intakeReminder.title).toBeDefined(); + expect(translations.intakeReminder.pills).toBeDefined(); + expect(translations.intakeReminder.takenBy).toBeDefined(); + + // Push notification keys + expect(translations.push.stockTitle).toBeDefined(); + expect(translations.push.intakeTitle).toBeDefined(); + expect(translations.push.pillsLeft).toBeDefined(); + expect(translations.push.emptySection).toBeDefined(); + expect(translations.push.lowSection).toBeDefined(); + }); + + it("should have all required keys in German", () => { + const translations = getTranslations("de"); + + // Stock reminder keys + expect(translations.stockReminder.subject).toBeDefined(); + expect(translations.stockReminder.title).toBeDefined(); + expect(translations.stockReminder.description).toBeDefined(); + expect(translations.stockReminder.tableHeaders.medication).toBe("Medikament"); + + // Intake reminder keys + expect(translations.intakeReminder.subject).toBeDefined(); + expect(translations.intakeReminder.pills).toBe("Tabletten"); + expect(translations.intakeReminder.takenBy).toBe("für {name}"); + }); + }); + + describe("t (template function)", () => { + it("should replace single placeholder", () => { + const result = t("Hello {name}!", { name: "World" }); + expect(result).toBe("Hello World!"); + }); + + it("should replace multiple placeholders", () => { + const result = t("{count} {type} running low", { count: 3, type: "medications" }); + expect(result).toBe("3 medications running low"); + }); + + it("should replace same placeholder multiple times", () => { + const result = t("{name} and {name} again", { name: "test" }); + expect(result).toBe("test and test again"); + }); + + it("should leave unmatched placeholders", () => { + const result = t("Hello {name}!", {}); + expect(result).toBe("Hello {name}!"); + }); + + it("should handle numeric values", () => { + const result = t("{count} pills left", { count: 42 }); + expect(result).toBe("42 pills left"); + }); + + it("should handle empty params object", () => { + const result = t("No placeholders here", {}); + expect(result).toBe("No placeholders here"); + }); + + it("should work with real translation strings", () => { + const translations = getTranslations("en"); + + // Stock reminder subject + const subject = t(translations.stockReminder.subject, { count: 3, s: "s" }); + expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Low"); + + // Intake reminder description + const description = t(translations.intakeReminder.description, { minutes: 30 }); + expect(description).toBe("Time to take your medication in 30 minutes:"); + + // Push notification + const push = t(translations.push.pillsAt, { count: 2, time: "08:00" }); + expect(push).toBe("2 pills at 08:00"); + }); + + it("should work with German translations", () => { + const translations = getTranslations("de"); + + const subject = t(translations.stockReminder.subject, { count: 2, e: "e" }); + expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente wird knapp"); + + const takenBy = t(translations.intakeReminder.takenBy, { name: "Daniel" }); + expect(takenBy).toBe("für Daniel"); + }); + }); + + describe("getDateLocale", () => { + it("should return 'en-US' for English", () => { + expect(getDateLocale("en")).toBe("en-US"); + }); + + it("should return 'de-DE' for German", () => { + expect(getDateLocale("de")).toBe("de-DE"); + }); + + it("should return 'en-US' for unknown language", () => { + expect(getDateLocale("fr" as Language)).toBe("en-US"); + }); + }); +}); diff --git a/backend/src/utils/scheduler-utils.ts b/backend/src/utils/scheduler-utils.ts new file mode 100644 index 0000000..f2bb0c8 --- /dev/null +++ b/backend/src/utils/scheduler-utils.ts @@ -0,0 +1,337 @@ +/** + * Shared utility functions for scheduler services. + * Exported separately to allow testing without side effects. + */ + +import { getDateLocale, type Language } from "../i18n/translations.js"; + +export type Blister = { usage: number; every: number; start: string }; + +// ============================================================================= +// Timezone utilities +// ============================================================================= + +/** Get current timezone from TZ env variable or default to UTC */ +export function getTimezone(): string { + return process.env.TZ || "UTC"; +} + +/** Format a date in the configured timezone */ +export function formatInTimezone(date: Date, tz?: string): string { + return date.toLocaleString("de-DE", { + timeZone: tz ?? getTimezone(), + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }); +} + +/** Get current hour in the configured timezone */ +export function getCurrentHourInTimezone(tz?: string): number { + const now = new Date(); + const timeStr = now.toLocaleString("en-US", { + timeZone: tz ?? getTimezone(), + hour: "numeric", + hour12: false + }); + return parseInt(timeStr, 10); +} + +/** Get today's date string in the configured timezone (YYYY-MM-DD) */ +export function getTodayInTimezone(tz?: string): string { + const now = new Date(); + const parts = now.toLocaleDateString("en-CA", { timeZone: tz ?? getTimezone() }).split("-"); + return parts.join("-"); // YYYY-MM-DD format +} + +/** Calculate the next scheduled time for a given reminder hour */ +export function getNextScheduledTime(reminderHour: number, tz?: string): Date { + const now = new Date(); + const timezone = tz ?? getTimezone(); + + // Get current time components in the target timezone + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false + }); + + const parts = formatter.formatToParts(now); + const getPart = (type: string) => parts.find(p => p.type === type)?.value || "0"; + + const currentHour = parseInt(getPart("hour"), 10); + const currentMinute = parseInt(getPart("minute"), 10); + + // Calculate if we need tomorrow + const needTomorrow = currentHour > reminderHour || (currentHour === reminderHour && currentMinute > 0); + + // Handle month overflow simply by adding a day to now if needed + let targetDate: Date; + if (needTomorrow) { + targetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); + } else { + targetDate = new Date(now); + } + + // Get the target date's date string in the timezone + const targetFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit" + }); + const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number); + + // Now we need to find the UTC time that corresponds to reminderHour:00 on targetDate in the target timezone + // Use a search approach: start with a guess and adjust + const guessUtc = new Date(Date.UTC(targetYear, targetMonth - 1, targetDay, reminderHour, 0, 0, 0)); + + // Check what hour this UTC time corresponds to in the target timezone + const checkFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour: "2-digit", + hour12: false + }); + + // Adjust based on the difference + const guessHour = parseInt(checkFormatter.format(guessUtc), 10); + const hourDiff = guessHour - reminderHour; + + // Apply correction (if guessHour is higher, we need to subtract time) + const correctedUtc = new Date(guessUtc.getTime() - hourDiff * 60 * 60 * 1000); + + return correctedUtc; +} + +/** Calculate milliseconds until next check at the given reminder hour */ +export function getMsUntilNextCheck(reminderHour: number, tz?: string): number { + const next = getNextScheduledTime(reminderHour, tz); + return next.getTime() - Date.now(); +} + +// ============================================================================= +// Blister/medication parsing utilities +// ============================================================================= + +/** Parse blister schedules from JSON columns */ +export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] { + try { + const usage = JSON.parse(row.usageJson) as number[]; + const every = JSON.parse(row.everyJson) as number[]; + const start = JSON.parse(row.startJson) as string[]; + const len = Math.min(usage.length, every.length, start.length); + const blisters: Blister[] = []; + for (let i = 0; i < len; i++) { + blisters.push({ usage: usage[i], every: every[i], start: start[i] }); + } + return blisters; + } catch { + return []; + } +} + +/** Parse takenByJson to array of strings */ +export function parseTakenByJson(takenByJson: string | null | undefined): string[] { + if (!takenByJson) return []; + try { + const parsed = JSON.parse(takenByJson); + return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : []; + } catch { + return []; + } +} + +// ============================================================================= +// Stock calculation utilities +// ============================================================================= + +/** Calculate daily usage from blisters */ +export function calculateDailyUsage(blisters: Blister[]): number { + return blisters.reduce((sum, s) => sum + s.usage / s.every, 0); +} + +/** Calculate depletion information for a medication */ +export function calculateDepletionInfo( + med: { count: number; blisters: Blister[] }, + language: Language +): { daysLeft: number | null; depletionDate: string | null } { + const dailyUsage = calculateDailyUsage(med.blisters); + if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null }; + + const daysLeft = Math.floor(med.count / dailyUsage); + const depletionMs = Date.now() + daysLeft * 86_400_000; + const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), { + weekday: "short", + day: "2-digit", + month: "short", + }); + + return { daysLeft, depletionDate }; +} + +// ============================================================================= +// Intake reminder utilities +// ============================================================================= + +export type UpcomingIntake = { + medName: string; + usage: number; + intakeTime: Date; + intakeTimeStr: string; + takenBy: string[]; + pillWeightMg: number | null; +}; + +/** + * Get upcoming intakes that fall within the reminder window. + * Returns intakes that should be notified about right now. + */ +export function getUpcomingIntakes( + medName: string, + blisters: Blister[], + minutesBefore: number, + takenBy: string[], + pillWeightMg: number | null, + locale: string, + tz?: string, + nowOverride?: number +): UpcomingIntake[] { + const now = nowOverride ?? Date.now(); + const timezone = tz ?? getTimezone(); + + // Window to detect if "now" is the right time to send reminder + // We check if the notify time (intake - minutesBefore) falls within current minute ±1 + const windowStart = now - 2 * 60 * 1000; // 2 minutes ago (catch slightly late checks) + const windowEnd = now + 1 * 60 * 1000; // 1 minute from now + + const upcoming: UpcomingIntake[] = []; + + for (const blister of blisters) { + const startTime = new Date(blister.start).getTime(); + const intervalMs = blister.every * 24 * 60 * 60 * 1000; + + if (intervalMs <= 0) continue; + + // Find the next scheduled intake time (could be today or in the future) + let nextTime = startTime; + + // If start is in the past, calculate occurrences + if (nextTime < now) { + const elapsed = now - startTime; + const intervals = Math.floor(elapsed / intervalMs); + + // Check the current occurrence (today's scheduled time, even if past) + const currentOccurrence = startTime + intervals * intervalMs; + // And the next occurrence + const nextOccurrence = startTime + (intervals + 1) * intervalMs; + + // If today's occurrence is within the reminder window, use it + // (intake hasn't happened yet, we should remind) + const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000; + if (currentNotifyTime >= windowStart && currentOccurrence > now) { + nextTime = currentOccurrence; + } else { + nextTime = nextOccurrence; + } + } + + // Calculate when we should notify for this intake + const notifyTime = nextTime - minutesBefore * 60 * 1000; + + if (notifyTime >= windowStart && notifyTime <= windowEnd) { + const intakeDate = new Date(nextTime); + upcoming.push({ + medName, + usage: blister.usage, + intakeTime: intakeDate, + intakeTimeStr: intakeDate.toLocaleTimeString(locale, { + hour: "2-digit", + minute: "2-digit", + timeZone: timezone + }), + takenBy, + pillWeightMg, + }); + } + } + + return upcoming; +} + +// ============================================================================= +// State file utilities +// ============================================================================= + +export type ReminderState = { + lastAutoEmailSent: string | null; + lastAutoEmailDate: string | null; + notifiedMedications: string[]; + nextScheduledCheck: string | null; + lastNotificationType: "stock" | "intake" | null; + lastNotificationChannel: "email" | "push" | "both" | null; +}; + +export type IntakeReminderState = { + sentReminders: string[]; +}; + +/** Create default reminder state */ +export function createDefaultReminderState(): ReminderState { + return { + lastAutoEmailSent: null, + lastAutoEmailDate: null, + notifiedMedications: [], + nextScheduledCheck: null, + lastNotificationType: null, + lastNotificationChannel: null, + }; +} + +/** Create default intake reminder state */ +export function createDefaultIntakeReminderState(): IntakeReminderState { + return { sentReminders: [] }; +} + +/** Parse reminder state from JSON string */ +export function parseReminderState(json: string): ReminderState { + try { + const saved = JSON.parse(json); + return { + lastAutoEmailSent: saved.lastAutoEmailSent ?? null, + lastAutoEmailDate: saved.lastAutoEmailDate ?? null, + notifiedMedications: saved.notifiedMedications ?? [], + nextScheduledCheck: saved.nextScheduledCheck ?? null, + lastNotificationType: saved.lastNotificationType ?? null, + lastNotificationChannel: saved.lastNotificationChannel ?? null, + }; + } catch { + return createDefaultReminderState(); + } +} + +/** Parse intake reminder state from JSON string */ +export function parseIntakeReminderState(json: string): IntakeReminderState { + try { + const saved = JSON.parse(json); + return { + sentReminders: saved.sentReminders ?? [], + }; + } catch { + return createDefaultIntakeReminderState(); + } +} + +/** Clean up old intake reminder entries (older than given milliseconds) */ +export function cleanOldIntakeReminders(sentReminders: string[], maxAgeMs: number = 24 * 60 * 60 * 1000): string[] { + const cutoff = Date.now() - maxAgeMs; + return sentReminders.filter(key => { + const timestamp = parseInt(key.split(":").pop() || "0", 10); + return timestamp > cutoff; + }); +} diff --git a/backend/src/utils/server-config.ts b/backend/src/utils/server-config.ts new file mode 100644 index 0000000..838740e --- /dev/null +++ b/backend/src/utils/server-config.ts @@ -0,0 +1,125 @@ +/** + * Utility functions for server configuration. + * Exported separately to allow testing without triggering server start. + */ + +import { existsSync, mkdirSync } from "fs"; +import { resolve } from "path"; +import type { CookieSerializeOptions } from "@fastify/cookie"; + +/** + * Parse comma-separated CORS origins string + */ +export function parseCorsOrigins(originsStr: string): string[] { + return originsStr + .split(",") + .map((o) => o.trim()) + .filter((o) => o.length > 0); +} + +/** + * Build base cookie options for access token + */ +export function buildBaseCookieOptions( + accessTtlMinutes: number, + isProduction: boolean +): CookieSerializeOptions { + return { + httpOnly: true, + secure: isProduction, + sameSite: "lax", + path: "/", + maxAge: accessTtlMinutes * 60, // Convert minutes to seconds + }; +} + +/** + * Build refresh cookie options (extends base with longer TTL) + */ +export function buildRefreshCookieOptions( + baseCookieOptions: CookieSerializeOptions, + refreshTtlDays: number +): CookieSerializeOptions { + return { + ...baseCookieOptions, + maxAge: refreshTtlDays * 24 * 60 * 60, // Convert days to seconds + }; +} + +/** + * Build complete app configuration object + */ +export interface AppConfigOptions { + jwtSecret?: string; + refreshSecret?: string; + accessTtlMinutes: number; + refreshTtlDays: number; + isProduction: boolean; +} + +export interface AppConfig { + accessSecret: string; + refreshSecret: string; + accessTtl: number; + refreshTtl: number; + cookieOptions: CookieSerializeOptions; + refreshCookieOptions: CookieSerializeOptions; +} + +export function buildAppConfig(options: AppConfigOptions): AppConfig { + const cookieOptions = buildBaseCookieOptions( + options.accessTtlMinutes, + options.isProduction + ); + const refreshCookieOptions = buildRefreshCookieOptions( + cookieOptions, + options.refreshTtlDays + ); + + return { + accessSecret: options.jwtSecret || "", + refreshSecret: options.refreshSecret || "", + accessTtl: options.accessTtlMinutes, + refreshTtl: options.refreshTtlDays, + cookieOptions, + refreshCookieOptions, + }; +} + +/** + * Ensure images directory exists + */ +export function ensureImagesDirectory(cwd?: string): string { + const basePath = cwd || process.cwd(); + const imagesDir = resolve(basePath, "data/images"); + if (!existsSync(imagesDir)) { + mkdirSync(imagesDir, { recursive: true }); + } + return imagesDir; +} + +/** + * Get JWT configuration based on auth enabled status + */ +export interface JwtConfig { + secret: string; + cookie: { + cookieName: string; + signed: boolean; + }; +} + +export function getJwtConfig(authEnabled: boolean, jwtSecret?: string): JwtConfig { + const effectiveSecret = + authEnabled && jwtSecret + ? jwtSecret + : "auth-disabled-no-secret-needed"; + + return { + secret: effectiveSecret, + cookie: { + cookieName: "access_token", + signed: false, + }, + }; +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..9f50a69 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + setupFiles: ["src/test/setup.ts"], + // Run tests sequentially to avoid DB conflicts + poolOptions: { + threads: { + singleThread: true, + }, + }, + // Timeout for longer integration tests + testTimeout: 10000, + }, +});