diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 0407fde..5051eda 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -4,7 +4,7 @@ on: pull_request: branches: [ "*" ] push: - branches: [ "main", "development", "release/*" ] + branches: [ "main", "development", "release/*", "feature-stock-tracking", "development-combined" ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -20,7 +20,58 @@ jobs: run: docker compose build - name: Build test images run: | - mv docker-compose.test.yml docker-compose.yml - docker compose build + docker compose -f docker-compose.yml -f docker-compose.test.yml build + - name: Start backend services + run: docker compose -f docker-compose.yml up -d database auth user analytics market api-gateway + docker compose -f docker-compose.yml -f docker-compose.test.yml build + - name: Start backend services + run: docker compose -f docker-compose.yml up -d database auth user analytics market api-gateway - name: Run tests - run: docker compose up --abort-on-container-failure + run: | + failed=() + for service in \ + auth-test \ + user-test \ + analytics-test \ + market-test \ + market-mutation-test \ + market-integration-test + do + echo "::group::Running $service" + if ! docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm "$service"; then + failed+=("$service") + fi + echo "::endgroup::" + done + + if [ ${#failed[@]} -gt 0 ]; then + echo "Failed test services: ${failed[*]}" + exit 1 + fi + - name: Stop services + if: always() + run: docker compose -f docker-compose.yml -f docker-compose.test.yml down -v --remove-orphans + run: | + failed=() + for service in \ + auth-test \ + user-test \ + analytics-test \ + market-test \ + market-mutation-test \ + market-integration-test + do + echo "::group::Running $service" + if ! docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm "$service"; then + failed+=("$service") + fi + echo "::endgroup::" + done + + if [ ${#failed[@]} -gt 0 ]; then + echo "Failed test services: ${failed[*]}" + exit 1 + fi + - name: Stop services + if: always() + run: docker compose -f docker-compose.yml -f docker-compose.test.yml down -v --remove-orphans diff --git a/.gitignore b/.gitignore index 47d3e1e..e2e8776 100644 --- a/.gitignore +++ b/.gitignore @@ -202,3 +202,6 @@ marimo/_static/ marimo/_lsp/ __marimo__/ app/server/services/api-gateway/bun.lock + +# stryker files, these are ran with node and break git error checking - mutation testing for JS +.stryker-tmp \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..acf6d3b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} diff --git a/app/client/Dockerfile b/app/client/Dockerfile index 3d460ed..8f0fe98 100644 --- a/app/client/Dockerfile +++ b/app/client/Dockerfile @@ -2,9 +2,9 @@ FROM oven/bun:1.3.9-slim AS builder WORKDIR /app -COPY package.json bun.lockb . +COPY package.json bun.lockb ./ RUN --mount=type=cache,target=/root/.bun/install/cache \ -bun install + bun install COPY tsconfig.* . COPY vite.config.ts . @@ -12,8 +12,9 @@ COPY index.* . COPY public ./public COPY src ./src +FROM finus-webserver-base:local as base RUN bun run build FROM nginx:1.29.5-alpine3.23-perl -COPY --from=builder /app/dist /data/www +COPY --from=base /app/dist /data/www COPY nginx.conf /etc/nginx/nginx.conf diff --git a/app/client/Dockerfile.base b/app/client/Dockerfile.base new file mode 100644 index 0000000..a5d1461 --- /dev/null +++ b/app/client/Dockerfile.base @@ -0,0 +1,15 @@ +FROM oven/bun:1.3.9-slim + +WORKDIR /app + +COPY package.json bun.lockb . +RUN --mount=type=cache,target=/root/.bun/install/cache \ + bun install + +COPY tsconfig.* . +COPY vite.config.ts . +COPY index.* . +COPY public ./public +COPY src ./src + +ENTRYPOINT [ "/bin/bash", "-c", "exit" ] diff --git a/app/client/Dockerfile.mutation.test b/app/client/Dockerfile.mutation.test new file mode 100644 index 0000000..92de251 --- /dev/null +++ b/app/client/Dockerfile.mutation.test @@ -0,0 +1,10 @@ +FROM oven/bun:1 + +WORKDIR /app + +COPY bun.lockb package.json ./ +RUN bun add -g @stryker-mutator/core @stryker-mutator/typescript-checker + +COPY . . + +CMD ["bun","run", "mutate"] diff --git a/app/client/Dockerfile.test b/app/client/Dockerfile.test new file mode 100644 index 0000000..2eb18e8 --- /dev/null +++ b/app/client/Dockerfile.test @@ -0,0 +1,8 @@ +FROM finus-webserver-base:local + +COPY test ./test +COPY vitest.config.ts . + +ENV CI=true + +ENTRYPOINT ["bun", "run", "vitest", "--run"] diff --git a/app/client/bun.lock b/app/client/bun.lock index 06868ca..993dbe8 100644 --- a/app/client/bun.lock +++ b/app/client/bun.lock @@ -26,6 +26,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@stryker-mutator/core": "^9.6.0", + "@stryker-mutator/vitest-runner": "^9.6.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -61,14 +63,28 @@ "@babel/generator": ["@babel/generator@7.29.0", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ=="], + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -79,6 +95,24 @@ "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.0", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-decorators": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA=="], + + "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw=="], + + "@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-transform-destructuring": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], @@ -155,6 +189,38 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.4", "", {}, "sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@5.1.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PubpMPO2nJgMufkoB3P2wwxNXEMUXnBIKi/ACzDUYfaoPuM7gSTmuxJeMscoLVEsR4qqrCMf5p0SiYGWnVJ8kw=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-tiNyA73pgpQ0FQ7axqtoLUe4GDYjNCDcVsbgcA5anvwg2z6i+suEngLKKJrWKJolT//GFPZHwN30binDIHgSgQ=="], + + "@inquirer/core": ["@inquirer/core@11.1.7", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-1BiBNDk9btIwYIzNZpkikIHXWeNzNncJePPqwDyVMhXhD1ebqbpn1mKGctpoqAbzywZfdG0O4tvmsGIcOevAPQ=="], + + "@inquirer/editor": ["@inquirer/editor@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/external-editor": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VJx4XyaKea7t8hEApTw5dxeIyMtWXre2OiyJcICCRZI4hkoHsMoCnl/KbUnJJExLbH9csLLHMVR144ZhFE1CwA=="], + + "@inquirer/expand": ["@inquirer/expand@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fC0UHJPXsTRvY2fObiwuQYaAnHrp3aDqfwKUJSdfpgv18QUG054ezGbaRNStk/BKD5IPijeMKWej8VV8O5Q/eQ=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@2.0.4", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Prenuv9C1PHj2Itx0BcAOVBTonz02Hc2Nd2DbU67PdGUaqn0nPCnV34oDyyoaZHnmfRxkpuhh/u51ThkrO+RdA=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.4", "", {}, "sha512-eLBsjlS7rPS3WEhmOmh1znQ5IsQrxWzxWDxO51e4urv+iVrSnIHbq4zqJIOiyNdYLa+BVjwOtdetcQx1lWPpiQ=="], + + "@inquirer/input": ["@inquirer/input@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nvZ6qEVeX/zVtZ1dY2hTGDQpVGD3R7MYPLODPgKO8Y+RAqxkrP3i/3NwF3fZpLdaMiNuK0z2NaYIx9tPwiSegQ=="], + + "@inquirer/number": ["@inquirer/number@4.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Ht8OQstxiS3APMGjHV0aYAjRAysidWdwurWEo2i8yI5xbhOBWqizT0+MU1S2GCcuhIBg+3SgWVjEoXgfhY+XaA=="], + + "@inquirer/password": ["@inquirer/password@5.0.10", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QbNyvIE8q2GTqKLYSsA8ATG+eETo+m31DSR0+AU7x3d2FhaTWzqQek80dj3JGTo743kQc6mhBR0erMjYw5jQ0A=="], + + "@inquirer/prompts": ["@inquirer/prompts@8.3.2", "", { "dependencies": { "@inquirer/checkbox": "^5.1.2", "@inquirer/confirm": "^6.0.10", "@inquirer/editor": "^5.0.10", "@inquirer/expand": "^5.0.10", "@inquirer/input": "^5.0.10", "@inquirer/number": "^4.0.10", "@inquirer/password": "^5.0.10", "@inquirer/rawlist": "^5.2.6", "@inquirer/search": "^4.1.6", "@inquirer/select": "^5.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yFroiSj2iiBFlm59amdTvAcQFvWS6ph5oKESls/uqPBect7rTU2GbjyZO2DqxMGuIwVA8z0P4K6ViPcd/cp+0w=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@5.2.6", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-jfw0MLJ5TilNsa9zlJ6nmRM0ZFVZhhTICt4/6CU2Dv1ndY7l3sqqo1gIYZyMMDw0LvE1u1nzJNisfHEhJIxq5w=="], + + "@inquirer/search": ["@inquirer/search@4.1.6", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3/6kTRae98hhDevENScy7cdFEuURnSpM3JbBNg8yfXLw88HgTOl+neUuy/l9W0No5NzGsLVydhBzTIxZP7yChQ=="], + + "@inquirer/select": ["@inquirer/select@5.1.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kTK8YIkHV+f02y7bWCh7E0u2/11lul5WepVTclr3UMBtBr05PgcZNWfMa7FY57ihpQFQH/spLMHTcr0rXy50tA=="], + + "@inquirer/type": ["@inquirer/type@4.0.4", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -329,10 +395,24 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@stryker-mutator/api": ["@stryker-mutator/api@9.6.0", "", { "dependencies": { "mutation-testing-metrics": "3.7.2", "mutation-testing-report-schema": "3.7.2", "tslib": "~2.8.0", "typed-inject": "~5.0.0" } }, "sha512-kJEEwOVoWDXGEIXuM+9efT6LSJ7nyxnQQvjEoKg8GSZXbDUjfD0tqA0aBD06U1SzQLKCM7ffjgPffr154MHZKw=="], + + "@stryker-mutator/core": ["@stryker-mutator/core@9.6.0", "", { "dependencies": { "@inquirer/prompts": "^8.0.0", "@stryker-mutator/api": "9.6.0", "@stryker-mutator/instrumenter": "9.6.0", "@stryker-mutator/util": "9.6.0", "ajv": "~8.18.0", "chalk": "~5.6.0", "commander": "~14.0.0", "diff-match-patch": "1.0.5", "emoji-regex": "~10.6.0", "execa": "~9.6.0", "json-rpc-2.0": "^1.7.0", "lodash.groupby": "~4.6.0", "minimatch": "~10.2.4", "mutation-server-protocol": "~0.4.0", "mutation-testing-elements": "3.7.2", "mutation-testing-metrics": "3.7.2", "mutation-testing-report-schema": "3.7.2", "npm-run-path": "~6.0.0", "progress": "~2.0.3", "rxjs": "~7.8.1", "semver": "^7.6.3", "source-map": "~0.7.4", "tree-kill": "~1.2.2", "tslib": "2.8.1", "typed-inject": "~5.0.0", "typed-rest-client": "~2.2.0" }, "bin": { "stryker": "bin/stryker.js" } }, "sha512-oSbw01l6HXHt0iW9x5fQj7yHGGT8ZjCkXSkI7Bsu0juO7Q6vRMXk7XcvKpCBgRgzKXi1osg8+iIzj7acHuxepQ=="], + + "@stryker-mutator/instrumenter": ["@stryker-mutator/instrumenter@9.6.0", "", { "dependencies": { "@babel/core": "~7.29.0", "@babel/generator": "~7.29.0", "@babel/parser": "~7.29.0", "@babel/plugin-proposal-decorators": "~7.29.0", "@babel/plugin-transform-explicit-resource-management": "^7.28.0", "@babel/preset-typescript": "~7.28.0", "@stryker-mutator/api": "9.6.0", "@stryker-mutator/util": "9.6.0", "angular-html-parser": "~10.4.0", "semver": "~7.7.0", "tslib": "2.8.1", "weapon-regex": "~1.3.2" } }, "sha512-tWdRYfm9LF4Go7cNOos0xEIOEnN7ZOSj38rfXvGZS9IINlvYBrBCl2xcz/67v6l5A7xksMWWByZRIq2bgdnnUg=="], + + "@stryker-mutator/util": ["@stryker-mutator/util@9.6.0", "", {}, "sha512-gw7fJOFNHEj9inAEOodD9RrrMEMhZmWJ46Ww/kDJAXlSsBBmdwCzeomNLngmLTvgp14z7Tfq85DHYwvmNMdOxA=="], + + "@stryker-mutator/vitest-runner": ["@stryker-mutator/vitest-runner@9.6.0", "", { "dependencies": { "@stryker-mutator/api": "9.6.0", "@stryker-mutator/util": "9.6.0", "tslib": "~2.8.0" }, "peerDependencies": { "@stryker-mutator/core": "9.6.0", "vitest": ">=2.0.0" } }, "sha512-/zyELz5jTDAiH0Hr23G6KSnBFl9XV+vn0T0qUAk4sPqJoP5NVm9jjpgt9EBACS/VTkVqSvXqBid4jmESPx11Sg=="], + "@swc/core": ["@swc/core@1.15.11", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.11", "@swc/core-darwin-x64": "1.15.11", "@swc/core-linux-arm-gnueabihf": "1.15.11", "@swc/core-linux-arm64-gnu": "1.15.11", "@swc/core-linux-arm64-musl": "1.15.11", "@swc/core-linux-x64-gnu": "1.15.11", "@swc/core-linux-x64-musl": "1.15.11", "@swc/core-win32-arm64-msvc": "1.15.11", "@swc/core-win32-ia32-msvc": "1.15.11", "@swc/core-win32-x64-msvc": "1.15.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w=="], "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg=="], @@ -487,7 +567,9 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "angular-html-parser": ["angular-html-parser@10.4.0", "", {}, "sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -509,23 +591,27 @@ "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001767", "", {}, "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], @@ -533,6 +619,8 @@ "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -541,6 +629,8 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -585,16 +675,22 @@ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.283", "", {}, "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], @@ -639,6 +735,8 @@ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -647,10 +745,20 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], @@ -677,6 +785,8 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], @@ -701,6 +811,10 @@ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], @@ -709,6 +823,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -719,6 +835,12 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -729,6 +851,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -739,7 +863,9 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-rpc-2.0": ["json-rpc-2.0@1.7.1", "", {}, "sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], @@ -777,6 +903,8 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -797,18 +925,34 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mutation-server-protocol": ["mutation-server-protocol@0.4.1", "", { "dependencies": { "zod": "^4.1.12" } }, "sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g=="], + + "mutation-testing-elements": ["mutation-testing-elements@3.7.2", "", {}, "sha512-i7X2Q4X5eYon72W2QQ9HND7plVhQcqTnv+Xc3KeYslRZSJ4WYJoal8LFdbWm7dKWLNE0rYkCUrvboasWzF3MMA=="], + + "mutation-testing-metrics": ["mutation-testing-metrics@3.7.2", "", { "dependencies": { "mutation-testing-report-schema": "3.7.2" } }, "sha512-ichXZSC4FeJbcVHYOWzWUhNuTJGogc0WiQol8lqEBrBSp+ADl3fmcZMqrx0ogInEUiImn+A8JyTk6uh9vd25TQ=="], + + "mutation-testing-report-schema": ["mutation-testing-report-schema@3.7.2", "", {}, "sha512-fN5M61SDzIOeJyatMOhGPLDOFz5BQIjTNPjo4PcHIEUWrejO4i4B5PFuQ/2l43709hEsTxeiXX00H73WERKcDw=="], + + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -823,9 +967,11 @@ "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -845,10 +991,16 @@ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], @@ -881,6 +1033,8 @@ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], @@ -889,9 +1043,13 @@ "rolldown": ["rolldown@1.0.0-beta.50", "", { "dependencies": { "@oxc-project/types": "=0.97.0", "@rolldown/pluginutils": "1.0.0-beta.50" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.50", "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", "@rolldown/binding-darwin-x64": "1.0.0-beta.50", "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -899,11 +1057,21 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "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" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], - "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -911,6 +1079,8 @@ "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], @@ -937,20 +1107,32 @@ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "typed-inject": ["typed-inject@5.0.0", "", {}, "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA=="], + + "typed-rest-client": ["typed-rest-client@2.2.0", "", { "dependencies": { "des.js": "^1.1.0", "js-md4": "^0.3.2", "qs": "^6.14.1", "tunnel": "0.0.6", "underscore": "^1.12.1" } }, "sha512-/e2Rk9g20N0r44kaQLb3v6QGuryOD8SPb53t43Y5kqXXA+SqWuU7zLiMxetw61jNn/JFrxTdr5nPDhGY/eTNhQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript-eslint": ["typescript-eslint@8.54.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/parser": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ=="], + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -967,6 +1149,8 @@ "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "weapon-regex": ["weapon-regex@1.3.6", "", {}, "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -979,16 +1163,32 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@eslint/config-array/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "@eslint/eslintrc/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@eslint/eslintrc/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], @@ -1011,20 +1211,28 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cross-spawn/path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "eslint/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.50", "", {}, "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA=="], + "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], @@ -1050,5 +1258,19 @@ "@types/papaparse/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "eslint/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/app/client/bun.lockb b/app/client/bun.lockb index 2631a84..77f69e2 100755 Binary files a/app/client/bun.lockb and b/app/client/bun.lockb differ diff --git a/app/client/docker-compose.test.yml b/app/client/docker-compose.test.yml new file mode 100644 index 0000000..566bead --- /dev/null +++ b/app/client/docker-compose.test.yml @@ -0,0 +1,21 @@ +services: + webserver-base: + image: finus-webserver-base:local + build: + dockerfile: Dockerfile.base + + user-input: + build: + dockerfile: Dockerfile.test + depends_on: + - webserver-base + + mutation-tests: + build: + context: . + dockerfile: Dockerfile.mutation.test + depends_on: + - webserver-base + volumes: + - .:/app/client + working_dir: /app/client diff --git a/app/client/docker-compose.yml b/app/client/docker-compose.yml index 7ab4fd7..d995f0b 100644 --- a/app/client/docker-compose.yml +++ b/app/client/docker-compose.yml @@ -1,6 +1,14 @@ services: + webserver-base: + image: finus-webserver-base:local + build: + dockerfile: Dockerfile.base + webserver: image: $DOCKER_HUB_USERNAME/finus-webserver:$RELEASE_VERSION - build: . + build: + dockerfile: Dockerfile ports: - "80:80" + depends_on: + - webserver-base diff --git a/app/client/package.json b/app/client/package.json index a739829..c2815c3 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "mutate": "stryker run" }, "dependencies": { "@tailwindcss/vite": "^4.1.18", @@ -31,6 +32,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@stryker-mutator/core": "^9.6.0", + "@stryker-mutator/vitest-runner": "^9.6.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/app/client/src/App.tsx b/app/client/src/App.tsx index 5db48fb..4d5ca5f 100644 --- a/app/client/src/App.tsx +++ b/app/client/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { BrowserRouter as Router, Navigate, @@ -10,7 +10,10 @@ import LoginPage from "./pages/LoginPage"; import SignUpPage from "./pages/SignUpPage"; import type { AuthSession, AuthUser, AuthApiResponse } from "./types/authTypes"; import DashboardPage from "./pages/DashboardPage.tsx"; +import MarketsPage from "./pages/MarketsPage"; import AppLayout from "./components/AppLayout.tsx"; +import ProjectionPage from "./pages/ProjectionPage.tsx"; +import { syncPinnedMarketsResetKey } from "./utils/marketStorage"; //import { loadSession, saveSession, clearSession } from "./utils/storage.ts"; //import { requestAuth } from "./api/AuthAPI"; //import { resolveUserFromToken } from "./utils/token"; @@ -139,6 +142,35 @@ function App() { loadSession(), ); + useEffect(() => { + let cancelled = false; + + async function syncServerResetKey() { + try { + const response = await fetch(`${API_BASE_URL}/client-state/reset-key`); + const data = (await response.json().catch(() => null)) as { + resetKey?: unknown; + } | null; + + if (cancelled || !response.ok) { + return; + } + + if (typeof data?.resetKey === "string" && data.resetKey.length > 0) { + syncPinnedMarketsResetKey(data.resetKey); + } + } catch { + // Ignore reset-key sync failures and keep the client usable offline. + } + } + + void syncServerResetKey(); + + return () => { + cancelled = true; + }; + }, []); + function handleAuthSuccess(token: string, fallbackUser: Partial) { const nextSession: AuthSession = { token, @@ -203,14 +235,29 @@ function App() { ) } /> + {session && ( }> } /> + } + /> )} + + {session && ( + }> + } + /> + + )} + } /> {/**Code below is only used for dashboard development purposes */} diff --git a/app/client/src/api/Account.ts b/app/client/src/api/Account.ts index 9177437..e1deda3 100644 --- a/app/client/src/api/Account.ts +++ b/app/client/src/api/Account.ts @@ -7,10 +7,18 @@ const requestUrl = "http://localhost:3000/api/accounts"; //Sends a request to get different accounts the user has export async function getUserAccounts( session: AuthSession, + type?: string, ): Promise { + let url = requestUrl; + + //Determine if we're targetting a specific type + if (type) { + url = `${requestUrl}/?type=${type}`; + } + try { //Sends a http request and waits for a response - const response = await fetch(requestUrl, { + const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${session.token}` }, }); diff --git a/app/client/src/api/Debt.ts b/app/client/src/api/Debt.ts new file mode 100644 index 0000000..6b32969 --- /dev/null +++ b/app/client/src/api/Debt.ts @@ -0,0 +1,59 @@ +import type { DebtPayoffResponse } from "../types/responseTypes"; +import type { AuthSession } from "@/types/authTypes"; +import type { Account } from "@/types/AccountType"; +import type { projectionDebtRequest } from "@/types/requestTypes"; + +const requestUrl = "http://localhost:3000/api/debts"; + +//Sends a request to get different debts the user has +export async function getDebt(session: AuthSession): Promise { + try { + //Sends a http request and waits for a response + const response = await fetch(requestUrl, { + method: "GET", + headers: { Authorization: `Bearer ${session.token}` }, + }); + + //Determine if we were able to retrieve user's data + if (!response.ok) { + //Failed to retrieve user data, return empty array + console.error("Error: Failed to retrieve users debts", response.status); + return []; + } + + return response.json(); + } catch (error) { + console.error(error); + throw error; + } +} + +//Post request, even tho it says get in the function +export async function getDebtProjection( + session: AuthSession, + request: projectionDebtRequest, +): Promise { + const url = "http://localhost:3000/predict-debt-payoff"; + + try { + //Create post request and wait for response + const response = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + Authorization: `Bearer ${session.token}`, + }, + body: JSON.stringify(request), + }); + + //Determine if our post was a success + if (!response.ok) { + console.error(response.status); + } + + return response.json(); + } catch (error) { + console.error(error); + throw error; + } +} diff --git a/app/client/src/api/GoalsAPI.ts b/app/client/src/api/GoalsAPI.ts new file mode 100644 index 0000000..c9cbb9a --- /dev/null +++ b/app/client/src/api/GoalsAPI.ts @@ -0,0 +1,47 @@ +import type { Goal } from "../types/goals.ts"; +import { instance } from "./config"; + +async function fetchGoals(): Promise { + console.log("fetching goals"); + const response = await instance.get("/goals"); + if (response.status !== 200) { + throw new Error(`Failed to fetch goals data: ${response.statusText}`); + } + console.log("returning a list of goals: ", response.data); + return response.data; +} + +async function createGoal(goal: Partial): Promise { + console.log("creating goal: ", goal); + const response = await instance.post("/goals", goal); + if (response.status !== 201) { + // 201 Created is standard for POST + throw new Error(`Failed to create goal: ${response.statusText}`); + } + console.log("got response from create goal: ", response.data); + return response.data; +} + +async function updateGoal( + goalId: string, + updates: Partial, +): Promise { + const response = await instance.patch(`/goals?gid=${goalId}`, updates); + if (response.status !== 200) { + throw new Error(`Failed to edit goal: ${response.statusText}`); + } + + // console.log("updated goal: ", response.data); + return response.data; +} + +async function deleteGoal(goalId: string): Promise { + // console.log("deleting goal: ", goalId); + const response = await instance.delete(`/goals?gid=${goalId}`); + if (response.status !== 204) { + // 204 No Content is standard for DELETE + throw new Error(`Failed to delete goal: ${response.statusText}`); + } +} + +export { fetchGoals, createGoal, updateGoal, deleteGoal }; diff --git a/app/client/src/api/Market.ts b/app/client/src/api/Market.ts new file mode 100644 index 0000000..df430cb --- /dev/null +++ b/app/client/src/api/Market.ts @@ -0,0 +1,88 @@ +import axios from "axios"; +import { instance } from "@/api/config"; +import type { + MarketHistoryPeriod, + MarketHistoryPoint, + MarketInstrument, +} from "@/types/Market"; +import { getIntervalForPeriod } from "@/utils/market"; + +type MarketQuoteResponse = Pick< + MarketInstrument, + "symbol" | "price" | "change" | "changePercent" | "timestamp" +>; + +type MarketHistoryResponse = { + symbol: string; + period: MarketHistoryPeriod; + interval: "5m" | "15m" | "1d" | "1wk" | "1mo"; + points: MarketHistoryPoint[]; +}; + +function toMarketApiError(error: unknown, fallbackMessage: string): Error { + if (axios.isAxiosError(error)) { + const detail = error.response?.data?.detail; + if (typeof detail === "string" && detail.length > 0) { + return new Error(detail); + } + } + + return new Error(fallbackMessage); +} + +async function searchMarkets(query: string): Promise { + try { + const response = await instance.get("/markets/search", { + params: { q: query }, + }); + + if (response.status !== 200) { + throw new Error(`Failed to search markets: ${response.statusText}`); + } + + return response.data as MarketInstrument[]; + } catch (error) { + throw toMarketApiError(error, "Unable to search market instruments."); + } +} + +async function getMarketQuote(symbol: string): Promise { + try { + const response = await instance.get("/markets/quote", { + params: { symbol }, + }); + + if (response.status !== 200) { + throw new Error(`Failed to load market quote: ${response.statusText}`); + } + + return response.data as MarketQuoteResponse; + } catch (error) { + throw toMarketApiError(error, "Unable to load market quote."); + } +} + +async function getMarketHistory( + symbol: string, + period: MarketHistoryPeriod, +): Promise { + try { + const response = await instance.get("/markets/history", { + params: { + symbol, + period, + interval: getIntervalForPeriod(period), + }, + }); + + if (response.status !== 200) { + throw new Error(`Failed to load market history: ${response.statusText}`); + } + + return response.data as MarketHistoryResponse; + } catch (error) { + throw toMarketApiError(error, "Unable to load market history."); + } +} + +export { getMarketHistory, getMarketQuote, searchMarkets }; diff --git a/app/client/src/api/Saving.ts b/app/client/src/api/Saving.ts new file mode 100644 index 0000000..6215858 --- /dev/null +++ b/app/client/src/api/Saving.ts @@ -0,0 +1,59 @@ +import { type Account } from "@/types/AccountType"; +import { type AuthSession } from "@/types/authTypes"; +import type { projectionSavingRequest } from "@/types/requestTypes"; +import { type savingProjectionResponseData } from "@/types/responseTypes"; +const requestUrl = "http://localhost:3000/api/savings"; + +//Sends a request to get different debts the user has +export async function getSaving(session: AuthSession): Promise { + try { + //Sends a http request and waits for a response + const response = await fetch(requestUrl, { + method: "GET", + headers: { Authorization: `Bearer ${session.token}` }, + }); + + //Determine if we were able to retrieve user's data + if (!response.ok) { + //Failed to retrieve user data, return empty array + console.error("Error: Failed to retrieve users debts", response.status); + return []; + } + + return response.json(); + } catch (error) { + console.error(error); + throw error; + } +} + +//Post request, even tho it says get in the function +export async function getSavingProjection( + session: AuthSession, + request: projectionSavingRequest, +): Promise { + const url = "http://localhost:3000/compound-interest"; + + try { + //Create post request and wait for response + const response = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + Authorization: `Bearer ${session.token}`, + }, + body: JSON.stringify(request), + }); + + //Determine if our post was a success + if (!response.ok) { + console.error(response.status); + return []; + } + + return response.json(); + } catch (error) { + console.error(error); + throw error; + } +} diff --git a/app/client/src/api/Transaction.ts b/app/client/src/api/Transaction.ts index 3eb91f7..3153898 100644 --- a/app/client/src/api/Transaction.ts +++ b/app/client/src/api/Transaction.ts @@ -8,7 +8,7 @@ const requestUrl = "http://localhost:3000/api/transactions"; //Sends a GET request to get the list of user transactions for the account export async function getTransactions( session: AuthSession, - financialAccount_id: string, + financialAccount_id: number, ): Promise { try { const response = await fetch( @@ -86,7 +86,7 @@ export async function putTranscations( } } -//DELETE /api/transactions/:id +//DELETE /api/transactions/ export async function deleteTransaction( session: AuthSession, diff --git a/app/client/src/components/AccountForm.tsx b/app/client/src/components/AccountForm.tsx index 578df72..63cc532 100644 --- a/app/client/src/components/AccountForm.tsx +++ b/app/client/src/components/AccountForm.tsx @@ -36,14 +36,15 @@ export default function PopupForm({ let interest = undefined; const accountBalance = Number(balance); - if (accountType === accountCategory.SAVING) { + if ( + (accountType === accountCategory.SAVING || + accountType === accountCategory.CREDIT_CARD) && + formInput.subType + ) { subtype = formInput.subType; } - if ( - accountType === accountCategory.SAVING || - accountType === accountCategory.DEBT - ) { + if (accountType === accountCategory.SAVING) { interest = formInput.interest; } @@ -162,6 +163,7 @@ export default function PopupForm({ const [accountType, setAccountType] = useState( () => { if (edit && selectedAccount) { + console.log(selectedAccount.type); return selectedAccount.type as typeofAccount; } else { //Default value @@ -178,13 +180,18 @@ export default function PopupForm({ <>
- {edit ?

Edit Account

:

Create Account

} + {edit ? ( +

Edit Account

+ ) : ( +

Create Account

+ )} @@ -195,13 +202,15 @@ export default function PopupForm({ value={accountType} id="type" name="type" - onChange={(event) => - setAccountType(event.target.value as typeofAccount) - } + className="formSelect" + onChange={(event) => { + setAccountType(event.target.value as typeofAccount); + setFormInput({ ...formInput, ["subType"]: "" }); + }} > {accountCat.map((category) => ( - ))} @@ -214,6 +223,7 @@ export default function PopupForm({ min="0" step="0.01" name="balance" + className="formInput" value={balance} onChange={(event) => handleCurrencyChange(event, setBalance)} onBlur={(event) => handleCurrencyBlur(event, balance, setBalance)} @@ -221,34 +231,45 @@ export default function PopupForm({ />

- {accountType === "DEBT" || accountType === "SAVING" ? ( - <> - - -

- - ) : null} - - {accountType === "SAVING" ? ( + {accountType === accountCategory.SAVING ? ( <> - { + handleChange(event); + }} + > + +

) : null} + {accountType === accountCategory.CREDIT_CARD ? ( + <> + + + + ) : null} +
{edit ? ( diff --git a/app/client/src/components/AccountListCard.tsx b/app/client/src/components/AccountListCard.tsx index 4fc5111..7987a6d 100644 --- a/app/client/src/components/AccountListCard.tsx +++ b/app/client/src/components/AccountListCard.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import AccountPopup from "./AccountForm.tsx"; import { type Account } from "../types/AccountType.ts"; import type { AuthSession } from "@/types/authTypes.ts"; +import { accountCategory } from "@/enum/AccountCategory.ts"; interface cardProp { account: Account; @@ -47,7 +48,9 @@ export default function Card({

- {account.type + " " + (account.subtype ? " " + account.type : "")} + {accountCategory[account.type as keyof typeof accountCategory] + + " " + + (account.subtype ? " " + account.subtype : "")}

{Number(account.balance).toFixed(2)}

diff --git a/app/client/src/components/AppLayout.tsx b/app/client/src/components/AppLayout.tsx index 92620d9..67a647f 100644 --- a/app/client/src/components/AppLayout.tsx +++ b/app/client/src/components/AppLayout.tsx @@ -1,13 +1,94 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Outlet } from "react-router-dom"; -import { IoReorderThreeSharp } from "react-icons/io5"; +import { IoCheckmarkCircleOutline, IoReorderThreeSharp } from "react-icons/io5"; import NavBar from "./NavBar"; import { SIDEBAR_WIDTH } from "../utils/constants"; +import GoalsPanel from "./GoalsPanel"; +import type { Goal } from "../types/goals.ts"; +import { + fetchGoals, + createGoal, + updateGoal, + deleteGoal, +} from "../api/GoalsAPI.ts"; +import LoadingSpinner from "@/components/LoadingSpinner"; + type AppLayoutProps = { onLogout: () => void; }; export default function AppLayout({ onLogout }: AppLayoutProps) { const [isNavBarOpen, setIsNavBarOpen] = useState(false); + const [isGoalsPanelOpen, setIsGoalsPanelOpen] = useState(false); + const closeTimeoutRef = useRef(null); + const [goals, setGoals] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + loadGoals(); + }, []); + + const loadGoals = async () => { + try { + setIsLoading(true); + const data = await fetchGoals(); + setGoals(data); + } catch (error) { + console.error("Failed to load goals:", error); + } finally { + setIsLoading(false); + } + }; + + const handleAddGoal = async () => { + // need a modal or a form for this placeholder + const newGoal: Partial = { + type: "reduce_spending", + name: "New Goal", + category: "Unknown", + target: 100, + current_amount: 0, + period: "m", + }; + + try { + const created = await createGoal(newGoal); + setGoals([...goals, created]); + } catch (error) { + console.error("Failed to create goal:", error); + } + }; + + const handleEditGoal = async (goalId: string, updates: Partial) => { + try { + const updated = await updateGoal(goalId, updates); + // console.log("Updated goal:", updated); + setGoals(goals.map((g) => (g.id === goalId ? updated : g))); + } catch (error) { + console.error("Failed to update goal:", error); + } + }; + + const handleDeleteGoal = async (goalId: string) => { + try { + await deleteGoal(goalId); + setGoals(goals.filter((g) => g.id !== goalId)); + } catch (error) { + console.error("Failed to delete goal:", error); + } + }; + + const handleOpenPanel = () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + setIsGoalsPanelOpen(true); + }; + + const handleClosePanel = () => { + closeTimeoutRef.current = setTimeout(() => { + setIsGoalsPanelOpen(false); + }, 200); //200ms delay before the goals panel closes + }; function toggleSidebar() { setIsNavBarOpen((prev) => !prev); @@ -44,6 +125,69 @@ export default function AppLayout({ onLogout }: AppLayoutProps) { " /> + +
+ {isLoading ? ( +
+ +
+ ) : ( + + )} +
{isNavBarOpen && ( ; + +type EditValuesAction = + | { + type: "INIT_GOAL"; + payload: { goalId: string; goal: Goal }; + } + | { + type: "UPDATE_FIELD"; + payload: { + goalId: string; + field: string; + value: string | number; + }; + }; + +function editValuesReducer( + state: EditValuesState, + action: EditValuesAction, +): EditValuesState { + switch (action.type) { + case "INIT_GOAL": { + const { goalId, goal } = action.payload; + if (state[goalId]) return state; + + const baseValues = { + name: goal.name || "", + category: goal.category || "", + target: goal.target || 0, + }; + + if (goal.type === "reduce_spending") { + return { + ...state, + [goalId]: { + ...baseValues, + type: "reduce_spending", + period: goal.period || "m", + }, + }; + } else { + // type === "save" + return { + ...state, + [goalId]: { + ...baseValues, + type: "save", + period: "m", + }, + }; + } + } + + case "UPDATE_FIELD": { + const { goalId, field, value } = action.payload; + const current = state[goalId]; + if (!current) return state; + + // Handle field updates with type safety + if (field === "type") { + // When type changes, we need to restructure the edit value + if (value === "reduce_spending") { + return { + ...state, + [goalId]: { + name: current.name, + category: current.category, + target: current.target, + type: "reduce_spending", + period: "m", // default period + } as EditValueReduceSpending, + }; + } else { + return { + ...state, + [goalId]: { + name: current.name, + category: current.category, + target: current.target, + type: "save", + period: "m", + } as EditValueSave, + }; + } + } + + // For other fields, preserve the type + if (field === "period" && current.type === "reduce_spending") { + return { + ...state, + [goalId]: { + ...current, + period: value as "m" | "w", + }, + }; + } + + // Generic field update + return { + ...state, + [goalId]: { + ...current, + [field]: value, + }, + }; + } + + default: + return state; + } +} + +function GoalsPanel({ + goals, + onAddGoal, + onEditGoal, + onDeleteGoal, + maxGoals = 5, +}: GoalsPanelProps) { + const [expandedGoalId, setExpandedGoalId] = useState(null); + const [editValues, dispatch] = useReducer(editValuesReducer, {}); + + const initializedGoals = useRef>(new Set()); + + //initialize edit values when a goal expands + useEffect(() => { + if (expandedGoalId) { + const goal = goals.find((g: Goal) => g.id === expandedGoalId); + if (goal && !initializedGoals.current.has(expandedGoalId)) { + initializedGoals.current.add(expandedGoalId); + dispatch({ + type: "INIT_GOAL", + payload: { goalId: expandedGoalId, goal }, + }); + } + } + }, [expandedGoalId, goals]); + + //helper to format currency + const formatAmount = (amount: number): string => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "CAD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); + }; + + //helper to get goal type, only the reduce_spending type has a monthly or a weekly period, savings are eternal + const getGoalDescription = (goal: Goal): string => { + if (goal.type === "reduce_spending") { + return `Spending limit • ${goal.period === "m" ? "Monthly" : "Weekly"}`; + } else { + return `Savings Goal`; + } + }; + + //toggle expanded view of a goal + const handleGoalClick = (goalId: string) => { + setExpandedGoalId(expandedGoalId === goalId ? null : goalId); + }; + + //helper to handle field changes + const handleFieldChange = ( + goalId: string, + field: string, + value: string | number, + ) => { + dispatch({ type: "UPDATE_FIELD", payload: { goalId, field, value } }); + }; + + const handleApplyEdits = (goalId: string) => { + const updates = editValues[goalId]; + if (updates) { + onEditGoal(goalId, updates); + setExpandedGoalId(null); + } + }; + + //saves the edits in state even of the panel closes + const hasUnsavedChanges = (goalId: string): boolean => { + const goal = goals.find((g: Goal) => g.id === goalId); + const edits = editValues[goalId]; + if (!goal || !edits) return false; + + return ( + goal.name !== edits.name || + goal.category !== edits.category || + goal.type !== edits.type || + goal.period !== edits.period || + goal.target !== edits.target + ); + }; + + return ( +
+
+

Your Goals

+ + {goals.length} / {maxGoals} + +
+ +
+ {goals.map((goal: Goal) => { + const progress = goal.progress_percentage; + const isExpanded = expandedGoalId === goal.id; + const currentEdits = editValues[goal.id]; + const hasChanges = hasUnsavedChanges(goal.id); + + const progressBarColor = + goal.type === "reduce_spending" ? "bg-red-500" : "bg-green-500"; + + return ( +
handleGoalClick(goal.id)} + > +
+ + {goal.name} - {goal.category} + + + {formatAmount(goal.current_amount)} /{" "} + {formatAmount(goal.target)} + +
+ +
+
+
+ +

+ {getGoalDescription(goal)} +

+ + {/* expanded details with editable fields */} + {isExpanded && currentEdits && ( +
+ {/* goal name */} +
+ Goal Name: + + handleFieldChange(goal.id, "name", e.target.value) + } + className="bg-black/50 border border-green-500/30 rounded px-2 py-1 text-sm text-white w-48" + onClick={(e) => e.stopPropagation()} + /> +
+ + {/* category */} +
+ Category: + + handleFieldChange(goal.id, "category", e.target.value) + } + className="bg-black/50 border border-green-500/30 rounded px-2 py-1 text-sm text-white w-48" + onClick={(e) => e.stopPropagation()} + /> +
+ + {/* goal type */} +
+ Goal Type: + +
+ + {/* period (only for reduce_spending) */} + {currentEdits.type === "reduce_spending" && ( +
+ Period: + +
+ )} + + {/* target amount */} +
+ + Target Amount: + + + handleFieldChange( + goal.id, + "target", + parseFloat(e.target.value) || 0, + ) + } + className="bg-black/50 border border-green-500/30 rounded px-2 py-1 text-sm text-white w-32 text-right" + onClick={(e) => e.stopPropagation()} + /> +
+ + {/* action buttons */} +
+ + +
+
+ )} +
+ ); + })} + + {/* add goal button */} + {goals.length < maxGoals && ( + + )} +
+
+ ); +} + +export default GoalsPanel; diff --git a/app/client/src/components/MarketDashboardSection.tsx b/app/client/src/components/MarketDashboardSection.tsx new file mode 100644 index 0000000..c7e78a3 --- /dev/null +++ b/app/client/src/components/MarketDashboardSection.tsx @@ -0,0 +1,388 @@ +import { useEffect, useMemo, useState } from "react"; + +import { getMarketHistory, getMarketQuote, searchMarkets } from "@/api/Market"; +import PinnedInstrumentsSection from "@/components/market/PinnedInstrumentsSection"; +import MarketSearchBar from "@/components/market/MarketSearchBar"; +import MarketSearchResultsPanel from "@/components/market/MarketSearchResultsPanel"; +import SelectedInstrumentPanel from "@/components/market/SelectedInstrumentPanel"; +import { + fromPinnedInstrument, + toPinnedInstrument, +} from "@/components/market/marketSectionHelpers"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { + MarketHistoryPeriod, + MarketHistoryPoint, + MarketInstrument, + MarketInstrumentType, +} from "@/types/Market"; +import { mergeInstrumentQuote, toChartSeries } from "@/utils/market"; +import { + loadPinnedMarkets, + PINNED_MARKETS_CLEARED_EVENT, + savePinnedMarkets, +} from "@/utils/marketStorage"; + +function isMarketInstrumentType(value: unknown): value is MarketInstrumentType { + return value === "stock" || value === "forex"; +} + +function normalizeMarketInstrument(value: unknown): MarketInstrument | null { + if (!value || typeof value !== "object") { + return null; + } + + const item = value as Partial; + if (typeof item.symbol !== "string" || typeof item.name !== "string") { + return null; + } + + return { + symbol: item.symbol, + displaySymbol: + typeof item.displaySymbol === "string" ? item.displaySymbol : item.symbol, + name: item.name, + type: isMarketInstrumentType(item.type) ? item.type : "stock", + currency: typeof item.currency === "string" ? item.currency : "USD", + exchange: typeof item.exchange === "string" ? item.exchange : undefined, + price: typeof item.price === "number" ? item.price : null, + change: typeof item.change === "number" ? item.change : null, + changePercent: + typeof item.changePercent === "number" ? item.changePercent : null, + timestamp: typeof item.timestamp === "number" ? item.timestamp : null, + }; +} + +export default function MarketDashboardSection() { + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); + const [searchError, setSearchError] = useState(null); + const [selectedPeriod, setSelectedPeriod] = + useState("6mo"); + const [selectedHistory, setSelectedHistory] = useState( + [], + ); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); + const [pinnedInstruments, setPinnedInstruments] = useState< + MarketInstrument[] + >(() => loadPinnedMarkets().map(fromPinnedInstrument)); + const [hydratingPinnedSymbols, setHydratingPinnedSymbols] = useState< + Record + >({}); + const [hydratedPinnedSymbols, setHydratedPinnedSymbols] = useState< + Record + >({}); + const [selectedInstrument, setSelectedInstrument] = + useState(() => { + const [firstPinned] = loadPinnedMarkets(); + return firstPinned ? fromPinnedInstrument(firstPinned) : null; + }); + + const pinnedSymbols = useMemo( + () => new Set(pinnedInstruments.map((instrument) => instrument.symbol)), + [pinnedInstruments], + ); + const selectedChartData = useMemo( + () => toChartSeries(selectedHistory), + [selectedHistory], + ); + const selectedSymbol = selectedInstrument?.symbol ?? null; + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setDebouncedSearchTerm(searchTerm.trim()); + }, 250); + + return () => window.clearTimeout(timeoutId); + }, [searchTerm]); + + useEffect(() => { + function handlePinnedMarketsCleared() { + const nextPinned = loadPinnedMarkets().map(fromPinnedInstrument); + setPinnedInstruments(nextPinned); + setHydratingPinnedSymbols({}); + setHydratedPinnedSymbols({}); + setSelectedInstrument((current) => { + if (nextPinned.length > 0) { + return nextPinned[0]; + } + + if (current) { + const refreshedSelection = searchResults.find( + (instrument) => instrument.symbol === current.symbol, + ); + return refreshedSelection ?? searchResults[0] ?? null; + } + + return searchResults[0] ?? null; + }); + } + + window.addEventListener( + PINNED_MARKETS_CLEARED_EVENT, + handlePinnedMarketsCleared, + ); + + return () => { + window.removeEventListener( + PINNED_MARKETS_CLEARED_EVENT, + handlePinnedMarketsCleared, + ); + }; + }, [searchResults]); + + useEffect(() => { + let cancelled = false; + + async function loadSearchResults() { + setSearchLoading(true); + setSearchError(null); + + try { + const payload = await searchMarkets(debouncedSearchTerm); + const nextResults = payload + .map(normalizeMarketInstrument) + .filter((item): item is MarketInstrument => item !== null); + + if (cancelled) { + return; + } + + setSearchResults(nextResults); + setSelectedInstrument((current) => { + if (!current) { + return nextResults[0] ?? null; + } + + const refreshedSelection = nextResults.find( + (instrument) => instrument.symbol === current.symbol, + ); + return refreshedSelection ?? current; + }); + } catch (error) { + if (cancelled) { + return; + } + + setSearchResults([]); + setSearchError( + error instanceof Error + ? error.message + : "Unable to load market instruments right now.", + ); + } finally { + if (!cancelled) { + setSearchLoading(false); + } + } + } + + void loadSearchResults(); + + return () => { + cancelled = true; + }; + }, [debouncedSearchTerm]); + + function syncInstrumentQuote( + symbol: string, + quote: Pick< + MarketInstrument, + "price" | "change" | "changePercent" | "timestamp" + >, + ) { + setSearchResults((current) => + current.map((instrument) => + instrument.symbol === symbol + ? mergeInstrumentQuote(instrument, quote) + : instrument, + ), + ); + setPinnedInstruments((current) => + current.map((instrument) => + instrument.symbol === symbol + ? mergeInstrumentQuote(instrument, quote) + : instrument, + ), + ); + setSelectedInstrument((current) => + current && current.symbol === symbol + ? mergeInstrumentQuote(current, quote) + : current, + ); + } + + useEffect(() => { + if (!selectedSymbol) { + setSelectedHistory([]); + setHistoryError(null); + setHistoryLoading(false); + return; + } + + const activeSymbol = selectedSymbol; + let cancelled = false; + + async function loadSelectedInstrumentDetails() { + setHistoryLoading(true); + setHistoryError(null); + + try { + const [quote, history] = await Promise.all([ + getMarketQuote(activeSymbol), + getMarketHistory(activeSymbol, selectedPeriod), + ]); + + if (cancelled) { + return; + } + + syncInstrumentQuote(activeSymbol, quote); + setSelectedHistory(history.points); + } catch (error) { + if (cancelled) { + return; + } + + setSelectedHistory([]); + setHistoryError( + error instanceof Error + ? error.message + : "Unable to load market history.", + ); + } finally { + if (!cancelled) { + setHistoryLoading(false); + } + } + } + + void loadSelectedInstrumentDetails(); + + return () => { + cancelled = true; + }; + }, [selectedPeriod, selectedSymbol]); + + useEffect(() => { + for (const instrument of pinnedInstruments) { + if (hydratedPinnedSymbols[instrument.symbol]) { + continue; + } + + if (hydratingPinnedSymbols[instrument.symbol]) { + continue; + } + + void hydratePinnedInstrument(instrument); + } + }, [hydratedPinnedSymbols, hydratingPinnedSymbols, pinnedInstruments]); + + async function hydratePinnedInstrument(instrument: MarketInstrument) { + setHydratingPinnedSymbols((current) => ({ + ...current, + [instrument.symbol]: true, + })); + + try { + const quote = await getMarketQuote(instrument.symbol); + syncInstrumentQuote(instrument.symbol, quote); + } catch (error) { + console.error("Failed to hydrate pinned market instrument", error); + } finally { + setHydratingPinnedSymbols((current) => ({ + ...current, + [instrument.symbol]: false, + })); + setHydratedPinnedSymbols((current) => ({ + ...current, + [instrument.symbol]: true, + })); + } + } + + function persistPinned(nextItems: MarketInstrument[]) { + savePinnedMarkets(nextItems.map(toPinnedInstrument)); + } + + function handleTogglePin(instrument: MarketInstrument) { + if (pinnedSymbols.has(instrument.symbol)) { + setPinnedInstruments((current) => { + const nextItems = current.filter( + (item) => item.symbol !== instrument.symbol, + ); + persistPinned(nextItems); + return nextItems; + }); + setHydratedPinnedSymbols((current) => { + const nextState = { ...current }; + delete nextState[instrument.symbol]; + return nextState; + }); + setHydratingPinnedSymbols((current) => { + const nextState = { ...current }; + delete nextState[instrument.symbol]; + return nextState; + }); + return; + } + + setPinnedInstruments((current) => { + const nextPinned = [...current, instrument]; + persistPinned(nextPinned); + return nextPinned; + }); + setHydratedPinnedSymbols((current) => ({ + ...current, + [instrument.symbol]: false, + })); + } + + return ( +
+ + + Stock and Forex Market + + + + + + + +
+ + + +
+
+
+
+ ); +} diff --git a/app/client/src/components/NavBar.tsx b/app/client/src/components/NavBar.tsx index 7b4ad65..bbc42a1 100644 --- a/app/client/src/components/NavBar.tsx +++ b/app/client/src/components/NavBar.tsx @@ -1,6 +1,8 @@ import { Sidebar } from "react-pro-sidebar"; import { Link, useLocation } from "react-router-dom"; import { CgHome } from "react-icons/cg"; +import { AiOutlineFundProjectionScreen } from "react-icons/ai"; +import { MdCandlestickChart } from "react-icons/md"; import { FaSignOutAlt, FaTimes } from "react-icons/fa"; import { SIDEBAR_WIDTH } from "@/utils/constants"; type NavigationBarProps = { @@ -12,7 +14,15 @@ function NavBar({ isOpen, onClose, onLogout }: NavigationBarProps) { const location = useLocation(); if (!isOpen) return null; - const navItems = [{ to: "/dashboard", label: "Dashboard", icon: }]; + const navItems = [ + { to: "/dashboard", label: "Dashboard", icon: }, + { to: "/markets", label: "Markets", icon: }, + { + to: "/projection", + label: "Projection", + icon: , + }, + ]; return ( <> diff --git a/app/client/src/components/ProjectionGraph.tsx b/app/client/src/components/ProjectionGraph.tsx new file mode 100644 index 0000000..b6a3310 --- /dev/null +++ b/app/client/src/components/ProjectionGraph.tsx @@ -0,0 +1,90 @@ +import type { projectedDataResponse } from "@/types/responseTypes"; +import type { ChartData, ChartOptions } from "chart.js"; +import { Line } from "react-chartjs-2"; +import { Wallet } from "lucide-react"; +import NoItemState from "./NoItemState"; + +interface graphProp { + data: projectedDataResponse; + name: string; +} + +export default function ProjectionGraph({ data, name }: graphProp) { + const colors = [ + "rgba(34, 250, 94, 1)", + "rgba(206, 232, 11, 1)", + "rgba(197, 34, 34, 1)", + ]; + + const chartOptions: ChartOptions<"line"> = { + responsive: true, + plugins: { + legend: { + position: "top", + labels: { + font: { + size: 16, + weight: "bold", + }, + padding: 50, + }, + }, + }, + backgroundColor: "rgb(255, 255, 255, 0.8)", + scales: { + x: { + ticks: { + font: { + size: 14, + weight: "bold", // Set the font weight to bold + }, + }, + }, + y: { + beginAtZero: true, + ticks: { + font: { + size: 14, + weight: "bold", // Set the font weight to bold + }, + }, + }, + }, + }; + + console.log(data); + const chartData: ChartData<"line"> = { + labels: data.dateLabel, + datasets: data.lineInfo.map((line, index) => ({ + label: line.name, + data: line.data, + fill: true, + borderColor: colors[index % colors.length], + backgroundColor: colors[index % colors.length].replace("1)", "0.55)"), + borderWidth: 1, + })), + }; + + return ( + <> + {chartData ? ( +
+

{name}

+ +
+ ) : ( + <> + } + /> + + )} + + ); +} diff --git a/app/client/src/components/SelectAccount.tsx b/app/client/src/components/SelectAccount.tsx new file mode 100644 index 0000000..b542d4a --- /dev/null +++ b/app/client/src/components/SelectAccount.tsx @@ -0,0 +1,42 @@ +import type { Account } from "@/types/AccountType"; +import React from "react"; +import { accountCategory } from "@/enum/AccountCategory.ts"; + +interface selectProp { + accounts: Account[]; + selectedAccount: number; + handleSelectAccount: (event: React.ChangeEvent) => void; +} + +export default function SelectAccount({ + accounts, + selectedAccount, + handleSelectAccount, +}: selectProp) { + console.log(accounts); + return ( + <> + + + + ); +} diff --git a/app/client/src/components/SelectDebt.tsx b/app/client/src/components/SelectDebt.tsx new file mode 100644 index 0000000..1e21e03 --- /dev/null +++ b/app/client/src/components/SelectDebt.tsx @@ -0,0 +1,34 @@ +import { type Debt } from "@/types/Debt"; +import React, { type SetStateAction } from "react"; + +interface selectProp { + debts: Debt[]; + selectedDebt: number; + setSelectedDebt: React.Dispatch>; +} + +//Component that puts out a selection of all the user's debts +export default function SelectDebt({ + debts, + selectedDebt, + setSelectedDebt, +}: selectProp) { + return ( + <> + + + + ); +} diff --git a/app/client/src/components/TransactionCard.tsx b/app/client/src/components/TransactionCard.tsx index 6401b24..4e4c600 100644 --- a/app/client/src/components/TransactionCard.tsx +++ b/app/client/src/components/TransactionCard.tsx @@ -43,7 +43,9 @@ export default function Card({

{transaction.id}

-

{transaction.amount + " " + transaction.date}

+

{"$" + transaction.amount.toFixed(2)}

+

{"Date:" + transaction.date}

+

{"Type: " + transaction.category}



{transaction.description}

diff --git a/app/client/src/components/TransactionList.tsx b/app/client/src/components/TransactionList.tsx index 85deae9..8e6fef8 100644 --- a/app/client/src/components/TransactionList.tsx +++ b/app/client/src/components/TransactionList.tsx @@ -6,6 +6,7 @@ import TransactionCard from "./TransactionCard"; import type { AuthSession } from "@/types/authTypes"; import { type Account } from "@/types/AccountType"; import { getUserAccounts } from "@/api/Account"; +import SelectAccount from "./SelectAccount"; interface listProp { session: AuthSession; @@ -13,7 +14,7 @@ interface listProp { export default function TransactionList({ session }: listProp) { const [userAccounts, setUserAccounts] = useState([]); - const [selectedAccount, setSelectedAccount] = useState(""); + const [selectedAccount, setSelectedAccount] = useState(0); const [accountTransactions, setAccountTransactions] = useState( [], ); @@ -85,20 +86,13 @@ export default function TransactionList({ session }: listProp) { <>
- - + , + ) => setSelectedAccount(Number(event.target.value))} + />
diff --git a/app/client/src/components/TransationForm.tsx b/app/client/src/components/TransationForm.tsx index d26ca3a..6c0609d 100644 --- a/app/client/src/components/TransationForm.tsx +++ b/app/client/src/components/TransationForm.tsx @@ -9,6 +9,7 @@ import { type Transaction } from "../types/Transaction"; import { type Account } from "@/types/AccountType"; import { transactionCategory } from "@/enum/TransactionCategory"; import type { AuthSession } from "@/types/authTypes"; +import SelectAccount from "@/components/SelectAccount"; interface popupProp { toggle: () => void; @@ -215,24 +216,22 @@ export default function PopupForm({ <>
- {edit ?

Edit Transaction

:

Create Transaction

} + {edit ? ( +

Edit Transaction

+ ) : ( +

Create Transaction

+ )} + + {account && ( + , + ) => setSelectedAccount(Number(event.target.value))} + /> + )} - -

{selectedType == transactionCategory.INCOME ? ( @@ -244,6 +243,7 @@ export default function PopupForm({ id="other" type="text" value={other} + className="formInput" onChange={(event) => setOther(event.target.value)} >

@@ -252,6 +252,7 @@ export default function PopupForm({

- + setSelectedDate(event.target.value)} /> diff --git a/app/client/src/components/market/MarketSearchBar.tsx b/app/client/src/components/market/MarketSearchBar.tsx new file mode 100644 index 0000000..1baf1ed --- /dev/null +++ b/app/client/src/components/market/MarketSearchBar.tsx @@ -0,0 +1,34 @@ +import { Search } from "lucide-react"; + +import { Input } from "@/components/ui/input"; + +type MarketSearchBarProps = { + searchTerm: string; + onChange: (value: string) => void; +}; + +export default function MarketSearchBar({ + searchTerm, + onChange, +}: MarketSearchBarProps) { + return ( +
+ +
+ + onChange(event.target.value)} + placeholder="Try AAPL, NVDA, EUR/USD, or USDJPY" + className="h-12 border-white/10 bg-white/5 pl-10 text-white placeholder:text-emerald-100/35" + /> +
+
+ ); +} diff --git a/app/client/src/components/market/MarketSearchResultsPanel.tsx b/app/client/src/components/market/MarketSearchResultsPanel.tsx new file mode 100644 index 0000000..1884572 --- /dev/null +++ b/app/client/src/components/market/MarketSearchResultsPanel.tsx @@ -0,0 +1,147 @@ +import { Pin, TrendingDown, TrendingUp } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import type { MarketInstrument } from "@/types/Market"; +import { cn } from "@/utils/utils"; +import { + formatMarketPrice, + formatSignedPercent, + isPositiveChange, +} from "@/utils/market"; + +type MarketSearchResultsPanelProps = { + deferredSearchTerm: string; + searchLoading: boolean; + searchError: string | null; + searchResults: MarketInstrument[]; + selectedSymbol: string | null; + pinnedSymbols: Set; + onSelectInstrument: (instrument: MarketInstrument) => void; + onTogglePin: (instrument: MarketInstrument) => void; +}; + +export default function MarketSearchResultsPanel({ + deferredSearchTerm, + searchLoading, + searchError, + searchResults, + selectedSymbol, + pinnedSymbols, + onSelectInstrument, + onTogglePin, +}: MarketSearchResultsPanelProps) { + return ( +
+
+
+

Search results

+

+ {deferredSearchTerm + ? `Showing matches for "${deferredSearchTerm}"` + : "Featured market instruments"} +

+
+ {searchLoading && ( + Loading... + )} +
+ +
+ {searchError && ( +
+ {searchError} +
+ )} + + {!searchError && !searchLoading && searchResults.length === 0 && ( +
+ No instruments matched that search. +
+ )} + + {searchResults.map((instrument) => { + const positive = isPositiveChange(instrument.change); + + return ( +
onSelectInstrument(instrument)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectInstrument(instrument); + } + }} + role="button" + tabIndex={0} + className={cn( + "flex w-full items-center justify-between gap-4 rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-3 text-left transition hover:border-emerald-300/30 hover:bg-white/[0.06]", + selectedSymbol === instrument.symbol && + "border-emerald-300/45 bg-emerald-400/10", + )} + > +
+
+

+ {instrument.displaySymbol} +

+ + {instrument.type} + +
+

+ {instrument.name} +

+
+ +
+
+

+ {formatMarketPrice(instrument.price, instrument.currency)} +

+
+ {positive ? ( + + ) : ( + + )} + {formatSignedPercent(instrument.changePercent)} +
+
+ + +
+
+ ); + })} +
+
+ ); +} diff --git a/app/client/src/components/market/PinnedInstrumentsSection.tsx b/app/client/src/components/market/PinnedInstrumentsSection.tsx new file mode 100644 index 0000000..b494432 --- /dev/null +++ b/app/client/src/components/market/PinnedInstrumentsSection.tsx @@ -0,0 +1,114 @@ +import { PinOff } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import type { MarketInstrument } from "@/types/Market"; +import { cn } from "@/utils/utils"; +import { formatMarketPrice, formatSignedPercent } from "@/utils/market"; +import { changeColorClass } from "@/components/market/marketSectionHelpers"; + +type PinnedInstrumentsSectionProps = { + pinnedInstruments: MarketInstrument[]; + selectedSymbol: string | null; + onSelectInstrument: (instrument: MarketInstrument) => void; + onTogglePin: (instrument: MarketInstrument) => void; +}; + +export default function PinnedInstrumentsSection({ + pinnedInstruments, + selectedSymbol, + onSelectInstrument, + onTogglePin, +}: PinnedInstrumentsSectionProps) { + return ( +
+
+
+

+ Pinned Forex and Stocks +

+
+ + {pinnedInstruments.length} pinned + +
+ + {pinnedInstruments.length === 0 ? ( +
+ Pin a stock or forex pair from the search results to keep it on your + dashboard. +
+ ) : ( +
+ {pinnedInstruments.map((instrument) => { + return ( +
onSelectInstrument(instrument)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectInstrument(instrument); + } + }} + role="button" + tabIndex={0} + className={cn( + "rounded-3xl border border-white/10 bg-white/[0.04] p-4 text-left transition hover:border-emerald-300/30 hover:bg-white/[0.06]", + selectedSymbol === instrument.symbol && + "border-emerald-300/45 bg-emerald-400/10 shadow-[0_12px_36px_rgba(16,185,129,0.14)]", + )} + > +
+
+

+ {instrument.displaySymbol} +

+

+ {instrument.name} +

+
+ +
+ +
+
+

+ {formatMarketPrice(instrument.price, instrument.currency)} +

+

+ {formatSignedPercent(instrument.changePercent)} +

+
+ + {instrument.type} + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/app/client/src/components/market/SelectedInstrumentPanel.tsx b/app/client/src/components/market/SelectedInstrumentPanel.tsx new file mode 100644 index 0000000..4a8953f --- /dev/null +++ b/app/client/src/components/market/SelectedInstrumentPanel.tsx @@ -0,0 +1,301 @@ +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Activity, Pin, PinOff, TrendingDown, TrendingUp } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { MarketHistoryPeriod, MarketInstrument } from "@/types/Market"; +import { cn } from "@/utils/utils"; +import { + MARKET_PERIOD_OPTIONS, + formatMarketPrice, + formatMarketTimestamp, + formatSignedPercent, + formatSignedValue, + isPositiveChange, +} from "@/utils/market"; +import { changeColorClass } from "@/components/market/marketSectionHelpers"; + +type ChartPoint = { + timestamp: number; + price: number; +}; + +type SelectedInstrumentPanelProps = { + selectedInstrument: MarketInstrument | null; + pinnedSymbols: Set; + selectedPeriod: MarketHistoryPeriod; + selectedChartData: ChartPoint[]; + historyError: string | null; + historyLoading: boolean; + onTogglePin: (instrument: MarketInstrument) => void; + onSelectPeriod: (period: MarketHistoryPeriod) => void; +}; + +export default function SelectedInstrumentPanel({ + selectedInstrument, + pinnedSymbols, + selectedPeriod, + selectedChartData, + historyError, + historyLoading, + onTogglePin, + onSelectPeriod, +}: SelectedInstrumentPanelProps) { + const firstTimestamp = selectedChartData[0]?.timestamp ?? 0; + const lastTimestamp = + selectedChartData[selectedChartData.length - 1]?.timestamp ?? 0; + const totalSpanMs = Math.max(lastTimestamp - firstTimestamp, 0); + + const axisLabelFormatter = + totalSpanMs <= 1000 * 60 * 60 * 36 + ? new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + }) + : totalSpanMs <= 1000 * 60 * 60 * 24 * 7 + ? new Intl.DateTimeFormat("en-US", { + weekday: "short", + hour: "numeric", + }) + : new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: selectedChartData.length > 180 ? "2-digit" : undefined, + }); + + const tooltipLabelFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: totalSpanMs <= 1000 * 60 * 60 * 24 * 14 ? "short" : undefined, + }); + + return ( +
+ {selectedInstrument ? ( + <> +
+
+
+

+ {selectedInstrument.displaySymbol} +

+ + {selectedInstrument.type === "stock" ? "Stock" : "Forex"} + + {selectedInstrument.exchange && ( + + {selectedInstrument.exchange} + + )} +
+

+ {selectedInstrument.name} +

+ +
+
+

+ {formatMarketPrice( + selectedInstrument.price, + selectedInstrument.currency, + )} +

+
+ {isPositiveChange(selectedInstrument.change) ? ( + + ) : ( + + )} + + {formatSignedValue(selectedInstrument.change, 2)} + + + {formatSignedPercent(selectedInstrument.changePercent)} + +
+
+ +
+ Updated {formatMarketTimestamp(selectedInstrument.timestamp)} +
+
+
+ + +
+ +
+ {MARKET_PERIOD_OPTIONS.map((option) => ( + + ))} +
+ +
+ {historyError ? ( +
+ {historyError} +
+ ) : historyLoading ? ( +
+ Loading historical data... +
+ ) : selectedChartData.length > 1 ? ( +
+ + + + + + + + + + + axisLabelFormatter.format(new Date(Number(value))) + } + /> + + formatMarketPrice( + Number(value), + selectedInstrument.currency, + ) + } + /> + + tooltipLabelFormatter.format(new Date(Number(value))) + } + formatter={(value) => [ + formatMarketPrice( + Number(value ?? 0), + selectedInstrument.currency, + ), + "Price", + ]} + /> + + + +
+ ) : ( +
+ Historical data is unavailable for this period. +
+ )} +
+ + ) : ( +
+
+ +
+

+ Pick a stock or forex option to inspect +

+
+ )} +
+ ); +} diff --git a/app/client/src/components/market/marketSectionHelpers.ts b/app/client/src/components/market/marketSectionHelpers.ts new file mode 100644 index 0000000..452b4bc --- /dev/null +++ b/app/client/src/components/market/marketSectionHelpers.ts @@ -0,0 +1,48 @@ +import type { + MarketHistoryPeriod, + MarketHistoryPoint, + MarketInstrument, + PinnedMarketInstrument, +} from "@/types/Market"; +import { isPositiveChange } from "@/utils/market"; + +const PINNED_HISTORY_PERIOD: MarketHistoryPeriod = "1mo"; + +type PinnedHistoryMap = Record; + +function toPinnedInstrument( + instrument: MarketInstrument, +): PinnedMarketInstrument { + return { + symbol: instrument.symbol, + displaySymbol: instrument.displaySymbol, + name: instrument.name, + type: instrument.type, + currency: instrument.currency, + exchange: instrument.exchange, + }; +} + +function fromPinnedInstrument( + instrument: PinnedMarketInstrument, +): MarketInstrument { + return { + ...instrument, + price: null, + change: null, + changePercent: null, + timestamp: null, + }; +} + +function changeColorClass(change: number | null) { + return isPositiveChange(change) ? "text-emerald-300" : "text-rose-300"; +} + +export { + changeColorClass, + fromPinnedInstrument, + PINNED_HISTORY_PERIOD, + toPinnedInstrument, +}; +export type { PinnedHistoryMap }; diff --git a/app/client/src/components/userForm.css b/app/client/src/components/userForm.css index 999dbe1..fb24786 100644 --- a/app/client/src/components/userForm.css +++ b/app/client/src/components/userForm.css @@ -1,33 +1,44 @@ .popupForm { - background-color: lightgrey; + background-color: rgba(14, 15, 15, 0.776); padding: 15px; place-content: start; + width: max-content; height: auto; + white-space: nowrap; } -input { +.formInput { margin-top: 2.5%; margin-bottom: 2.5%; margin-left: 1%; + margin-right: 1%; + background-color: rgba(0, 0, 0, 0.566); + padding-left: 2%; + border-color: black; + border-width: 2px; } -select { +.formSelect { margin-top: 2.5%; margin-bottom: 2.5%; margin-left: 1%; + background-color: rgba(0, 0, 0, 0.566); + padding-left: 2%; + border-color: black; + border-width: 2px; } .popup { color: black; - background-color: floralwhite; - padding: 15px; + background-color: rgb(34, 206, 66); + padding: 10px; border-radius: 5px; border-style: solid; border-width: 2px; - border-color: black; + border-color: rgba(34, 250, 94, 1); position: fixed; - top: 0; + top: 10%; bottom: 0; right: 35%; left: 35%; @@ -36,20 +47,29 @@ select { height: max-content; font-size: 10pt; + color: antiquewhite; justify-content: center; align-items: center; } -button { +.formButton { padding: 5px; border-width: 1px; border-color: black; border-radius: 5px; } +.formH2 { + font-weight: bold; + text-align: center; + padding-bottom: 2%; +} + .bottomButtons { + margin-top: 5px; display: flex; + color: black; } .bottomButtons :first-child { diff --git a/app/client/src/enum/AccountCategory.ts b/app/client/src/enum/AccountCategory.ts index 6b084df..b80d1ae 100644 --- a/app/client/src/enum/AccountCategory.ts +++ b/app/client/src/enum/AccountCategory.ts @@ -1,6 +1,7 @@ export const accountCategory = { - SAVING: "Saving", + SAVING: "Savings", CHEQUING: "Chequing", INVESTMENT: "Investment", - DEBT: "Debt", + CREDIT_CARD: "Credit Card", + UNCONFIRMED: "Unconfirmed", }; diff --git a/app/client/src/pages/MarketsPage.tsx b/app/client/src/pages/MarketsPage.tsx new file mode 100644 index 0000000..2d7ee09 --- /dev/null +++ b/app/client/src/pages/MarketsPage.tsx @@ -0,0 +1,57 @@ +import MarketDashboardSection from "@/components/MarketDashboardSection"; +import type { AuthSession } from "@/types/authTypes"; + +type MarketsPageProps = { + session?: AuthSession; +}; + +function MarketsPage({ session }: MarketsPageProps) { + const glowLeft = ( +
+ ); + + const glowRight = ( +
+ ); + + return ( +
+ {glowLeft} + {glowRight} +

+ Markets for {session?.user.first_name ?? session?.user.name ?? "you"} +

+

+ Search for stocks and foreign exchange options +

+ +
+ ); +} + +export default MarketsPage; diff --git a/app/client/src/pages/ProjectionPage.tsx b/app/client/src/pages/ProjectionPage.tsx new file mode 100644 index 0000000..b520687 --- /dev/null +++ b/app/client/src/pages/ProjectionPage.tsx @@ -0,0 +1,536 @@ +import { + Chart, + PointElement, + LineElement, + ArcElement, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, +} from "chart.js"; + +import type { AuthSession } from "../types/authTypes"; +import SelectAccount from "@/components/SelectAccount"; +import React, { useEffect, useState } from "react"; +import { type Account } from "@/types/AccountType"; +import { getDebt, getDebtProjection } from "@/api/Debt"; +import { getSaving, getSavingProjection } from "@/api/Saving"; +import { + type LineInfo, + type projectedDataResponse, +} from "@/types/responseTypes"; +import ProjectionGraph from "@/components/ProjectionGraph"; +import { TbGraph } from "react-icons/tb"; +import NoItemState from "@/components/NoItemState"; +import { handleCurrencyChange, handleCurrencyBlur } from "@/utils/handleInput"; +import type { + projectionDebtRequest, + projectionSavingRequest, +} from "@/types/requestTypes"; +import { + validateDebtProjection, + validateSavingProjection, +} from "@/utils/ValidateProjectionRequest"; +import { accountCategory } from "@/enum/AccountCategory"; + +Chart.register( + PointElement, + LineElement, + ArcElement, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, +); + +type ProjectionProp = { + session: AuthSession; +}; + +function ProjectionPage({ session }: ProjectionProp) { + const [accounts, setAccounts] = useState([]); + const [debts, setDebts] = useState([]); + const [selectedAccount, setSelectedAccount] = useState(0); + const [selectedDebt, setSelectedDebt] = useState(0); + const [nextDueDate, setNextDueDate] = useState(""); + const [interest, setInterest] = useState(""); + const [amount, setAmount] = useState(""); + const [period, setPeriod] = useState(""); + const [minPay, setMinPay] = useState(""); + + const [selectedType, setSelectedType] = useState<"Saving" | "Debt">("Saving"); + + const [savingData, setSavingData] = useState< + projectedDataResponse | undefined + >(undefined); + const [debtData, setDebtData] = useState( + undefined, + ); + + const [debtRequest, setDebtRequest] = useState< + projectionDebtRequest | undefined + >(undefined); + const [savingRequest, setSavingRequest] = useState< + projectionSavingRequest | undefined + >(undefined); + + //Total amount of interest generated + const [totalInterest, setTotalInterest] = useState(0); + //Total amoutn of pay + const [totalPay, setTotalPay] = useState(0); + + const calculateProjection = () => { + const inputAmount = Number(amount); + const inputMinPay = Number(minPay); + const inputInterest = Number(interest); + const inputPeriod = Number(period); + + switch (selectedType) { + case "Debt": + if ( + validateDebtProjection( + selectedDebt, + inputAmount, + inputMinPay, + inputInterest, + nextDueDate, + inputPeriod, + ) + ) { + const newDebtRequest: projectionDebtRequest = { + id: selectedAccount.toString(), + category: accountCategory.CREDIT_CARD, + remainingAmount: inputAmount, + minimumPayment: inputMinPay, + interestRate: inputInterest / 100, + nextDueDate: nextDueDate, + period: inputPeriod, + }; + + setDebtRequest(newDebtRequest); + + getDebtProjection(session, newDebtRequest) + .then((data) => { + let dataTotalInterest = 0; + let dataTotalPay = 0; + const debtLineData: LineInfo = { + name: "Remaining Debt", + data: [], + }; + console.log(data); + if (data) { + const graphData: projectedDataResponse = { + lineInfo: [], + dateLabel: [], + }; + data.debtStages.map((stage) => { + debtLineData.data.push(stage.remainingDebt); + graphData.dateLabel.push(stage.installmentDate); + dataTotalInterest += stage.interestAmount; + dataTotalPay += stage.principalAmount; + }); + + graphData.lineInfo = [debtLineData]; + + setDebtData(graphData); + setTotalPay(dataTotalPay); + setTotalInterest(dataTotalInterest); + } + }) + .catch((error) => { + alert(error); + }); + } else { + alert("Please enter all fields"); + } + break; + + case "Saving": + if ( + validateSavingProjection( + selectedAccount, + inputInterest, + inputAmount, + inputMinPay, + inputPeriod, + ) + ) { + const newSavingRequest: projectionSavingRequest = { + financial_account_id: selectedAccount, + balance: inputAmount, + monthly_deposit: inputMinPay, + annual_interest_rate: inputInterest / 100, + time_frame: inputPeriod, + }; + + setSavingRequest(newSavingRequest); + + getSavingProjection(session, newSavingRequest) + .then((data) => { + console.log(data); + const graphData: projectedDataResponse = { + dateLabel: [], + lineInfo: [], + }; + const bestCase: LineInfo = { name: "Best Case", data: [] }; + const expectedCase: LineInfo = { + name: "Expected Case", + data: [], + }; + const worstCase: LineInfo = { name: "Worst Case", data: [] }; + + if (data) { + //Extract all the data from the response + data.map((datapoint) => { + graphData.dateLabel.push(datapoint.date); + bestCase.data.push(datapoint.accumulative_best_balance); + expectedCase.data.push( + datapoint.accumulative_expected_balance, + ); + worstCase.data.push(datapoint.accumulative_worst_balance); + }); + + graphData.lineInfo = [bestCase, expectedCase, worstCase]; + setSavingData(graphData); + } else { + alert("Failed to get the projection "); + } + }) + .catch(() => { + alert("Failed to get the projection"); + }); + } else { + alert("Please enter all fields"); + } + } + }; + + const handleTypeChange = (typeChange: "Saving" | "Debt") => { + setSelectedType(typeChange); + + if (selectedType === "Debt") { + if (debtRequest) { + //Set the fields to what was used in the debt projection graph + setSelectedAccount(Number(debtRequest.id)); + setAmount(debtRequest.remainingAmount.toFixed(2)); + setInterest(debtRequest.interestRate.toString()); + setMinPay(debtRequest.minimumPayment.toFixed(2)); + setNextDueDate(debtRequest.nextDueDate); + setPeriod(debtRequest.period.toString()); + } else { + setSelectedDebt(0); + setAmount(""); + setInterest(""); + setMinPay(""); + setPeriod(""); + setNextDueDate(""); + } + } else if (selectedType === "Saving") { + if (savingRequest) { + //Assigns the fields to what the projection of the saving account used + setSelectedAccount(savingRequest.financial_account_id); + setAmount(savingRequest.balance.toFixed(2)); + setInterest(savingRequest.annual_interest_rate.toString()); + setMinPay(savingRequest.monthly_deposit.toFixed(2)); + setPeriod(savingRequest.time_frame.toString()); + } else { + //Resets the fields + setSelectedAccount(0); + setAmount(""); + setInterest(""); + setMinPay(""); + setPeriod(""); + } + } + }; + + //Onchange handler for the select accounts + const handleSelectAccount = (event: React.ChangeEvent) => { + let target; + const id = Number(event.target.value); + + if (selectedType === "Debt") { + setSelectedDebt(id); + + target = debts.find((account) => account.id === id)?.balance; + } else if (selectedType === "Saving") { + setSelectedAccount(id); + + target = accounts.find((account) => account.id === id)?.balance; + } + + if (target) { + setAmount(target.toFixed(2)); + } else { + setAmount("0"); + } + }; + + //Handles on change of percentage + const handleNumberChange = ( + event: React.ChangeEvent, + setNumber: React.Dispatch>, + min: number, + max: number, + ) => { + let input = event.target.value; + let changeInterest; + const pattern = /^\d*\.?\d{0,2}$/; + + console.log(input); + console.log(pattern.test(input)); + + //Determine if the input follows the format/pattern + if (pattern.test(input) || input === "") { + input = input.replace(/^0+(?=\d)/, ""); + changeInterest = Number(input); + + if (changeInterest > max) { + changeInterest = max; + } + + if (changeInterest < min) { + changeInterest = min; + } + console.log(changeInterest); + setNumber(changeInterest.toString()); + } + }; + //Get the saving accounts + useEffect(() => { + if (selectedType === "Saving") { + getSaving(session) + .then((userAccounts) => { + console.log(userAccounts); + + //Detemrine accounts exist + if (userAccounts) { + setAccounts(userAccounts); + } + }) + .catch(() => { + //alert("Failed to get accounts"); + }); + } + }, [session, selectedType]); + + //Gets the debt accounts + useEffect(() => { + if (selectedType === "Debt") { + getDebt(session) + .then((userDebts) => { + console.log(userDebts); + + //Detemrine if there are any debts + if (userDebts) { + setDebts(userDebts); + } + }) + .catch(() => {}); + } + }, [session, selectedType]); + + const typeButton = [ + { key: "Saving", label: "Saving" }, + { key: "Debt", label: "Debt" }, + ].map(({ key, label }) => ( + + )); + + const glowLeft = ( +
+ ); + + const glowRight = ( +
+ ); + return ( +
+ {glowLeft} + {glowRight} +

Projection

+

+ Here you can view a projection of you're saving's or debt +

+ +

Select Saving Account or Debt

+ +
+ {typeButton} +
+ +
+ {selectedType === "Saving" ? ( + + ) : ( + + )} +
+ <> + + handleCurrencyChange(event, setAmount)} + onBlur={(event) => handleCurrencyBlur(event, amount, setAmount)} + /> + + + handleCurrencyChange(event, setMinPay)} + onBlur={(event) => handleCurrencyBlur(event, minPay, setMinPay)} + /> + + + { + handleNumberChange(event, setPeriod, 0, 100); + }} + /> + + + + { + handleNumberChange(event, setInterest, 0, 100); + }} + /> + + {selectedType === "Debt" && ( + <> +

+ + setNextDueDate(event.target.value)} + /> + + )} +
+ + +
+ +
+ {selectedType === "Saving" ? ( + savingData ? ( + <> + + + ) : ( + <> + } + /> + + ) + ) : null} + + {selectedType === "Debt" ? ( + debtData ? ( + <> + +
+ {"Total Amount Paid:$" + + totalPay.toFixed(2) + + " Total Interest Paid:$" + + totalInterest.toFixed(2)} +
+ + ) : ( + <> + } + /> + + ) + ) : null} +
+
+ ); +} + +export default ProjectionPage; diff --git a/app/client/src/types/Debt.ts b/app/client/src/types/Debt.ts new file mode 100644 index 0000000..6f1a3c5 --- /dev/null +++ b/app/client/src/types/Debt.ts @@ -0,0 +1,16 @@ +export type Debt = { + id: string; + name: string; + category: string; + remainingAmount: number; + minimumPayment: number; + interestRate?: number; + nextDueDate: string; // format: YYYY-MM-DD + period: number; // number of days between each payment installment +}; + +export type DebtStage = { + paidAmount: number; + remainingDebt: number; + installmentDate: string; // YYYY-MM-DD +}; diff --git a/app/client/src/types/Market.ts b/app/client/src/types/Market.ts new file mode 100644 index 0000000..ace9cf1 --- /dev/null +++ b/app/client/src/types/Market.ts @@ -0,0 +1,33 @@ +export type MarketInstrumentType = "stock" | "forex"; + +export type MarketInstrument = { + symbol: string; + displaySymbol: string; + name: string; + type: MarketInstrumentType; + currency: string; + exchange?: string; + price: number | null; + change: number | null; + changePercent: number | null; + timestamp: number | null; +}; + +export type MarketHistoryPoint = { + timestamp: number; + price: number; +}; + +export type MarketHistoryPeriod = + | "1d" + | "5d" + | "1mo" + | "3mo" + | "6mo" + | "1y" + | "5y"; + +export type PinnedMarketInstrument = Pick< + MarketInstrument, + "symbol" | "displaySymbol" | "name" | "type" | "currency" | "exchange" +>; diff --git a/app/client/src/types/goals.ts b/app/client/src/types/goals.ts new file mode 100644 index 0000000..5e25225 --- /dev/null +++ b/app/client/src/types/goals.ts @@ -0,0 +1,31 @@ +export type GoalType = "reduce_spending" | "save"; // just two for simplicity, but can expand + +export interface BaseGoal { + id: string; + name: string; + type: GoalType; + category: string; + target: number; + period: "m" | "w"; //"na" for savings + progress_percentage: number; + current_amount: number; //this is the amount that has been saved or spent depending on goal type. This is aggregated on the server, and is not actually stored in DB +} + +export interface SpendingLimitGoal extends BaseGoal { + type: "reduce_spending"; + period: "m" | "w"; +} + +export interface SavingsTargetGoal extends BaseGoal { + type: "save"; +} + +export type Goal = SpendingLimitGoal | SavingsTargetGoal; + +export interface GoalsPanelProps { + goals: Goal[]; + onAddGoal: () => void; + onEditGoal: (goalId: string, updates: Partial) => void; + onDeleteGoal: (goalId: string) => void; + maxGoals?: number; +} diff --git a/app/client/src/types/requestTypes.ts b/app/client/src/types/requestTypes.ts new file mode 100644 index 0000000..01e3792 --- /dev/null +++ b/app/client/src/types/requestTypes.ts @@ -0,0 +1,17 @@ +export interface projectionDebtRequest { + id: string; + category: string; + remainingAmount: number; + minimumPayment: number; + interestRate: number; + nextDueDate: string; // format: YYYY-MM-DD + period: number; // number of days between each payment installment +} + +export interface projectionSavingRequest { + financial_account_id: number; + balance: number; + monthly_deposit: number; + annual_interest_rate: number; + time_frame: number; +} diff --git a/app/client/src/types/responseTypes.ts b/app/client/src/types/responseTypes.ts index b3ef6e0..6b4308f 100644 --- a/app/client/src/types/responseTypes.ts +++ b/app/client/src/types/responseTypes.ts @@ -3,3 +3,36 @@ export interface updateResponse { lastUpdated?: Date; id?: number; } + +export interface projectedDataResponse { + lineInfo: LineInfo[]; + dateLabel: string[]; +} + +export interface LineInfo { + data: number[]; + name: string; +} + +export type DebtPayoffResponse = { + id: string; + category: string; + minimumPayment: number; + interestRate: number; + debtStages: AdvancedDebtStage[]; +}; + +export type AdvancedDebtStage = { + id: number; + principalAmount: number; + interestAmount: number; + remainingDebt: number; + installmentDate: string; // YYYY-MM-DD +}; + +export interface savingProjectionResponseData { + accumulative_best_balance: number; + accumulative_expected_balance: number; + accumulative_worst_balance: number; + date: string; +} diff --git a/app/client/src/utils/NormalizeRow.ts b/app/client/src/utils/NormalizeRow.ts index ecd445d..f320a8f 100644 --- a/app/client/src/utils/NormalizeRow.ts +++ b/app/client/src/utils/NormalizeRow.ts @@ -58,8 +58,9 @@ function normalizeDate(input: string | null): string | null { return null; } - // helper to normalize amount fields + +// Stryker disable all function normalizeAmount(input: string | null): number | null { if (!input) return null; let amount = input.replace(/,/g, "").trim(); //trim diff --git a/app/client/src/utils/ValidateFile.ts b/app/client/src/utils/ValidateFile.ts index aa5eb7d..20bd1dd 100644 --- a/app/client/src/utils/ValidateFile.ts +++ b/app/client/src/utils/ValidateFile.ts @@ -13,7 +13,9 @@ export function validateFile(rows: TransactionDraft[]): FileValidationResult { //returns a summary of validation results for the whole file const validCount = rows.filter((r) => r.errors.length === 0).length; const invalidCount = rows.length - validCount; - const tooManyInvalid = rows.length > 0 && invalidCount / rows.length > 0.2; //if more than 20% of the rows are invalid we send a warning + // Stryker disable all + const tooManyInvalid = + rows.length === 0 ? false : invalidCount / rows.length > 0.2; //if more than 20% of the rows are invalid we send a warning return { validCount, diff --git a/app/client/src/utils/ValidateForms.ts b/app/client/src/utils/ValidateForms.ts index 10cb245..83d41a2 100644 --- a/app/client/src/utils/ValidateForms.ts +++ b/app/client/src/utils/ValidateForms.ts @@ -60,5 +60,5 @@ export function validateTransactionForm( } export function validateIncomeForm(name: string, income: number) { - return name && income > 0; + return name.trim().length > 0 && income > 0; } diff --git a/app/client/src/utils/ValidateProjectionRequest.ts b/app/client/src/utils/ValidateProjectionRequest.ts new file mode 100644 index 0000000..0d6349c --- /dev/null +++ b/app/client/src/utils/ValidateProjectionRequest.ts @@ -0,0 +1,35 @@ +export function validateDebtProjection( + id: number, + remainingAmount: number, + minimumPayment: number, + interestRate: number, + nextDueDate: string, + period: number, +) { + return ( + id && + remainingAmount >= 0 && + minimumPayment > 0 && + interestRate >= 0 && + interestRate <= 100 && + nextDueDate && + period + ); +} + +export function validateSavingProjection( + id: number, + interestRate: number, + balence: number, + monthly_deposit: number, + time_frame: number, +) { + return ( + id && + interestRate >= 0 && + interestRate <= 100 && + balence >= 0 && + monthly_deposit >= 0 && + time_frame > 0 + ); +} diff --git a/app/client/src/utils/handleInput.ts b/app/client/src/utils/handleInput.ts index c370db8..7d05c0b 100644 --- a/app/client/src/utils/handleInput.ts +++ b/app/client/src/utils/handleInput.ts @@ -11,7 +11,7 @@ export const handleCurrencyChange = ( console.log(input); console.log(pattern.test(input)); //Determine if the input follows the format/pattern - if (pattern.test(input) || input === "") { + if (pattern.test(input)) { input = input.replace(/^0+(?=\d)/, ""); setCurrency(input); } diff --git a/app/client/src/utils/market.ts b/app/client/src/utils/market.ts new file mode 100644 index 0000000..5393cf6 --- /dev/null +++ b/app/client/src/utils/market.ts @@ -0,0 +1,129 @@ +import type { + MarketHistoryPeriod, + MarketHistoryPoint, + MarketInstrument, +} from "@/types/Market"; + +const MARKET_PERIOD_OPTIONS: Array<{ + value: MarketHistoryPeriod; + label: string; +}> = [ + { value: "1d", label: "1D" }, + { value: "5d", label: "5D" }, + { value: "1mo", label: "1M" }, + { value: "3mo", label: "3M" }, + { value: "6mo", label: "6M" }, + { value: "1y", label: "1Y" }, + { value: "5y", label: "5Y" }, +]; + +function getIntervalForPeriod( + period: MarketHistoryPeriod, +): "5m" | "15m" | "1d" | "1wk" | "1mo" { + if (period === "1d") { + return "5m"; + } + + if (period === "5d") { + return "15m"; + } + + if (period === "1y") { + return "1wk"; + } + + if (period === "5y") { + return "1mo"; + } + + return "1d"; +} + +function formatMarketPrice(value: number | null, currency = "USD"): string { + if (value === null) { + return "N/A"; + } + + if (currency === "JPY") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: 2, + }).format(value); + } + + if (currency.length === 3) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: value >= 100 ? 2 : 4, + }).format(value); + } + + return value.toFixed(4); +} + +function formatSignedValue(value: number | null, digits = 2): string { + if (value === null) { + return "N/A"; + } + + const sign = value >= 0 ? "+" : ""; + return `${sign}${value.toFixed(digits)}`; +} + +function formatSignedPercent(value: number | null): string { + if (value === null) { + return "N/A"; + } + + const sign = value >= 0 ? "+" : ""; + return `${sign}${value.toFixed(2)}%`; +} + +function formatMarketTimestamp(timestamp: number | null): string { + if (!timestamp) { + return "Unavailable"; + } + + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(timestamp * 1000)); +} + +function isPositiveChange(value: number | null): boolean { + return (value ?? 0) >= 0; +} + +function toChartSeries(points: MarketHistoryPoint[]) { + return points.map((point) => ({ + timestamp: point.timestamp * 1000, + price: point.price, + })); +} + +function mergeInstrumentQuote( + instrument: MarketInstrument, + quote: Pick< + MarketInstrument, + "price" | "change" | "changePercent" | "timestamp" + >, +): MarketInstrument { + return { + ...instrument, + ...quote, + }; +} + +export { + MARKET_PERIOD_OPTIONS, + formatMarketPrice, + formatMarketTimestamp, + formatSignedPercent, + formatSignedValue, + getIntervalForPeriod, + isPositiveChange, + mergeInstrumentQuote, + toChartSeries, +}; diff --git a/app/client/src/utils/marketStorage.ts b/app/client/src/utils/marketStorage.ts new file mode 100644 index 0000000..36a8e74 --- /dev/null +++ b/app/client/src/utils/marketStorage.ts @@ -0,0 +1,106 @@ +import type { PinnedMarketInstrument } from "@/types/Market"; +import { loadSession } from "@/utils/storage"; + +const PINNED_MARKETS_STORAGE_KEY_PREFIX = "finus-pinned-markets"; +const LEGACY_PINNED_MARKETS_STORAGE_KEY = PINNED_MARKETS_STORAGE_KEY_PREFIX; +const SERVER_RESET_KEY_STORAGE_KEY = "finus-server-reset-key"; +const PINNED_MARKETS_CLEARED_EVENT = "finus:pinned-markets-cleared"; + +function resolvePinnedMarketsStorageKey(): string | null { + const session = loadSession(); + const userId = session?.user.id; + const userEmail = session?.user.email?.trim().toLowerCase(); + + if (typeof userId === "number" && Number.isFinite(userId)) { + return `${PINNED_MARKETS_STORAGE_KEY_PREFIX}:id:${userId}`; + } + + if (userEmail) { + return `${PINNED_MARKETS_STORAGE_KEY_PREFIX}:email:${userEmail}`; + } + + return null; +} + +function clearLegacyPinnedMarkets() { + localStorage.removeItem(LEGACY_PINNED_MARKETS_STORAGE_KEY); +} + +function clearAllPinnedMarkets() { + clearLegacyPinnedMarkets(); + + for (let index = localStorage.length - 1; index >= 0; index -= 1) { + const key = localStorage.key(index); + if (!key) { + continue; + } + + if (key.startsWith(`${PINNED_MARKETS_STORAGE_KEY_PREFIX}:`)) { + localStorage.removeItem(key); + } + } +} + +function loadPinnedMarkets(): PinnedMarketInstrument[] { + clearLegacyPinnedMarkets(); + + const storageKey = resolvePinnedMarketsStorageKey(); + if (!storageKey) { + return []; + } + + const raw = localStorage.getItem(storageKey); + if (!raw) { + return []; + } + + try { + const parsed = JSON.parse(raw) as PinnedMarketInstrument[]; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter( + (item) => + typeof item?.symbol === "string" && + typeof item?.displaySymbol === "string" && + typeof item?.name === "string" && + (item?.type === "stock" || item?.type === "forex") && + typeof item?.currency === "string", + ); + } catch { + return []; + } +} + +function savePinnedMarkets(items: PinnedMarketInstrument[]) { + clearLegacyPinnedMarkets(); + + const storageKey = resolvePinnedMarketsStorageKey(); + if (!storageKey) { + return; + } + + localStorage.setItem(storageKey, JSON.stringify(items)); +} + +function syncPinnedMarketsResetKey(resetKey: string): boolean { + const previousResetKey = localStorage.getItem(SERVER_RESET_KEY_STORAGE_KEY); + if (previousResetKey === resetKey) { + return false; + } + + clearAllPinnedMarkets(); + localStorage.setItem(SERVER_RESET_KEY_STORAGE_KEY, resetKey); + window.dispatchEvent(new Event(PINNED_MARKETS_CLEARED_EVENT)); + return true; +} + +export { + clearAllPinnedMarkets, + loadPinnedMarkets, + PINNED_MARKETS_CLEARED_EVENT, + savePinnedMarkets, + PINNED_MARKETS_STORAGE_KEY_PREFIX, + syncPinnedMarketsResetKey, +}; diff --git a/app/client/stryker.conf.json b/app/client/stryker.conf.json new file mode 100644 index 0000000..6d6ef8c --- /dev/null +++ b/app/client/stryker.conf.json @@ -0,0 +1,24 @@ +{ + "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "testRunner": "vitest", + "vitest": { + "configFile": "vitest.config.ts" + }, + "mutate": ["src/**/*.ts", "!src/**/*.d.ts"], + "reporters": ["html", "clear-text", "progress"], + "coverageAnalysis": "off", + "timeoutMS": 60000, + "ignorePatterns": [ + "src/api/**/*.ts", + "src/utils/constants.ts", + "src/utils/fakeData.ts", + "src/utils/market.ts", + "src/utils/marketStorage.ts", + "src/utils/storage.ts", + "src/utils/token.ts", + "src/enum/**/*.ts", + "src/utils/utils.ts", + "src/components", + "src/utils/ValidateForms.ts" + ] +} diff --git a/app/client/test/handleInput.test.ts b/app/client/test/handleInput.test.ts index cc52883..5a59393 100644 --- a/app/client/test/handleInput.test.ts +++ b/app/client/test/handleInput.test.ts @@ -90,3 +90,48 @@ describe("handleCurrencyBlur", () => { expect(setCurrency).toHaveBeenCalledWith("45.00"); }); }); + +it("accepts whole numbers without decimals", () => { + const setCurrency = vi.fn(); + const event = mockEvent("45"); + + handleCurrencyChange(event, setCurrency); + + expect(setCurrency).toHaveBeenCalledWith("45"); +}); + +it("does not accept whitespace-only input", () => { + const setCurrency = vi.fn(); + const event = mockEvent(" "); + + handleCurrencyChange(event, setCurrency); + + expect(setCurrency).not.toHaveBeenCalled(); +}); + +it("rejects non-empty strings that are not valid currency", () => { + const setCurrency = vi.fn(); + const event = mockEvent("Stryker was here!"); + + handleCurrencyChange(event, setCurrency); + + expect(setCurrency).not.toHaveBeenCalled(); +}); + +it("does not strip non-leading zeros", () => { + const setCurrency = vi.fn(); + const event = mockEvent("10.20"); + + handleCurrencyChange(event, setCurrency); + + expect(setCurrency).toHaveBeenCalledWith("10.20"); +}); + +it("does not strip zeros inside the number", () => { + const setCurrency = vi.fn(); + const event = mockEvent("101"); + + handleCurrencyChange(event, setCurrency); + + expect(setCurrency).toHaveBeenCalledWith("101"); +}); diff --git a/app/client/test/normalizeRow.test.ts b/app/client/test/normalizeRow.test.ts index 925a097..3807eb0 100644 --- a/app/client/test/normalizeRow.test.ts +++ b/app/client/test/normalizeRow.test.ts @@ -92,3 +92,95 @@ describe("normalizeRow", () => { expect(result.category).toBe("misc"); }); }); +it("normalizes dates with dots", () => { + const raw = { date: "2024.01.15" }; + const result = normalizeRow(raw); + expect(result.date).toBe("2024-01-15"); +}); + +it("normalizes dates with slashes", () => { + const raw = { date: "2024/01/15" }; + const result = normalizeRow(raw); + expect(result.date).toBe("2024-01-15"); +}); + +it("normalizes dates with spaces", () => { + const raw = { date: "2024 01 15" }; + const result = normalizeRow(raw); + expect(result.date).toBe("2024-01-15"); +}); + +it("rejects parentheses amounts with extra characters before", () => { + const raw = { + date: "2024-01-01", + description: "", + amount: "X(500.00)", + sender: "", + recipient: "", + category: "", + }; + const result = normalizeRow(raw); + expect(result.amount).toBeNull(); +}); + +it("rejects parentheses amounts with extra characters after", () => { + const raw = { + date: "2024-01-01", + description: "", + amount: "(500.00)X", + sender: "", + recipient: "", + category: "", + }; + const result = normalizeRow(raw); + expect(result.amount).toBeNull(); +}); + +it("rejects MM-DD-YYYY with extra characters before", () => { + const raw = { date: "X01-31-2024" }; + const result = normalizeRow(raw); + expect(result.date).toBeNull(); +}); + +it("rejects MM-DD-YYYY with extra characters after", () => { + const raw = { date: "01-31-2024X" }; + const result = normalizeRow(raw); + expect(result.date).toBeNull(); +}); +it("pads single-digit month and day", () => { + const raw = { date: "1-2-2024" }; + const result = normalizeRow(raw); + expect(result.date).toBe("2024-01-02"); +}); + +it("trims whitespace around amount", () => { + const raw = { amount: " 123.45 " }; + const result = normalizeRow(raw); + expect(result.amount).toBe(123.45); +}); + +it("parses negative parentheses amounts with leading whitespace", () => { + const raw = { + date: "2024-01-01", + description: "", + amount: " (500.00)", + sender: "", + recipient: "", + category: "", + }; + + const result = normalizeRow(raw); + expect(result.amount).toBe(-500); +}); + +it("rejects YYYY-MM-DD with leading garbage", () => { + const raw = { date: "X2024-01-15" }; + const result = normalizeRow(raw); + expect(result.date).toBeNull(); +}); + +it("rejects YYYY-MM-DD with trailing garbage", () => { + const raw = { date: "2024-01-15X" }; + const result = normalizeRow(raw); + expect(result.date).toBeNull(); +}); diff --git a/app/client/test/parseCsvFile.test.ts b/app/client/test/parseCsvFile.test.ts index eebe6ca..c4efc92 100644 --- a/app/client/test/parseCsvFile.test.ts +++ b/app/client/test/parseCsvFile.test.ts @@ -136,3 +136,67 @@ describe("parseCsvFile", () => { ); }); }); + +it("removes BOM using beforeFirstChunk", async () => { + const file = mockFile("bom.csv"); + const mockParse = Papa.parse as unknown as Mock; + + mockParse.mockImplementation((_file, config: FixedParseConfig) => { + // Simulate Papa calling beforeFirstChunk + const cleaned = config.beforeFirstChunk?.("\uFEFFa,b,c"); + + expect(cleaned).toBe("a,b,c"); + + const result: ParseResult = { + data: [], + errors: [], + meta: mockMeta, + }; + + config.complete?.(result, _file); + }); + + await parseCsvFile(file); +}); + +it("normalizes headers using transformHeader", async () => { + const file = mockFile("headers.csv"); + const mockParse = Papa.parse as unknown as Mock; + + mockParse.mockImplementation((_file, config: FixedParseConfig) => { + const transformed = config.transformHeader?.(" Amount ", 0); + + expect(transformed).toBe("amount"); + + const result: ParseResult = { + data: [], + errors: [], + meta: mockMeta, + }; + + config.complete?.(result, _file); + }); + + await parseCsvFile(file); +}); + +it("does not remove BOM when it is not at the start", async () => { + const file = mockFile("bom-middle.csv"); + const mockParse = Papa.parse as unknown as Mock; + + mockParse.mockImplementation((_file, config: FixedParseConfig) => { + const cleaned = config.beforeFirstChunk?.("a\uFEFF,b,c"); + + expect(cleaned).toBe("a\uFEFF,b,c"); + + const result: ParseResult = { + data: [], + errors: [], + meta: mockMeta, + }; + + config.complete?.(result, _file); + }); + + await parseCsvFile(file); +}); diff --git a/app/client/test/validateFile.test.ts b/app/client/test/validateFile.test.ts index 3f2e904..f6284c2 100644 --- a/app/client/test/validateFile.test.ts +++ b/app/client/test/validateFile.test.ts @@ -76,3 +76,12 @@ describe("validateFile", () => { }); }); }); + +it("does not flag tooManyInvalid when rows are empty even if invalid rows exist conceptually", () => { + // simulate invalid rows but pass empty array + const result = validateFile([]); + + expect(result.validCount).toBe(0); + expect(result.invalidCount).toBe(0); + expect(result.tooManyInvalid).toBe(false); +}); diff --git a/app/client/test/validateForms.test.ts b/app/client/test/validateForms.test.ts deleted file mode 100644 index 512a0ae..0000000 --- a/app/client/test/validateForms.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - validateAccountForm, - validateTransactionForm, - validateIncomeForm, -} from "../src/utils/ValidateForms"; -import { accountCategory } from "../src/enum/AccountCategory"; -import { transactionCategory } from "../src/enum/TransactionCategory"; -//tests for validateForms functions -function mockFile(): File { - return new File(["dummy"], "test.csv", { type: "text/csv" }); -} - -describe("validateAccountForm", () => { - it("returns true when CSV file is provided (CSV overrides all other checks)", () => { - const file = mockFile(); - const result = validateAccountForm("My Account", "Checking", 100, file); - expect(result).toBe(true); - }); - - it("returns true for valid manual account input", () => { - const validType = Object.values(accountCategory)[0]; - - const result = validateAccountForm("My Account", validType, 500); - expect(result).toBe(true); - }); - - it("returns false when name is missing", () => { - const validType = Object.values(accountCategory)[0]; - - const result = validateAccountForm("", validType, 500); - expect(result).toBe(false); - }); - - it("returns false for invalid account type", () => { - const result = validateAccountForm("My Account", "INVALID_TYPE", 100); - expect(result).toBe(false); - }); - - it("returns false for negative balance", () => { - const validType = Object.values(accountCategory)[0]; - - const result = validateAccountForm("My Account", validType, -10); - expect(result).toBe(false); - }); - - it("returns false for invalid interest rate", () => { - const validType = Object.values(accountCategory)[0]; - - const result = validateAccountForm( - "My Account", - validType, - 100, - undefined, - undefined, - 200, - ); - expect(result).toBe(false); - }); - - it("returns true when optional subtype and interest are valid", () => { - const validType = Object.values(accountCategory)[0]; - - const result = validateAccountForm( - "My Account", - validType, - 100, - undefined, - "sub", - 5, - ); - expect(result).toBe(true); - }); -}); - -describe("validateTransactionForm", () => { - it("returns true when CSV file is provided", () => { - const file = mockFile(); - const result = validateTransactionForm( - 123, - "Deposit", - 100, - "2024-01-01", - file, - ); - expect(result).toBe(true); - }); - - it("returns true for valid manual transaction input", () => { - const validType = Object.values(transactionCategory)[0]; - - const result = validateTransactionForm(123, validType, 50, "2024-01-01"); - expect(result).toBe(true); - }); - - it("returns false when account_id is missing", () => { - const validType = Object.values(transactionCategory)[0]; - - const result = validateTransactionForm(0, validType, 50, "2024-01-01"); - expect(result).toBe(false); - }); - - it("returns false for invalid transaction type", () => { - const result = validateTransactionForm( - 123, - "INVALID_TYPE", - 50, - "2024-01-01", - ); - expect(result).toBe(false); - }); - - it("returns false for non-positive amount", () => { - const validType = Object.values(transactionCategory)[0]; - - const result = validateTransactionForm(123, validType, 0, "2024-01-01"); - expect(result).toBe(false); - }); - - it("returns false when date is missing", () => { - const validType = Object.values(transactionCategory)[0]; - - const result = validateTransactionForm(123, validType, 50, ""); - expect(result).toBe(false); - }); -}); - -describe("validateIncomeForm", () => { - it("returns true for valid name and positive income", () => { - expect(validateIncomeForm("Job", 1000)).toBe(true); - }); - - it("returns false for missing name", () => { - expect(validateIncomeForm("", 1000)).toBe(false); - }); - - it("returns false for non-positive income", () => { - expect(validateIncomeForm("Job", 0)).toBe(false); - }); -}); diff --git a/app/client/vitest.config.ts b/app/client/vitest.config.ts new file mode 100644 index 0000000..c6a4ad2 --- /dev/null +++ b/app/client/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index b0ce0aa..76428b5 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -2,7 +2,7 @@ #It depends on the main compose file being built #as all the test images inherit from the regular images. #you can use the following command to run the tests -#docker compose build -q && docker compose -f docker-compose.test.yml build -q && docker compose -f docker-compose.test.yml up --abort-on-container-failure +#docker compose build -q && docker compose -f docker-compose.yml -f docker-compose.test.yml build -q && docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-failure services: auth-test: build: @@ -18,3 +18,57 @@ services: build: context: services/python/analytics dockerfile: Dockerfile.test + + # user-integration-test:#uncomment to run integration tests locally as it likely does not work properly in CI/CD + # build: + # context: ./services/ts + # dockerfile: user/Dockerfile.integration.test + # env_file: + # - ./default_container.env + # environment: + # - JWT_SECRET + + # depends_on: + # database: + # condition: service_healthy + # api-gateway: + # condition: service_started + + # user-mutation-test: + # build: + # context: ./services/ts + # dockerfile: user/Dockerfile.mutation.test + market-test: + build: + context: services/python/market + dockerfile: Dockerfile.test + + market-mutation-test: + build: + context: services/python/market + dockerfile: Dockerfile.mutation.test + + market-integration-test: + build: + context: services/python/market + dockerfile: Dockerfile.integration.test + env_file: + - ./default_container.env + depends_on: + market: + condition: service_started + + # user-integration-test: + # build: + # context: ./services/ts + # dockerfile: user/Dockerfile.integration.test + # env_file: + # - ./default_container.env + # environment: + # - JWT_SECRET + + # depends_on: + # database: + # condition: service_healthy + # api-gateway: + # condition: service_started diff --git a/app/server/docker-compose.yml b/app/server/docker-compose.yml index 050e541..906ab34 100644 --- a/app/server/docker-compose.yml +++ b/app/server/docker-compose.yml @@ -10,6 +10,8 @@ services: - NODE_ENV=development ports: - "3000:8000" + volumes: + - client_state_data:/var/lib/finus-client-state depends_on: - auth - user @@ -84,6 +86,13 @@ services: - MYSQL_PASSWORD ports: - "3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"] + interval: 0.5s + timeout: 10s + retries: 100 + start_period: 0s volumes: db_data: + client_state_data: diff --git a/app/server/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/app/server/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 0000000..c418db7 --- /dev/null +++ b/app/server/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.1.1","results":[[":services/ts/user/test/logic_snapshot.test.ts",{"duration":24.460399999999993,"failed":true}],[":services/ts/user/test/dates.test.ts",{"duration":28.86179999999996,"failed":true}],[":services/ts/user/test/logic_expenses.test.ts",{"duration":14.341799999999978,"failed":false}],[":services/ts/auth/test/logic.test.ts",{"duration":0,"failed":true}],[":services/ts/user/test/logic_transactions.test.ts",{"duration":8.949200000000019,"failed":false}],[":services/ts/auth/test/parsing.test.ts",{"duration":0,"failed":true}],[":services/ts/auth/test/validation.test.ts",{"duration":6.108399999999989,"failed":false}]]} \ No newline at end of file diff --git a/app/server/services/database/populator.py b/app/server/services/database/populator.py index 88e5629..57292b0 100644 --- a/app/server/services/database/populator.py +++ b/app/server/services/database/populator.py @@ -9,12 +9,9 @@ import mysql.connector from mysql.connector import Error import random -import hashlib -import os from datetime import datetime, timedelta import argparse import bcrypt -import subprocess def get_db_connection(): @@ -43,7 +40,7 @@ def get_db_connection(): FINANCIAL_ACCOUNT_TYPES = ['chequing', 'savings', 'credit_card', 'investment'] FINANCIAL_ACCOUNT_SUBTYPES = ['RRSP', 'TFSA', 'FHSA', 'RESP', 'RDSP', 'loan', 'na'] INVESTMENT_TYPES = ['stocks', 'bonds', 'mutual funds', 'ETFs'] -GOAL_TYPES = ['money', 'debt'] +GOAL_TYPES = ['save', 'reduce_spending'] FIRST_NAMES = ['John', 'Jane', 'Alex', 'Emily', 'Michael', 'Sarah', 'David', 'Laura'] LAST_NAMES = ['Smith', 'Finus', 'Williams', 'Brown', 'Jones'] GOAL_NAMES = ['Emergency Fund', 'New Car', 'House Down Payment', 'Vacation', 'Retirement', 'Pay off Credit Card'] @@ -67,14 +64,13 @@ def create_goals(cursor, profile_ids): for _ in range(random.randint(1, 3)): name = random.choice(GOAL_NAMES) goal_type = random.choice(GOAL_TYPES) - objective = random.choice(GOAL_OBJECTIVES) - description = f"Goal: {objective}" - deadline = datetime.now() + timedelta(days=random.randint(30, 365*3)) # 1 month to 3 years - + category = random.choice(TRANSACTION_CATEGORIES) + target = random.randint(100, 50000) + period = random.choice(['w', 'm']) cursor.execute(""" - INSERT INTO finus.goal (name, type, objective, description, deadline) - VALUES (%s, %s, %s, %s, %s) - """, (name, goal_type, objective, description, deadline)) + INSERT INTO finus.goal (name, type, category, target, period) + VALUES (%s, %s, %s, %s , %s) + """, (name, goal_type, category, target , period)) goal_id = cursor.lastrowid @@ -355,11 +351,11 @@ def populate_lookup_tables(cursor): (inv_type,) ) - for goal_type in GOAL_TYPES: - cursor.execute( - "INSERT IGNORE INTO finus.goalType (type) VALUES (%s)", - (goal_type,) - ) + # for goal_type in GOAL_TYPES: + # cursor.execute( + # "INSERT IGNORE INTO finus.goalType (type) VALUES (%s)", + # (goal_type,) + # ) diff --git a/app/server/services/database/schema.sql b/app/server/services/database/schema.sql index 900a284..340e43e 100644 --- a/app/server/services/database/schema.sql +++ b/app/server/services/database/schema.sql @@ -34,21 +34,15 @@ CREATE TABLE finus.finusAccount_profile ( FOREIGN KEY (account_id) REFERENCES finus.finusAccount(id) ON DELETE CASCADE -- this used to mention financialAccount, but should be mentioning finusAccount ); -CREATE TABLE finus.goalType ( - type VARCHAR(50) NOT NULL, - PRIMARY KEY (type) -); CREATE TABLE finus.goal ( id INTEGER NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, type VARCHAR(50) NOT NULL, - objective VARCHAR(500) NOT NULL, - description VARCHAR(500), - deadline DATETIME, - PRIMARY KEY (id), - FOREIGN KEY (type) REFERENCES finus.goalType(type) ON DELETE CASCADE - + category VARCHAR(50), + period VARCHAR(1) NOT NULL, + target DECIMAL(12,2) NOT NULL, -- this is the monetary amount that we are trying to reach, there is no current amount to track, as that is just recalculated from the transactions + PRIMARY KEY (id) ); CREATE TABLE finus.profile_goal ( @@ -160,8 +154,8 @@ CREATE TABLE finus.fixedInterestInvestment( ); #Populate lookup tables -INSERT INTO finus.financialAccountType (type) VALUES ('chequing'), ('savings'), ('credit_card'), ('investment'); -INSERT INTO finus.financialAccountSubtype (subtype) VALUES ('RRSP'), ('TFSA'), ('FHSA'), ('RESP'), ('RDSP'), ('loan'), ('na'); +INSERT INTO finus.financialAccountType (type) VALUES ('chequing'), ('savings'), ('credit_card'), ('investment'); +INSERT INTO finus.financialAccountSubtype (subtype) VALUES ('RRSP'), ('TFSA'), ('FHSA'), ('RESP'), ('RDSP'), ('Loan'), ('na'); -- loan is used for credit_card accounts that are for loans like mortgage and etc, this is used to track debt INSERT INTO finus.investmentType (type) VALUES ('fixedInterest'), ('stock'); #These have to match table names -INSERT INTO finus.goalType (type) VALUES ('money'), ('debt'); + diff --git a/app/server/services/python/analytics/Dockerfile b/app/server/services/python/analytics/Dockerfile index d11521e..63065b0 100644 --- a/app/server/services/python/analytics/Dockerfile +++ b/app/server/services/python/analytics/Dockerfile @@ -1,16 +1,7 @@ FROM python:3.11-slim -#cache pip packages -RUN --mount=type=cache,target=/root/.cache - WORKDIR /app/server/services/python/analytics -RUN apt-get update && apt-get install -y \ - gcc \ - g++ \ - && rm -rf /var/lib/apt/lists/* - - COPY requirements.txt . RUN --mount=type=cache,target=/root/.cache/pip \ diff --git a/app/server/services/python/analytics/src/dependencies.py b/app/server/services/python/analytics/src/dependencies.py index ae26603..ebb1ac0 100644 --- a/app/server/services/python/analytics/src/dependencies.py +++ b/app/server/services/python/analytics/src/dependencies.py @@ -32,16 +32,7 @@ async def get_current_user(authorization: Optional[str] = Header(None)): payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) user_id = payload.get('sub') - if not user_id: - raise HTTPException(status_code=401, detail="User ID not found in token") - return int(user_id) - - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=401, detail="Token expired") - except jwt.InvalidTokenError: - raise HTTPException(status_code=401, detail="Invalid token") - except ValueError: - raise HTTPException(status_code=401, detail="Invalid authorization header format") - except TypeError as e: - raise HTTPException(status_code=401, detail=str(e)) \ No newline at end of file + + except Exception as e: + raise HTTPException(status_code=401, detail=str(e))#general exception for simpler unit \ No newline at end of file diff --git a/app/server/services/python/analytics/src/logic/budget.py b/app/server/services/python/analytics/src/logic/budget.py index d34c237..cea8f34 100644 --- a/app/server/services/python/analytics/src/logic/budget.py +++ b/app/server/services/python/analytics/src/logic/budget.py @@ -1,7 +1,7 @@ from src.logic.budgetCalc import BudgetCalculator from src.logic.budgetPerfCalc import BudgetPerformanceCalculator from src.models.schemas import BudgetResponse -from src.queries.budget import get_user_transactions_with_connection +from src.queries.budget import get_user_transactions_with_connection, get_user_goals from src.utils.trans_cat_classifier import CategoryClassifier import pandas as pd @@ -14,20 +14,30 @@ # Find the top 10 most impactful categories and calculate a budget for each #At the moment, this uses a 50/30/20 rule, where categories are classified as needs, wants, savings and then a proportional budget is calculated # Monthly budget is generated based on the average monthly income, but then it can be divided or multiplied to match the specified period (week is divided by 4.33, year multiplied by 12) + +#More technical detail: +#Pools of money are created for needs, wants and savings based on income per month - the budget is created on a monthly basis and can be rescaled for weeks or years later. +# Each savings goal adds a flat 5% amount increase to the savings pool, money is taken away from wants pool and added to savings +# Each expenditure reduction goal adds a progressive % to savings pool based on how close the expenditure is to the limit, and takes away this money from wants pool - adding it to savings +# Needs pool is untouched always +# The most impactful categories are then used to calculate a budget for each category by drawing out of the pools +# Categories are categorized into needs, wants, savings and then a proportional budget is calculated + def generate_budget(period: str, user_id: int) -> BudgetResponse: end_date = pd.Timestamp.today() start_date = end_date - pd.Timedelta(days=365) - transactions = get_user_transactions_with_connection( user_id, start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d') ) + goals = get_user_goals(user_id) + income = [t for t in transactions if t['amount'] > 0] avg_monthly_income = sum(t['amount'] for t in income) / 12 - return _default_calculator.generate_budget(transactions, period, avg_monthly_income) + return _default_calculator.generate_budget(transactions, period, avg_monthly_income, goals) #Calculate the performance against a proposed user budget diff --git a/app/server/services/python/analytics/src/logic/budgetCalc.py b/app/server/services/python/analytics/src/logic/budgetCalc.py index afe6367..70a49aa 100644 --- a/app/server/services/python/analytics/src/logic/budgetCalc.py +++ b/app/server/services/python/analytics/src/logic/budgetCalc.py @@ -1,18 +1,23 @@ -from typing import List, Dict, Tuple +from typing import List, Dict, Optional, Tuple import pandas as pd from src.models.schemas import BudgetCategory, BudgetResponse from src.utils.trans_cat_classifier import CategoryClassifier + class BudgetCalculator: def __init__(self, classifier: CategoryClassifier): self.classifier = classifier self.WEEKS_PER_MONTH = 4.33 + + def separate_income_expenses(self, transactions: List[Dict]) -> Tuple[List[Dict], List[Dict]]: income = [t for t in transactions if t['amount'] > 0] expenses = [t for t in transactions if t['amount'] < 0] return income, expenses + + def calculate_monthly_averages(self, expenses: List[Dict]) -> Dict[str, float]: expense_by_category = {} for t in expenses: @@ -22,9 +27,13 @@ def calculate_monthly_averages(self, expenses: List[Dict]) -> Dict[str, float]: return {cat: total / 12 for cat, total in expense_by_category.items()} + + def get_top_categories(self, monthly_avg: Dict[str, float], n: int = 10) -> List[Tuple[str, float]]: return sorted(monthly_avg.items(), key=lambda x: x[1], reverse=True)[:n] + + def calculate_budget_pools(self, avg_monthly_income: float) -> Dict[str, float]: return { 'needs': avg_monthly_income * 0.5, @@ -32,6 +41,57 @@ def calculate_budget_pools(self, avg_monthly_income: float) -> Dict[str, float]: 'savings': avg_monthly_income * 0.2 } + + #This calculates the impact of goals on the budget - the goal type is important + # Pure savings goals incur a flat boost to savings + # Reduce spending goals reduce wants by 15% if the spending is over the limit, this penalty becomes smaller the further away the spending is from the limit + def calculate_goal_impact( + self, + goals: List[Dict], + avg_monthly_income: float, + monthly_avg_by_category: Dict[str, float] + ) -> Tuple[float, float, List[str]]: + savings_boost = 0.0 + wants_reduction = 0.0 + categories_to_include = set() + + for goal in goals: + category = goal.get('category') + goal_type = goal.get('type') + + if category: + categories_to_include.add(category) + + if goal_type == 'save': + #save goals add 5% boost to savings + boost_amount = avg_monthly_income * 0.05 + savings_boost += boost_amount + + elif goal_type == 'reduce_spending': + #get current spending for this category + current_spending = monthly_avg_by_category.get(category, 0) + target_amount = float(goal.get('target', 0)) + + if target_amount > 0: + percent_of_limit = (current_spending / target_amount) * 100 + + if percent_of_limit >= 100: + #already over limit: reduce wants by 7% + reduction = avg_monthly_income * 0.07 + wants_reduction = max(wants_reduction, reduction) + elif percent_of_limit >= 80: + #approaching limit: reduce wants by 5% + reduction = avg_monthly_income * 0.05 + wants_reduction = max(wants_reduction, reduction) + elif percent_of_limit >= 60: + #getting close: reduce wants by 3% + reduction = avg_monthly_income * 0.03 + wants_reduction = max(wants_reduction, reduction) + + return savings_boost, wants_reduction, list(categories_to_include) + + + def calculate_category_budget( self, category: str, @@ -39,8 +99,12 @@ def calculate_category_budget( category_type: str, monthly_avg_by_category: Dict[str, float], category_types: Dict[str, str], - pools: Dict[str, float] + pools: Dict[str, float], + goals: List[Dict] = None ) -> float: + goals = goals or [] + goal = next((g for g in goals if g.get('category') == category and g.get('type') == 'reduce_spending'), None) + if category_type == 'need': pool_total = pools['needs'] pool_actual = sum( @@ -53,11 +117,22 @@ def calculate_category_budget( monthly_avg_by_category.get(c, 0) for c, t in category_types.items() if t == 'want' ) - + + #apply spending limit goal if exists if pool_actual > 0: - return (avg_spent / pool_actual) * pool_total - return avg_spent * 0.9 # try to reduce spending by 10% if there is absolutely no income + recommended = (avg_spent / pool_actual) * pool_total + else: + recommended = avg_spent * 0.9 + + #apply spending limit goal if exists + if goal and goal.get('target', 0) > 0: + goal_target_monthly = float(goal['target']) + recommended = min(recommended, goal_target_monthly) + + return recommended# try to reduce spending by 10% if there is absolutely no income + + def scale_budget(self, amount: float, period: str) -> float: if period == 'w': return amount / self.WEEKS_PER_MONTH @@ -66,27 +141,68 @@ def scale_budget(self, amount: float, period: str) -> float: else: # 'y' return amount * 12 + + def generate_budget( self, transactions: List[Dict], period: str, - avg_monthly_income: float + avg_monthly_income: float, + goals: Optional[List[Dict]] = None ) -> BudgetResponse: - """Generate budget from transactions.""" + goals = goals or [] income, expenses = self.separate_income_expenses(transactions) monthly_avg = self.calculate_monthly_averages(expenses) + monthly_avg_savings = self.calculate_monthly_averages(income) pools = self.calculate_budget_pools(avg_monthly_income) + savings_boost, wants_reduction, goal_categories = self.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg + ) + + if savings_boost > 0: + #transfer from wants to savings + pools['wants'] = max(0, pools['wants'] - savings_boost) + pools['savings'] = pools['savings'] + savings_boost + + if wants_reduction > 0: + #reduce wants and increase savings + pools['wants'] = max(0, pools['wants'] - wants_reduction) + pools['savings'] = pools['savings'] + wants_reduction + category_types = self.classifier.classify_many(set(monthly_avg.keys())) + + #ensure goal categories are included in category_types + for goal_cat in goal_categories: + if goal_cat not in category_types: + #default to 'want' if not classified + category_types[goal_cat] = 'want' + top_categories = self.get_top_categories(monthly_avg) + top_category_names = {cat for cat, _ in top_categories} + #add any missing goal categories to top_categories + for goal_cat in goal_categories: + if goal_cat not in top_category_names and goal_cat in monthly_avg:#some goals may have expenses in their categories + top_categories.append((goal_cat, monthly_avg[goal_cat])) + if goal_cat not in top_category_names and goal_cat in monthly_avg_savings:#some goals are just savings goals and their categories have no expenses, so they won't be in monthly expenses + top_categories.append((goal_cat, monthly_avg_savings[goal_cat])) + if goal_cat not in top_category_names:#lastly some goals may not have expenses or savings, so they're just 0 + top_categories.append((goal_cat, 0)) + + #re-sort + top_categories.sort(key=lambda x: x[1], reverse=True) + + + budget_categories = [] for category, avg_spent in top_categories: cat_type = category_types.get(category, 'want') monthly_recommended = self.calculate_category_budget( category, avg_spent, cat_type, - monthly_avg, category_types, pools + monthly_avg, category_types, pools, goals ) + scaled_recommended = self.scale_budget(monthly_recommended, period) diff --git a/app/server/services/python/analytics/src/logic/debt.py b/app/server/services/python/analytics/src/logic/debt.py new file mode 100644 index 0000000..cf763ec --- /dev/null +++ b/app/server/services/python/analytics/src/logic/debt.py @@ -0,0 +1,48 @@ +import pandas as pd +from typing import List, Optional +from datetime import datetime, timedelta +from src.models.schemas import DebtPayoffRequest, DebtPayoffResponse, DebtPayoffStage, BadRequestError + + +def generate_debt_payoff_stages(dbr: DebtPayoffRequest) -> DebtPayoffResponse: + remaining_debt = dbr.remainingAmount + minimum_payment = dbr.minimumPayment + interest_rate = dbr.interestRate or 0 + + monthly_interest_rate = round(interest_rate / 12 / 100, 5) if interest_rate else 0 + + expected_date = datetime.strptime(dbr.nextDueDate, "%Y-%m-%d") + stages: List[DebtPayoffStage] = [] + + i = 1 + while remaining_debt > 0: + interest = remaining_debt * monthly_interest_rate + principal = minimum_payment - interest + + if principal > remaining_debt: + principal = remaining_debt + + if principal <= 0: + raise BadRequestError("Minimum payment is too low. Debt will never be paid off.") + + new_remaining = remaining_debt - principal + + stages.append(DebtPayoffStage( + id=i, + principalAmount=round(principal, 2), + interestAmount=round(interest, 2), + remainingDebt=round(new_remaining, 2), + installmentDate=expected_date.strftime("%Y-%m-%d") + )) + + remaining_debt = new_remaining + expected_date += timedelta(days=dbr.period) + i += 1 + + return DebtPayoffResponse( + id=dbr.id, + category=dbr.category, + minimumPayment=dbr.minimumPayment, + interestRate=interest_rate, + debtStages=stages, + ) diff --git a/app/server/services/python/analytics/src/logic/savings.py b/app/server/services/python/analytics/src/logic/savings.py index b70a5a4..43166dd 100644 --- a/app/server/services/python/analytics/src/logic/savings.py +++ b/app/server/services/python/analytics/src/logic/savings.py @@ -1,6 +1,13 @@ import pandas as pd +import numpy as np from typing import List, Dict +from datetime import datetime, date +from dateutil.relativedelta import relativedelta +from fastapi import HTTPException # from ..queries import savings as savings_queries +from src.dependencies import get_db_connection +from src.models.schemas import ProjectedSavingsRequest, MonthlySavingGrowthRate, CompoundInterestResponse +from src.queries.savings import get_savings_transactions def calculate_savings_over_time( accounts: List[Dict], @@ -35,7 +42,7 @@ def calculate_savings_over_time( else: df['year_month'] = df['date'].dt.to_period('M') monthly_groups = df.groupby('year_month') - for month in pd.date_range(start=start_date, end=end_date, freq='M'): + for month in pd.date_range(start=start_date, end=end_date, freq='M'):#change this for production - GitHub actions might fail if this is ME, and work with M instead month_str = month.strftime('%Y-%m') month_period = pd.Period(month_str, freq='M') if month_period in monthly_groups.groups: @@ -49,4 +56,154 @@ def calculate_savings_over_time( 'label': f'{period_name} Savings', 'data': [item['savings'] for item in savings_over_time] }] - } \ No newline at end of file + } + +# def get_monthly_balances(transactions: pd.DataFrame): +# df = transactions.copy() +# df['date'] = pd.to_datetime(df['date']) + +# # Sort by date +# df = df.sort_values('date') + +# # Group by month +# df['year_month'] = df['date'].dt.to_period('M') + +# monthly_sums = df.groupby('year_month')['amount'].sum().reset_index() + +# monthly_sums['year_month'] = monthly_sums['year_month'].dt.to_timestamp() + +# # Create full monthly range +# full_range = pd.date_range( +# start=monthly_sums['year_month'].min(), +# end=monthly_sums['year_month'].max(), +# freq='MS' # month start +# ) + +# monthly_sums = monthly_sums.set_index('year_month').reindex(full_range, fill_value=0) +# monthly_sums = monthly_sums.rename_axis('date').reset_index() + +# # Convert to cumulative balance +# monthly_sums['balance'] = monthly_sums['amount'].cumsum() +# print(monthly_sums) + +# return monthly_sums + +def get_monthly_balances(transactions: pd.DataFrame): + df = transactions.copy() + df['date'] = pd.to_datetime(df['date']) + + # Extract month only (1–12) + df['month'] = df['date'].dt.month + + # Initialize 12 months with no balance + monthly_totals = {month: 0 for month in range(1, 13)} + + # Aggregate ignoring year + for _, row in df.iterrows(): + monthly_totals[row['month']] += row['amount'] + + # Convert to DataFrame (ordered) + monthly_df = pd.DataFrame([ + {"month": m, "amount": monthly_totals[m]} + for m in range(1, 13) + ]) + + # Compute cumulative balance for each month + monthly_df['balance'] = monthly_df['amount'].cumsum() + + print(monthly_df) + + return monthly_df + +def compute_monthly_growth_rates(monthly_df: pd.DataFrame): + # Compute increase rate between months + monthly_df['growth_rate'] = monthly_df['balance'].pct_change() + + # Replace inf and -inf with NaN and drop NaN + monthly_df['growth_rate'].replace([np.inf, -np.inf], np.nan, inplace=True) + + # Replace NaN with 0 + monthly_df['growth_rate'].fillna(0, inplace=True) + + growth_rates = monthly_df['growth_rate'] + growth_rates = growth_rates.round(3) + + return growth_rates + +def generate_savings_growth_rate(transactions: List[Dict]) -> MonthlySavingGrowthRate: + df = pd.DataFrame(transactions) + + if df.empty: + return None + + monthly_balances = get_monthly_balances(df) + growth_rates = compute_monthly_growth_rates(monthly_balances) + + mean_growth = growth_rates.mean() + standard_deviation_growth = growth_rates.std() + + return MonthlySavingGrowthRate( + best_case=round(mean_growth + standard_deviation_growth, 2), + expected_case=round(mean_growth, 2), + worst_case=max(round(mean_growth - standard_deviation_growth, 2), 0) + ) + + +def calculate_compound_interest(request: ProjectedSavingsRequest) -> List[CompoundInterestResponse]: + connection = get_db_connection() + if not connection: + raise HTTPException(status_code=500, detail="Database connection failed") + + cursor = connection.cursor(dictionary=True) + transactions = get_savings_transactions(cursor, [request.financial_account_id]) + print(transactions) + if len(transactions) == 0: + return [] + + monthly_savings_rate = generate_savings_growth_rate(transactions) + print(monthly_savings_rate) + + best_rate = monthly_savings_rate.best_case + worst_rate = monthly_savings_rate.worst_case + expected_rate = monthly_savings_rate.expected_case + + worst_balance = request.balance + expected_balance = request.balance + best_balance = request.balance + + monthly_deposit = request.monthly_deposit + time_frame = request.time_frame + + results: List[CompoundInterestResponse] = [] + + months = time_frame * 12 + current_date = date.today() + + for _ in range(months): + # deposit money + worst_balance += monthly_deposit + expected_balance += monthly_deposit + best_balance += monthly_deposit + + # compute growth + worst_balance += worst_balance * worst_rate + expected_balance += expected_balance * expected_rate + best_balance += best_balance * best_rate + + # rounding + worst_balance = round(worst_balance, 2) + expected_balance = round(expected_balance, 2) + best_balance = round(best_balance, 2) + + results.append( + CompoundInterestResponse( + accumulative_worst_balance=worst_balance, + accumulative_expected_balance=expected_balance, + accumulative_best_balance=best_balance, + date=current_date.strftime("%B %Y") + ) + ) + + current_date += relativedelta(months=1) + + return results \ No newline at end of file diff --git a/app/server/services/python/analytics/src/main.py b/app/server/services/python/analytics/src/main.py index 240a046..eb80e9b 100644 --- a/app/server/services/python/analytics/src/main.py +++ b/app/server/services/python/analytics/src/main.py @@ -1,14 +1,17 @@ from fastapi import FastAPI, Depends, Query, HTTPException from fastapi.middleware.cors import CORSMiddleware import pandas as pd +from typing import List, Dict import uvicorn import os + from src.dependencies import get_db_connection, get_current_user from src.utils.dates import period_calc from src.logic.budget import generate_budget, generate_budget_performance from src.queries import savings as savings_queries, incomeflow as incomeflow_queries -from src.logic import savings as savings_service, incomeflow as incomeflow_service +from src.logic import savings as savings_service, incomeflow as incomeflow_service, debt as debt_service +from src.models.schemas import BadRequestError, ProjectedSavingsRequest, ProjectedSavingsResponse, CompoundInterestResponse, DebtPayoffRequest app = FastAPI() @@ -116,6 +119,34 @@ async def get_budget( except Exception as e: print(e) raise HTTPException(status_code=500, detail=str(e)) + +@app.post('/predict-debt-payoff') +async def predict_debt_payoff( + requestBody: DebtPayoffRequest, + user_id: int = Depends(get_current_user), +): + try: + print(f"Generating predicted debt payoff for user {user_id}") + print(requestBody) + return debt_service.generate_debt_payoff_stages(requestBody) + except BadRequestError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + print(e) + raise HTTPException(status_code=500, detail=str(e)) + +@app.post('/compound-interest') +async def compount_interest( + requestBody: ProjectedSavingsRequest, + user_id: int = Depends(get_current_user), +) -> List[CompoundInterestResponse]: + try: + print(f"Generating compound interests for user {user_id}") + print(requestBody) + return savings_service.calculate_compound_interest(requestBody) + except Exception as e: + print(e) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/server/services/python/analytics/src/models/schemas.py b/app/server/services/python/analytics/src/models/schemas.py index bd8b43c..be89b8f 100644 --- a/app/server/services/python/analytics/src/models/schemas.py +++ b/app/server/services/python/analytics/src/models/schemas.py @@ -1,3 +1,4 @@ +from numpy import number from pydantic import BaseModel from typing import List, Literal, Optional from datetime import datetime @@ -51,4 +52,52 @@ class BudgetPerformanceItem(BaseModel): class BudgetPerformanceResponse(BaseModel): categories: List[str] budgetAmounts: List[float] - actualAmounts: List[float] \ No newline at end of file + actualAmounts: List[float] + +class DebtPayoffRequest(BaseModel): + id: str + category: str + remainingAmount: float + minimumPayment: float + interestRate: Optional[float] = 0 + nextDueDate: str # YYYY-MM-DD + period: int # days + +class DebtPayoffStage(BaseModel): + id: int + principalAmount: float + interestAmount: float + remainingDebt: float + installmentDate: str + +class DebtPayoffResponse(BaseModel): + id: str + category: str + minimumPayment: float + interestRate: float + debtStages: List[DebtPayoffStage] + +class ProjectedSavingsRequest(BaseModel): + financial_account_id: int + balance: float + monthly_deposit: float + annual_interest_rate: float | None # annual interest rate in percentage + time_frame: int # in years +class MonthlySavingGrowthRate(BaseModel): + best_case: float + expected_case: float + worst_case: float +class ProjectedSavingsResponse(BaseModel): + balance: float + monthly_contribution: float + interest_rate: float | None # annual interest rate in percentage + time_frame: int # in years + stat: List[MonthlySavingGrowthRate] +class CompoundInterestResponse(BaseModel): + accumulative_best_balance: float + accumulative_expected_balance: float + accumulative_worst_balance: float + date: str + +class BadRequestError(Exception): + pass \ No newline at end of file diff --git a/app/server/services/python/analytics/src/queries/budget.py b/app/server/services/python/analytics/src/queries/budget.py index 7d1d9a4..6306a78 100644 --- a/app/server/services/python/analytics/src/queries/budget.py +++ b/app/server/services/python/analytics/src/queries/budget.py @@ -24,4 +24,39 @@ def get_user_transactions_with_connection(user_id: int, start_date: str, end_dat return get_user_transactions(cursor, user_id, start_date, end_date) finally: cursor.close() - connection.close() \ No newline at end of file + connection.close() + +#this was added to make goals influence the budget - analytics service will fetch goals on its own +def get_user_goals(user_id: int) -> List[Dict]: + """Fetch active goals for a user.""" + connection = get_db_connection() + cursor = connection.cursor(dictionary=True) + + #first get the user's profile + cursor.execute(""" + SELECT profile_id + FROM finus.finusAccount_profile + WHERE account_id = %s + """, (user_id,)) + profile = cursor.fetchone() + + if not profile: + cursor.close() + connection.close() + return [] + + profile_id = profile['profile_id'] + + #fetch goals for this profile + cursor.execute(""" + SELECT g.* + FROM finus.goal g + JOIN finus.profile_goal pg ON g.id = pg.goal_id + WHERE pg.profile_id = %s + """, (profile_id,)) + + goals = cursor.fetchall() + cursor.close() + connection.close() + + return goals \ No newline at end of file diff --git a/app/server/services/python/analytics/src/queries/savings.py b/app/server/services/python/analytics/src/queries/savings.py index e8b3bfd..b7909f0 100644 --- a/app/server/services/python/analytics/src/queries/savings.py +++ b/app/server/services/python/analytics/src/queries/savings.py @@ -1,5 +1,4 @@ from typing import List, Dict -import mysql.connector def get_savings_accounts(cursor, user_id: int) -> List[Dict]: cursor.execute(""" diff --git a/app/server/services/python/analytics/test/budgetPerfCalc.py b/app/server/services/python/analytics/test/budgetPerfCalc.py new file mode 100644 index 0000000..dfa3e17 --- /dev/null +++ b/app/server/services/python/analytics/test/budgetPerfCalc.py @@ -0,0 +1,152 @@ +import pytest +import pandas as pd +from datetime import datetime +from unittest.mock import patch, ANY +from src.logic.budgetPerfCalc import BudgetPerformanceCalculator +from src.models.schemas import BudgetResponse, BudgetCategory + +pytestmark = pytest.mark.asyncio + +class TestBudgetPerformanceCalculator: + + @pytest.fixture + def performance_calc(self): + return BudgetPerformanceCalculator() + + @pytest.fixture + def mock_transactions(self): + return [ + {'category': 'groceries', 'amount': -300, 'date': '2024-03-10'}, + {'category': 'groceries', 'amount': -150, 'date': '2024-03-12'}, + {'category': 'dining', 'amount': -200, 'date': '2024-03-14'}, + {'category': 'entertainment', 'amount': -100, 'date': '2024-03-15'}, + ] + + @pytest.fixture + def sample_budget_response(self): + categories = [ + BudgetCategory( + category='groceries', + type='need', + avg_monthly_spent=300, + monthly_budget=280, + recommended_budget=65, + is_essential=True + ), + BudgetCategory( + category='dining', + type='want', + avg_monthly_spent=200, + monthly_budget=150, + recommended_budget=35, + is_essential=False + ), + BudgetCategory( + category='entertainment', + type='want', + avg_monthly_spent=150, + monthly_budget=120, + recommended_budget=28, + is_essential=False + ), + ] + return BudgetResponse( + budget=categories, + generated_date=datetime.now().strftime('%Y-%m-%d %H:%M:%S') + ) + + # Synchronous tests + def test_get_period_boundaries_weekly(self, performance_calc): + ref_date = pd.Timestamp('2024-03-15') + start, end = performance_calc.get_period_boundaries('w', ref_date) + assert start.strftime('%Y-%m-%d') == '2024-03-10' + assert end.strftime('%Y-%m-%d') == '2024-03-16' + + def test_get_period_boundaries_monthly(self, performance_calc): + ref_date = pd.Timestamp('2024-03-15') + start, end = performance_calc.get_period_boundaries('m', ref_date) + assert start.strftime('%Y-%m-%d') == '2024-03-01' + assert end.strftime('%Y-%m-%d') == '2024-03-31' + + def test_get_period_boundaries_yearly(self, performance_calc): + ref_date = pd.Timestamp('2024-03-15') + start, end = performance_calc.get_period_boundaries('y', ref_date) + assert start.strftime('%Y-%m-%d') == '2024-01-01' + assert end.strftime('%Y-%m-%d') == '2024-12-31' + + def test_calculate_actual_spending(self, performance_calc, mock_transactions): + result = performance_calc.calculate_actual_spending(mock_transactions) + assert result['groceries'] == 450 + assert result['dining'] == 200 + assert result['entertainment'] == 100 + + # Async tests with proper mocking + async def test_generate_performance( + self, + performance_calc, + sample_budget_response, + mock_transactions + ): + user_id = 123 + period = 'w' + + # Mock the database function at the EXACT import path used in budgetPerfCalc + with patch('src.logic.budgetPerfCalc.get_user_transactions_with_connection') as mock_get: + # Configure the mock to return our test data + mock_get.return_value = mock_transactions + + # Call the async function + result = await performance_calc.generate_performance( + user_id, + sample_budget_response, + period + ) + + # Assertions + assert 'categories' in result + assert 'budgetAmounts' in result + assert 'actualAmounts' in result + assert len(result['categories']) == len(sample_budget_response.budget) + + # Verify the mock was called with correct arguments + mock_get.assert_called_once_with(user_id, ANY, ANY) + + async def test_generate_performance_empty_transactions( + self, + performance_calc, + sample_budget_response + ): + user_id = 123 + period = 'w' + + with patch('src.logic.budgetPerfCalc.get_user_transactions_with_connection') as mock_get: + mock_get.return_value = [] + + result = await performance_calc.generate_performance( + user_id, + sample_budget_response, + period + ) + + # All actual amounts should be 0 + assert all(amount == 0 for amount in result['actualAmounts']) + mock_get.assert_called_once() + + async def test_generate_performance_db_error( + self, + performance_calc, + sample_budget_response + ): + user_id = 123 + period = 'w' + + with patch('src.logic.budgetPerfCalc.get_user_transactions_with_connection') as mock_get: + mock_get.side_effect = Exception("Database connection failed") + + # The error should propagate + with pytest.raises(Exception, match="Database connection failed"): + await performance_calc.generate_performance( + user_id, + sample_budget_response, + period + ) \ No newline at end of file diff --git a/app/server/services/python/analytics/test/budget_integration.py b/app/server/services/python/analytics/test/budget_integration.py new file mode 100644 index 0000000..56724c3 --- /dev/null +++ b/app/server/services/python/analytics/test/budget_integration.py @@ -0,0 +1,38 @@ +import pytest +from unittest.mock import patch, AsyncMock +from src.logic.budget import generate_budget, generate_budget_performance +from src.models.schemas import BudgetResponse + +class TestBudgetIntegration: + + @pytest.mark.asyncio + async def test_full_budget_flow(self): + user_id = 123 + period = 'w' + + # Mock transactions for generate_budget + mock_transactions = [ + {'amount': 5000, 'category': 'salary', 'date': '2024-01-01'}, + {'amount': -300, 'category': 'groceries', 'date': '2024-01-02'}, + {'amount': -200, 'category': 'dining', 'date': '2024-01-03'}, + {'amount': 5000, 'category': 'salary', 'date': '2024-02-01'}, + {'amount': -250, 'category': 'groceries', 'date': '2024-02-02'}, + ] + + with patch( + 'src.logic.budget.get_user_transactions_with_connection', + return_value=mock_transactions + ): + # Generate budget + budget = generate_budget(period, user_id) + + assert isinstance(budget, BudgetResponse) + assert len(budget.budget) > 0 + + # Generate performance + performance = await generate_budget_performance(user_id, budget, period) + + assert 'categories' in performance + assert 'budgetAmounts' in performance + assert 'actualAmounts' in performance + assert len(performance['categories']) == len(budget.budget) \ No newline at end of file diff --git a/app/server/services/python/analytics/test/cat_classifier.py b/app/server/services/python/analytics/test/cat_classifier.py new file mode 100644 index 0000000..ef7cb27 --- /dev/null +++ b/app/server/services/python/analytics/test/cat_classifier.py @@ -0,0 +1,55 @@ +import pytest +from src.utils.trans_cat_classifier import CategoryClassifier + +class TestCategoryClassifier: + + def test_classify_needs(self, classifier): + assert classifier.classify('rent') == 'need' + assert classifier.classify('groceries') == 'need' + assert classifier.classify('healthcare') == 'need' + assert classifier.classify('utilities') == 'need' + + def test_classify_wants(self, classifier): + assert classifier.classify('dining') == 'want' + assert classifier.classify('entertainment') == 'want' + assert classifier.classify('shopping') == 'want' + assert classifier.classify('coffee') == 'want' + + def test_classify_savings(self, classifier): + assert classifier.classify('savings') == 'savings' + assert classifier.classify('investment') == 'savings' + assert classifier.classify('retirement') == 'savings' + + def test_classify_unknown_defaults_to_want(self, classifier): + assert classifier.classify('unknown_category') == 'want' + assert classifier.classify('random_stuff') == 'want' + + def test_classify_case_insensitive(self, classifier): + assert classifier.classify('RENT') == 'need' + assert classifier.classify('Groceries') == 'need' + assert classifier.classify('DINING') == 'want' + + def test_classify_many(self, classifier): + categories = {'rent', 'dining', 'savings', 'unknown'} + result = classifier.classify_many(categories) + + assert result['rent'] == 'need' + assert result['dining'] == 'want' + assert result['savings'] == 'savings' + assert result['unknown'] == 'want' + + def test_get_categories_by_type(self, classifier): + category_types = { + 'rent': 'need', + 'groceries': 'need', + 'dining': 'want', + 'savings': 'savings' + } + + needs = classifier.get_categories_by_type(category_types, 'need') + wants = classifier.get_categories_by_type(category_types, 'want') + savings = classifier.get_categories_by_type(category_types, 'savings') + + assert needs == {'rent', 'groceries'} + assert wants == {'dining'} + assert savings == {'savings'} \ No newline at end of file diff --git a/app/server/services/python/analytics/test/conftest.py b/app/server/services/python/analytics/test/conftest.py index 67187ae..1dfc278 100644 --- a/app/server/services/python/analytics/test/conftest.py +++ b/app/server/services/python/analytics/test/conftest.py @@ -1,7 +1,8 @@ import pytest +import pandas as pd from unittest.mock import patch, MagicMock from datetime import datetime, timedelta -from src.models.schemas import BudgetCategory, BudgetResponse +from src.models.schemas import BudgetCategory, BudgetResponse, DebtPayoffRequest from src.utils.trans_cat_classifier import CategoryClassifier import jwt import os @@ -79,6 +80,13 @@ def mock_savings_transactions(): {'financialAccount_id': 2, 'amount': 150, 'date': base_date + timedelta(days=15)}, {'financialAccount_id': 1, 'amount': -75, 'date': base_date + timedelta(days=20)}, ] +@pytest.fixture +def mock_savings_transactions_by_financial_account(): + return pd.DataFrame([ + {"financialAccountId": "1", "date": "2026-01-10", "amount": 100}, + {"financialAccountId": "1", "date": "2025-01-20", "amount": 50}, + {"financialAccountId": "1", "date": "2026-02-01", "amount": 200}, + ]) @pytest.fixture def mock_incomeflow_transactions(): @@ -95,7 +103,35 @@ def mock_incomeflow_transactions(): {'amount': -100, 'category': 'entertainment', 'date': base_date + timedelta(days=8)}, {'amount': -400, 'category': 'utilities', 'date': base_date + timedelta(days=12)}, ] +@pytest.fixture +def mock_transactions_with_same_amount(): + return [ + {"date": "2026-01-01", "amount": 100}, + {"date": "2026-02-01", "amount": 100}, + {"date": "2026-03-01", "amount": 100}, + {"date": "2026-04-01", "amount": 100}, + {"date": "2026-05-01", "amount": 100}, + ] +@pytest.fixture +def mock_monthly_diff(): + return pd.DataFrame({ + "month": [1, 2, 3], + "amount": [0, 0, 100], + "balance": [0, 0, 100] + }) + +@pytest.fixture +def mock_debt_payoff_request(): + return DebtPayoffRequest( + id="1", + category="credit_card", + remainingAmount=1000.0, + minimumPayment=200.0, + interestRate=12.0, + nextDueDate="2024-01-01", + period=30 + ) @pytest.fixture def mock_env_vars(): diff --git a/app/server/services/python/analytics/test/dependencies.py b/app/server/services/python/analytics/test/dependencies.py new file mode 100644 index 0000000..df09d1d --- /dev/null +++ b/app/server/services/python/analytics/test/dependencies.py @@ -0,0 +1,76 @@ +import pytest +from unittest.mock import patch, MagicMock +import mysql.connector +from src.dependencies import get_db_connection, get_current_user +from fastapi import HTTPException +import os +import jwt +from jwt import InvalidSignatureError + +class TestDatabaseConnection: + + @patch('mysql.connector.connect') + def test_get_db_connection_success(self, mock_connect, mock_env_vars): + mock_connection = MagicMock() + mock_connect.return_value = mock_connection + + result = get_db_connection() + + mock_connect.assert_called_once_with( + host='localhost', + user='test_user', + password='test_password', + database='test_db' + ) + + assert result == mock_connection + + @patch('mysql.connector.connect') + def test_get_db_connection_missing_env_vars(self, mock_connect): + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(HTTPException): + get_db_connection() + + @patch('mysql.connector.connect') + def test_get_db_connection_failure(self, mock_connect): + mock_connect.side_effect = mysql.connector.Error("Connection failed") + with patch.dict(os.environ, {'MYSQL_HOST': 'localhost', 'MYSQL_USER': 'test_user', 'MYSQL_PASSWORD': 'test_password', 'DB_NAME': 'test_db'}, clear=True): + with pytest.raises(mysql.connector.Error): + get_db_connection() + + + +class TestJWTAuthentication: + + @pytest.mark.asyncio + async def test_get_current_user_missing_user_id(self): + + payload = {'email': 'test@example.com'} + token = jwt.encode(payload, 'test_secret_key_12345', algorithm='HS256') + + # Ensure token is string + if isinstance(token, bytes): + token = token.decode('utf-8') + + with patch.dict(os.environ, {'JWT_SECRET': 'test_secret_key_12345'}): + with pytest.raises(HTTPException) as exc_info: + await get_current_user(authorization=f"Bearer {token}") + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "User ID not found in token" + + @pytest.mark.asyncio + async def test_get_current_user_invalid_signature(self): + + payload = {'sub': '123'} + token = jwt.encode(payload, 'wrong_secret', algorithm='HS256') + + if isinstance(token, bytes): + token = token.decode('utf-8') + + with patch.dict(os.environ, {'JWT_SECRET': 'test_secret_key_12345'}): + with pytest.raises(HTTPException) as exc_info: + await get_current_user(authorization=f"Bearer {token}") + + assert exc_info.value.status_code == 401 + assert "token" in exc_info.value.detail.lower() \ No newline at end of file diff --git a/app/server/services/python/analytics/test/incomeflow.py b/app/server/services/python/analytics/test/incomeflow.py new file mode 100644 index 0000000..18945bf --- /dev/null +++ b/app/server/services/python/analytics/test/incomeflow.py @@ -0,0 +1,131 @@ +# tests/test_service_incomeflow.py +import pytest +import pandas as pd +from src.logic.incomeflow import build_sankey_data + +class TestIncomeflowService: + + def test_build_sankey_data_with_income_and_expenses(self, mock_incomeflow_transactions): + result = build_sankey_data(mock_incomeflow_transactions) + + assert 'nodes' in result + assert 'links' in result + + # Should have total income node + node_names = [node['name'] for node in result['nodes']] + assert 'total income' in node_names + + # Should have all unique categories as nodes + expected_categories = {'salary', 'freelance', 'interest', 'rent', + 'groceries', 'dining', 'entertainment', 'utilities'} + for category in expected_categories: + assert category in node_names + + # Should have links + assert len(result['links']) > 0 + + # Verify link structure + for link in result['links']: + assert 'source' in link + assert 'target' in link + assert 'value' in link + assert isinstance(link['value'], int) + + def test_build_sankey_data_with_overflow(self): + transactions = [ + {'amount': 5000, 'category': 'salary', 'date': '2024-03-01'}, + {'amount': 1000, 'category': 'freelance', 'date': '2024-03-02'}, + {'amount': -1500, 'category': 'rent', 'date': '2024-03-03'}, + {'amount': -500, 'category': 'groceries', 'date': '2024-03-04'}, + ] + + result = build_sankey_data(transactions) + + # Should have 'unspent' node + node_names = [node['name'] for node in result['nodes']] + assert 'unspent' in node_names + + # Should have link to unspent + unspent_index = node_names.index('unspent') + total_income_index = node_names.index('total income') + + # Find link from total income to unspent + unspent_links = [link for link in result['links'] + if link['source'] == total_income_index + and link['target'] == unspent_index] + assert len(unspent_links) == 1 + assert unspent_links[0]['value'] == 4000 # 6000 - 2000 + + def test_build_sankey_data_with_overspending(self): + transactions = [ + {'amount': 3000, 'category': 'salary', 'date': '2024-03-01'}, + {'amount': -1500, 'category': 'rent', 'date': '2024-03-03'}, + {'amount': -1000, 'category': 'groceries', 'date': '2024-03-04'}, + {'amount': -800, 'category': 'dining', 'date': '2024-03-05'}, + ] + + result = build_sankey_data(transactions) + + # Should have 'overspent' and 'savings' nodes + node_names = [node['name'] for node in result['nodes']] + assert 'overspent' in node_names + assert 'savings' in node_names + + # Should have link from savings to overspent + savings_index = node_names.index('savings') + overspent_index = node_names.index('overspent') + + overspent_links = [link for link in result['links'] + if link['source'] == savings_index + and link['target'] == overspent_index] + assert len(overspent_links) == 1 + assert overspent_links[0]['value'] == 300 # 3300 - 3000 + + def test_build_sankey_data_empty_transactions(self): + """Test sankey data with no transactions.""" + result = build_sankey_data([]) + + assert result['nodes'] == [{'name': 'total income'}, {'name': 'no data'}] + assert result['links'] == [] + + def test_build_sankey_data_only_income(self): + transactions = [ + {'amount': 5000, 'category': 'salary', 'date': '2024-03-01'}, + {'amount': 1000, 'category': 'freelance', 'date': '2024-03-02'}, + ] + + result = build_sankey_data(transactions) + + node_names = [node['name'] for node in result['nodes']] + assert 'unspent' in node_names + + # All income should flow to unspent + total_income_index = node_names.index('total income') + unspent_index = node_names.index('unspent') + + unspent_links = [link for link in result['links'] + if link['source'] == total_income_index + and link['target'] == unspent_index] + assert unspent_links[0]['value'] == 6000 + + def test_build_sankey_data_only_expenses(self): + transactions = [ + {'amount': -1500, 'category': 'rent', 'date': '2024-03-03'}, + {'amount': -500, 'category': 'groceries', 'date': '2024-03-04'}, + ] + + result = build_sankey_data(transactions) + + node_names = [node['name'] for node in result['nodes']] + assert 'overspent' in node_names + assert 'savings' in node_names + + # Expenses should come from savings + savings_index = node_names.index('savings') + overspent_index = node_names.index('overspent') + rent_index = node_names.index('rent') + groceries_index = node_names.index('groceries') + + # Links from savings to overspent and to categories + savings_links = [link for link in result['links'] if link['source'] == savings_index] + assert len(savings_links) == 1 # savings into overspent \ No newline at end of file diff --git a/app/server/services/python/analytics/test/periodCalc.py b/app/server/services/python/analytics/test/periodCalc.py new file mode 100644 index 0000000..8e272e9 --- /dev/null +++ b/app/server/services/python/analytics/test/periodCalc.py @@ -0,0 +1,146 @@ +import pytest +import pandas as pd +from src.utils.dates import period_calc + +class TestPeriodCalc: + + def test_period_calc_weekly(self): + end_date = pd.Timestamp('2024-03-15') + + start_date, freq, date_format, period_name = period_calc('w', end_date) + + # Check start date is 7 days before end date + expected_start = end_date - pd.Timedelta(days=7) + assert start_date == expected_start + assert start_date.strftime('%Y-%m-%d') == '2024-03-08' + + # Check other return values + assert freq == 'D' + assert date_format == '%Y-%m-%d' + assert period_name == 'Weekly' + + def test_period_calc_monthly(self): + end_date = pd.Timestamp('2024-03-15') + + start_date, freq, date_format, period_name = period_calc('m', end_date) + + # Check start date is 30 days before end date + expected_start = end_date - pd.Timedelta(days=30) + assert start_date == expected_start + assert start_date.strftime('%Y-%m-%d') == '2024-02-14' # March 15 - 30 days = Feb 14 + + # Check other return values + assert freq == 'D' + assert date_format == '%Y-%m-%d' + assert period_name == 'Monthly' + + def test_period_calc_yearly(self): + end_date = pd.Timestamp('2024-03-15') + + start_date, freq, date_format, period_name = period_calc('y', end_date) + + # Check start date is 365 days before end date + expected_start = end_date - pd.Timedelta(days=365) + assert start_date == expected_start + assert start_date.strftime('%Y-%m-%d') == '2023-03-16' # 2024 is leap year + + # Check other return values + assert freq == 'M' + assert date_format == '%Y-%m' + assert period_name == 'Yearly' + + def test_period_calc_with_different_end_dates(self): + test_cases = [ + # (end_date, expected_weekly_start, expected_monthly_start, expected_yearly_start) + ('2024-01-01', '2023-12-25', '2023-12-02', '2023-01-01'), + ('2024-06-15', '2024-06-08', '2024-05-16', '2023-06-16'), + ('2024-12-31', '2024-12-24', '2024-12-01', '2024-01-01'), + ] + + for end_str, expected_weekly, expected_monthly, expected_yearly in test_cases: + end_date = pd.Timestamp(end_str) + + # Test weekly + start, freq, fmt, name = period_calc('w', end_date) + assert start.strftime('%Y-%m-%d') == expected_weekly + + # Test monthly + start, freq, fmt, name = period_calc('m', end_date) + assert start.strftime('%Y-%m-%d') == expected_monthly + + # Test yearly + start, freq, fmt, name = period_calc('y', end_date) + assert start.strftime('%Y-%m-%d') == expected_yearly + + def test_period_calc_edge_cases(self): + + end_date = pd.Timestamp('2024-02-29') # Leap year + start, _, _, _ = period_calc('m', end_date) + assert start.strftime('%Y-%m-%d') == '2024-01-30' # Feb 29 - 30 days = Jan 30 + + end_date = pd.Timestamp('2024-12-31') + start, _, _, _ = period_calc('y', end_date) + assert start.strftime('%Y-%m-%d') == '2024-01-01' + + end_date = pd.Timestamp('2024-01-01') + start, _, _, _ = period_calc('m', end_date) + assert start.strftime('%Y-%m-%d') == '2023-12-02' # Jan 1 - 30 days = Dec 2 + + def test_period_calc_invalid_period(self): + end_date = pd.Timestamp('2024-03-15') + + # Invalid period should go to else branch (yearly) + start, freq, date_format, period_name = period_calc('invalid', end_date) + + expected_start = end_date - pd.Timedelta(days=365) + assert start == expected_start + assert freq == 'M' + assert date_format == '%Y-%m' + assert period_name == 'Yearly' + + def test_period_calc_with_time_component(self): + end_date = pd.Timestamp('2024-03-15 14:30:45') + + start, _, _, _ = period_calc('w', end_date) + + # Time component should be preserved in the calculation + expected_start = end_date - pd.Timedelta(days=7) + assert start == expected_start + assert start.hour == 14 + assert start.minute == 30 + assert start.second == 45 + + def test_period_calc_returns_tuple(self): + end_date = pd.Timestamp('2024-03-15') + result = period_calc('w', end_date) + + assert isinstance(result, tuple) + assert len(result) == 4 + + start, freq, date_format, period_name = result + assert isinstance(start, pd.Timestamp) + assert isinstance(freq, str) + assert isinstance(date_format, str) + assert isinstance(period_name, str) + + @pytest.mark.parametrize("period,expected_freq,expected_format,expected_name", [ + ('w', 'D', '%Y-%m-%d', 'Weekly'), + ('m', 'D', '%Y-%m-%d', 'Monthly'), + ('y', 'M', '%Y-%m', 'Yearly'), + ]) + def test_period_calc_parametrized(self, period, expected_freq, expected_format, expected_name): + end_date = pd.Timestamp('2024-03-15') + + start, freq, date_format, period_name = period_calc(period, end_date) + + assert freq == expected_freq + assert date_format == expected_format + assert period_name == expected_name + + # Verify start date calculation + if period == 'w': + assert start == end_date - pd.Timedelta(days=7) + elif period == 'm': + assert start == end_date - pd.Timedelta(days=30) + else: + assert start == end_date - pd.Timedelta(days=365) \ No newline at end of file diff --git a/app/server/services/python/analytics/test/savings.py b/app/server/services/python/analytics/test/savings.py new file mode 100644 index 0000000..02d7216 --- /dev/null +++ b/app/server/services/python/analytics/test/savings.py @@ -0,0 +1,99 @@ +import pytest +import pandas as pd +from datetime import datetime +from unittest.mock import patch, MagicMock +from src.logic.savings import calculate_savings_over_time + +class TestSavings: + + def test_calculate_savings_over_time_weekly(self, mock_savings_accounts, mock_savings_transactions): + end_date = pd.Timestamp('2024-03-15') + start_date = end_date - pd.Timedelta(days=7) + + result = calculate_savings_over_time( + mock_savings_accounts, + mock_savings_transactions, + start_date, + end_date, + 'w', + 'Weekly' + ) + + assert 'labels' in result + assert 'datasets' in result + assert len(result['labels']) == 8 # 7 days + 1? Check your implementation + assert result['datasets'][0]['label'] == 'Weekly Savings' + + # Verify data is numeric + for value in result['datasets'][0]['data']: + assert isinstance(value, (int, float)) + + def test_calculate_savings_over_time_monthly(self, mock_savings_accounts, mock_savings_transactions): + end_date = pd.Timestamp('2024-03-15') + start_date = end_date - pd.Timedelta(days=30) + + result = calculate_savings_over_time( + mock_savings_accounts, + mock_savings_transactions, + start_date, + end_date, + 'm', + 'Monthly' + ) + + assert result['datasets'][0]['label'] == 'Monthly Savings' + assert len(result['labels']) > 0 + + def test_calculate_savings_over_time_yearly(self, mock_savings_accounts, mock_savings_transactions): + end_date = pd.Timestamp('2024-03-15') + start_date = end_date - pd.Timedelta(days=365) + + result = calculate_savings_over_time( + mock_savings_accounts, + mock_savings_transactions, + start_date, + end_date, + 'y', + 'Yearly' + ) + + assert result['datasets'][0]['label'] == 'Yearly Savings' + # Yearly should have monthly labels + assert all(len(label) == 7 for label in result['labels']) # YYYY-MM format + + def test_calculate_savings_over_time_empty_transactions(self, mock_savings_accounts): + end_date = pd.Timestamp('2024-03-15') + start_date = end_date - pd.Timedelta(days=7) + + result = calculate_savings_over_time( + mock_savings_accounts, + [], # Empty transactions + start_date, + end_date, + 'w', + 'Weekly' + ) + + # Should still return structure with zero data + assert 'labels' in result + assert 'datasets' in result + # All savings values should be the initial balances + expected_initial = sum(acc['balance'] for acc in mock_savings_accounts) + assert all(value == expected_initial for value in result['datasets'][0]['data']) + + def test_calculate_savings_over_time_no_accounts(self): + end_date = pd.Timestamp('2024-03-15') + start_date = end_date - pd.Timedelta(days=7) + + result = calculate_savings_over_time( + [], # Empty accounts + [], + start_date, + end_date, + 'w', + 'Weekly' + ) + + # Should return empty structure + assert result['labels'] == [] + assert result['datasets'][0]['data'] == [] \ No newline at end of file diff --git a/app/server/services/python/analytics/test/test_budgetCalc.py b/app/server/services/python/analytics/test/test_budgetCalc.py index 87d6565..7811609 100644 --- a/app/server/services/python/analytics/test/test_budgetCalc.py +++ b/app/server/services/python/analytics/test/test_budgetCalc.py @@ -103,4 +103,520 @@ async def test_generate_budget(self, calculator, sample_transactions): assert hasattr(result, 'budget') assert hasattr(result, 'generated_date') - assert len(result.budget) <= 10 # At most 10 categories \ No newline at end of file + assert len(result.budget) <= 10 # At most 10 categories + + + #check if multiple saving goals adds 5% boost each + @pytest.mark.asyncio + async def test_calculate_goal_impact_save_goals(self, calculator): + goals = [ + {'type': 'save', 'category': 'vacation'}, + {'type': 'save', 'category': 'emergency_fund'}, + ] + avg_monthly_income = 5000 + monthly_avg_by_category = {} + + savings_boost, wants_reduction, categories = calculator.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg_by_category + ) + + # 2 goals * 5% of $5000 = $500 + assert savings_boost == 500 + assert wants_reduction == 0 + assert set(categories) == {'vacation', 'emergency_fund'} + + + #check if a single savings goal adds a 5% boost + @pytest.mark.asyncio + async def test_calculate_goal_impact_single_save_goal(self, calculator): + goals = [ + {'type': 'save', 'category': 'vacation'}, + ] + avg_monthly_income = 5000 + monthly_avg_by_category = {} + + savings_boost, wants_reduction, categories = calculator.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg_by_category + ) + + assert savings_boost == 250 # 5% of $5000 + assert wants_reduction == 0 + assert categories == ['vacation'] + + + + #test reduce spending goal when spending exceeds limit (>=100%) + @pytest.mark.asyncio + async def test_calculate_goal_impact_reduce_spending_over_limit(self, calculator): + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 400}, + ] + avg_monthly_income = 5000 + monthly_avg_by_category = {'groceries': 450} # Over limit + + savings_boost, wants_reduction, categories = calculator.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg_by_category + ) + + assert savings_boost == 0 + assert wants_reduction == 5000 * 0.07 # 7% of $5000 = $350 + assert categories == ['groceries'] + + + #test reduce spending goal when spending is 80-99% of limit + @pytest.mark.asyncio + async def test_calculate_goal_impact_reduce_spending_approaching_limit(self, calculator): + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 100}, + ] + avg_monthly_income = 5000 + monthly_avg_by_category = {'groceries': 87.5} # 87.5% of limit + + savings_boost, wants_reduction, categories = calculator.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg_by_category + ) + + assert savings_boost == 0 + assert wants_reduction == 5000 * 0.05 # 5% of $5000 = $250 + assert categories == ['groceries'] + + + #test reduce spending goal when spending is 60-79% of limit + @pytest.mark.asyncio + async def test_calculate_goal_impact_reduce_spending_getting_close(self, calculator): + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 100}, + ] + avg_monthly_income = 5000 + monthly_avg_by_category = {'groceries': 70} # 70% of limit + + savings_boost, wants_reduction, categories = calculator.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg_by_category + ) + + assert savings_boost == 0 + assert wants_reduction == 5000 * 0.03 # 3% of $5000 = $150 + assert categories == ['groceries'] + + + #test reduce spending goal when spending is below 60% of limit + @pytest.mark.asyncio + async def test_calculate_goal_impact_reduce_spending_below_threshold(self, calculator): + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 100}, + ] + avg_monthly_income = 5000 + monthly_avg_by_category = {'groceries': 50} # 50% of limit + + savings_boost, wants_reduction, categories = calculator.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg_by_category + ) + + assert savings_boost == 0 + assert wants_reduction == 0 + assert categories == ['groceries'] + + + #test multiple reduce spending goals - should take the max reduction + @pytest.mark.asyncio + async def test_calculate_goal_impact_multiple_reduce_spending_goals(self, calculator): + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 100}, + {'type': 'reduce_spending', 'category': 'dining', 'target': 100}, + ] + avg_monthly_income = 5000 + monthly_avg_by_category = { + 'groceries': 101, #over limit - 7% reduction + 'dining': 83, # 83% of limit - 5% reduction + } + + savings_boost, wants_reduction, categories = calculator.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg_by_category + ) + + assert savings_boost == 0 + #should take the max reduction (7% from groceries) + assert wants_reduction == 5000 * 0.07 # $350 + assert set(categories) == {'groceries', 'dining'} + + + #test mixed save and reduce spending goals + @pytest.mark.asyncio + async def test_calculate_goal_impact_mixed_goals(self, calculator): + goals = [ + {'type': 'save', 'category': 'vacation'}, + {'type': 'reduce_spending', 'category': 'groceries', 'target': 100}, + ] + avg_monthly_income = 5000 + monthly_avg_by_category = {'groceries': 101} # Over limit + + savings_boost, wants_reduction, categories = calculator.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg_by_category + ) + + #save goal: 5% boost = $250 + assert savings_boost == 250 + #reduce spending: 7% reduction = $350 + assert round(wants_reduction) == 350 + assert set(categories) == {'vacation', 'groceries'} + + + #test category budget calculation with a reduce_spending goal cap + @pytest.mark.asyncio + async def test_calculate_category_budget_with_reduce_spending_goal(self, calculator): + monthly_avg = {'groceries': 300} + category_types = {'groceries': 'want'} + pools = {'needs': 2000, 'wants': 1000, 'savings': 500} + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 250} + ] + + #without goal, recommended would be (300 / 300) * 1000 = 1000 + #with goal, should be capped at 250 + result = calculator.calculate_category_budget( + 'groceries', 300, 'want', monthly_avg, category_types, pools, goals + ) + + assert result == 250 + + + #test category budget where goal cap is higher than calculated budget + @pytest.mark.asyncio + async def test_calculate_category_budget_with_goal_above_cap(self, calculator): + monthly_avg = {'groceries': 300} + category_types = {'groceries': 'want'} + pools = {'needs': 2000, 'wants': 500, 'savings': 500} + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 400} + ] + + #without goal: (300 / 300) * 500 = 500 + #goal cap is 400, but calculated is 500, so cap to 400 + result = calculator.calculate_category_budget( + 'groceries', 300, 'want', monthly_avg, category_types, pools, goals + ) + + assert result == 400 + + + #test category budget where goal doesn't affect the calculation + @pytest.mark.asyncio + async def test_calculate_category_budget_with_no_goal_impact(self, calculator): + monthly_avg = {'groceries': 300} + category_types = {'groceries': 'want'} + pools = {'needs': 2000, 'wants': 1000, 'savings': 500} + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 500} + ] + + # Calculated budget is 1000, goal cap is 500, so cap to 500 + result = calculator.calculate_category_budget( + 'groceries', 300, 'want', monthly_avg, category_types, pools, goals + ) + + # Since calculated budget is 1000, it gets capped to 500 + assert result == 500 + + + + #test full budget generation with goals that influence the pools + @pytest.mark.asyncio + async def test_generate_budget_with_goals(self, calculator, sample_transactions): + avg_income = 5000 + goals = [ + {'type': 'save', 'category': 'vacation'}, + {'type': 'reduce_spending', 'category': 'groceries', 'target': 300}, + ] + + #create transactions with high grocery spending to trigger reduction + sample_transactions.append({'amount': -350, 'category': 'groceries', 'date': '2024-01-15'}) + + result = calculator.generate_budget(sample_transactions, 'w', avg_income, goals) + + assert hasattr(result, 'budget') + assert len(result.budget) > 0 + + #verify that the grocery category appears and has a capped budget + grocery_goal = next((g for g in result.budget if g.category == 'groceries'), None) + if grocery_goal: + #should be capped at 300 (or less) + assert grocery_goal.recommended_budget <= 300 + + + #test full budget generation with only save goals + @pytest.mark.asyncio + async def test_generate_budget_with_save_goal_only(self, calculator, sample_transactions): + avg_income = 5000 + + goals = [ + {'type': 'save', 'category': 'vacation'}, + {'type': 'save', 'category': 'emergency_fund'}, + ] + + result = calculator.generate_budget(sample_transactions, 'w', avg_income, goals) + + assert hasattr(result, 'budget') + #verify goal categories are included + goal_categories = {g.category for g in result.budget} + assert 'vacation' in goal_categories + assert 'emergency_fund' in goal_categories + + + #test full budget generation with reduce spending goals + @pytest.mark.asyncio + async def test_generate_budget_with_reduce_spending_goal(self, calculator, sample_transactions): + avg_income = 5000 + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 300}, + ] + + #add high grocery spending + sample_transactions.append({'amount': -350, 'category': 'groceries', 'date': '2024-01-15'}) + + result = calculator.generate_budget(sample_transactions, 'w', avg_income, goals) + + assert hasattr(result, 'budget') + + #verify grocery budget is capped + grocery = next((g for g in result.budget if g.category == 'groceries'), None) + if grocery: + assert grocery.recommended_budget <= 300 + + + #test that goal categories appear even if not in top 10 + @pytest.mark.asyncio + async def test_generate_budget_with_goal_category_not_in_top_10(self, calculator, sample_transactions): + avg_income = 5000 + + #add a goal for a category with very low spending + goals = [ + {'type': 'save', 'category': 'rare_category'}, + ] + + #add a small transaction for this category + sample_transactions.append({'amount': -10, 'category': 'rare_category', 'date': '2024-01-15'}) + + result = calculator.generate_budget(sample_transactions, 'w', avg_income, goals) + + #verify the goal category appears in the budget + goal_categories = {g.category for g in result.budget} + assert 'rare_category' in goal_categories + + + #test full budget generation with no goals (should work same as before) + @pytest.mark.asyncio + async def test_generate_budget_with_no_goals(self, calculator, sample_transactions): + avg_income = 5000 + + result = calculator.generate_budget(sample_transactions, 'w', avg_income, None) + + assert hasattr(result, 'budget') + assert len(result.budget) > 0 + + + #test full budget generation with empty goals list + @pytest.mark.asyncio + async def test_generate_budget_with_empty_goals_list(self, calculator, sample_transactions): + avg_income = 5000 + + result = calculator.generate_budget(sample_transactions, 'w', avg_income, []) + + assert hasattr(result, 'budget') + assert len(result.budget) > 0 + + + #test reduce spending goal with zero target (should be ignored). + @pytest.mark.asyncio + async def test_calculate_goal_impact_zero_target(self, calculator): + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 0}, + ] + avg_monthly_income = 5000 + monthly_avg_by_category = {'groceries': 450} + + savings_boost, wants_reduction, categories = calculator.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg_by_category + ) + + assert savings_boost == 0 + assert wants_reduction == 0 + assert categories == ['groceries'] + + + #test goal with missing category (should still work) + @pytest.mark.asyncio + async def test_calculate_goal_impact_missing_category(self, calculator): + goals = [ + {'type': 'save'}, #no category + {'type': 'reduce_spending', 'target': 400}, #no category + ] + avg_monthly_income = 5000 + monthly_avg_by_category = {} + + savings_boost, wants_reduction, categories = calculator.calculate_goal_impact( + goals, avg_monthly_income, monthly_avg_by_category + ) + + assert savings_boost == 250 #save goal still applies + assert wants_reduction == 0 + assert categories == [] #no categories added since they're missing + + + #test category budget when pool_actual is zero (no expenses in any want category) + @pytest.mark.asyncio + async def test_calculate_category_budget_with_zero_pool_actual(self, calculator): + monthly_avg = {'groceries': 300} + category_types = {'groceries': 'want'} + + monthly_avg = {} #no want categories have any spending + category_types = {'new_category': 'want'} + pools = {'needs': 2000, 'wants': 1000, 'savings': 500} + goals = [] + + result = calculator.calculate_category_budget( + 'new_category', 0, 'want', monthly_avg, category_types, pools, goals + ) + + #when pool_actual is 0 and avg_spent is 0, recommended = 0 * 0.9 = 0 + assert result == 0 + + #test that wants_reduction properly reduces wants and increases savings + @pytest.mark.asyncio + async def test_generate_budget_with_wants_reduction(self, calculator): + avg_income = 5000 + + groceries_per_month = 450 + grocery_transactions = [] + for month in range(1, 13): + grocery_transactions.append({ + 'amount': -groceries_per_month, + 'category': 'groceries', + 'date': f'2024-{month:02d}-15' + }) + + base_transactions = [ + {'amount': 5000, 'category': 'salary', 'date': '2024-01-01'}, + {'amount': -200, 'category': 'dining', 'date': '2024-01-02'}, + {'amount': -150, 'category': 'dining', 'date': '2024-01-03'}, + {'amount': -100, 'category': 'entertainment', 'date': '2024-01-04'}, + ] + + # Add monthly salary transactions + salary_transactions = [] + for month in range(1, 13): + salary_transactions.append({ + 'amount': 5000, + 'category': 'salary', + 'date': f'2024-{month:02d}-01' + }) + + all_transactions = salary_transactions + grocery_transactions + base_transactions + + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 400}, + ] + + avg_income = 5000 + + result = calculator.generate_budget(all_transactions, 'w', avg_income, goals) + + assert hasattr(result, 'budget') + assert len(result.budget) > 0 + wants_budget_total = 0 + for cat in result.budget: + if cat.type == 'want': + wants_budget_total += cat.recommended_budget + assert wants_budget_total <= 1200, f"Wants budget {wants_budget_total} should be reduced from baseline" + grocery = next((cat for cat in result.budget if cat.category == 'groceries'), None) + if grocery: + assert grocery.recommended_budget <= 400 # Should be capped at target + + + + #test with multiple reduce spending goals to ensure max reduction is applied + @pytest.mark.asyncio + async def test_generate_budget_with_multiple_reduce_spending_goals(self, calculator): + transactions = [] + for month in range(1, 13): + transactions.append({ + 'amount': 5000, 'category': 'salary', 'date': f'2024-{month:02d}-01' + }) + for month in range(1, 13): + transactions.append({ + 'amount': -450, 'category': 'groceries', 'date': f'2024-{month:02d}-10' + }) + + for month in range(1, 13): + transactions.append({ + 'amount': -350, 'category': 'dining', 'date': f'2024-{month:02d}-15' + }) + for month in range(1, 13): + transactions.append({ + 'amount': -250, 'category': 'entertainment', 'date': f'2024-{month:02d}-20' + }) + + avg_income = 5000 + + goals = [ + {'type': 'reduce_spending', 'category': 'groceries', 'target': 400}, #over limit: 450 -> 7% reduction + {'type': 'reduce_spending', 'category': 'dining', 'target': 400}, #350/400=87.5% -> 5% reduction + {'type': 'reduce_spending', 'category': 'entertainment', 'target': 300}, #250/300=83.3% -> 5% reduction + ] + + result = calculator.generate_budget(transactions, 'w', avg_income, goals) + + wants_budget_total = sum( + cat.recommended_budget for cat in result.budget if cat.type == 'want' + ) + + assert wants_budget_total <= 1200 + grocery = next((cat for cat in result.budget if cat.category == 'groceries'), None) + dining = next((cat for cat in result.budget if cat.category == 'dining'), None) + + if grocery: + assert grocery.recommended_budget <= 400 + if dining: + assert dining.recommended_budget <= 400 + + + #test multiple goal categories covering all three branches + @pytest.mark.asyncio + async def test_generate_budget_multiple_goal_categories_all_branches(self, calculator): + transactions = [] + for month in range(1, 13): + transactions.append({'amount': 5000, 'category': 'salary', 'date': f'2024-{month:02d}-01'}) + + for month in range(1, 13): + transactions.append({'amount': -5000, 'category': 'rent', 'date': f'2024-{month:02d}-05'}) + transactions.append({'amount': -1000, 'category': 'groceries', 'date': f'2024-{month:02d}-10'}) + transactions.append({'amount': -800, 'category': 'dining', 'date': f'2024-{month:02d}-12'}) + transactions.append({'amount': -600, 'category': 'entertainment', 'date': f'2024-{month:02d}-15'}) + + for month in range(1, 13): + transactions.append({'amount': -50, 'category': 'groceries_small', 'date': f'2024-{month:02d}-20'}) + + for month in range(1, 13): + transactions.append({'amount': 150, 'category': 'investment', 'date': f'2024-{month:02d}-25'}) + + avg_income = 5000 + 150 + + goals = [ + {'type': 'save', 'category': 'groceries_small'}, #in monthly_avg + {'type': 'save', 'category': 'investment'}, #in monthly_avg_savings + {'type': 'save', 'category': 'new_goal'}, #in neither + ] + + result = calculator.generate_budget(transactions, 'w', avg_income, goals) + + goal_categories = {cat.category for cat in result.budget} + assert 'groceries_small' in goal_categories + assert 'investment' in goal_categories + assert 'new_goal' in goal_categories + + groceries = next(cat for cat in result.budget if cat.category == 'groceries_small') + investment = next(cat for cat in result.budget if cat.category == 'investment') + new_goal = next(cat for cat in result.budget if cat.category == 'new_goal') + + assert groceries.avg_monthly_spent > 0 + assert investment.avg_monthly_spent > 0 + assert new_goal.avg_monthly_spent == 0 \ No newline at end of file diff --git a/app/server/services/python/analytics/test/test_budgetPerfCalc.py b/app/server/services/python/analytics/test/test_budgetPerfCalc.py index dfa3e17..12cd384 100644 --- a/app/server/services/python/analytics/test/test_budgetPerfCalc.py +++ b/app/server/services/python/analytics/test/test_budgetPerfCalc.py @@ -13,6 +13,49 @@ class TestBudgetPerformanceCalculator: def performance_calc(self): return BudgetPerformanceCalculator() + @pytest.fixture + def sample_budget_response_with_high_targets(self): + categories = [ + BudgetCategory( + category='groceries', + type='need', + avg_monthly_spent=300, + monthly_budget=280, + recommended_budget=500, + is_essential=True + ), + BudgetCategory( + category='dining', + type='want', + avg_monthly_spent=200, + monthly_budget=150, + recommended_budget=400, + is_essential=False + ), + BudgetCategory( + category='entertainment', + type='want', + avg_monthly_spent=150, + monthly_budget=120, + recommended_budget=300, + is_essential=False + ), + ] + return BudgetResponse( + budget=categories, + generated_date='2024-03-15 12:00:00' + ) + + @pytest.fixture + def mock_transactions_low_spending(self): + """Create transactions with low actual spending.""" + return [ + {'category': 'groceries', 'amount': -50, 'date': '2024-03-10'}, + {'category': 'groceries', 'amount': -30, 'date': '2024-03-12'}, + {'category': 'dining', 'amount': -40, 'date': '2024-03-14'}, + {'category': 'entertainment', 'amount': -20, 'date': '2024-03-15'}, + ] + @pytest.fixture def mock_transactions(self): return [ @@ -131,6 +174,32 @@ async def test_generate_performance_empty_transactions( # All actual amounts should be 0 assert all(amount == 0 for amount in result['actualAmounts']) mock_get.assert_called_once() + + + async def test_calculate_performance_underflow(self, performance_calc, sample_budget_response_with_high_targets): + #create actual spending that is much lower than budget - this will cause variance < -100 - underflow + actual_spending = { + 'groceries': 50, + 'dining': 30, + 'entertainment': 20 + } + + result = performance_calc.calculate_performance( + sample_budget_response_with_high_targets.budget, + actual_spending, + 'w' + ) + + assert len(result) > 0 + + #verify all variances are less than -100 + for i, category in enumerate(result['categories']): + budget = sample_budget_response_with_high_targets.budget[i].recommended_budget + actual = actual_spending[category] + variance = actual - budget + + assert variance < -100 + async def test_generate_performance_db_error( self, diff --git a/app/server/services/python/analytics/test/test_budget_integration.py b/app/server/services/python/analytics/test/test_budget_integration.py index 56724c3..df131ad 100644 --- a/app/server/services/python/analytics/test/test_budget_integration.py +++ b/app/server/services/python/analytics/test/test_budget_integration.py @@ -18,11 +18,15 @@ async def test_full_budget_flow(self): {'amount': 5000, 'category': 'salary', 'date': '2024-02-01'}, {'amount': -250, 'category': 'groceries', 'date': '2024-02-02'}, ] + + mock_goals = [] - with patch( - 'src.logic.budget.get_user_transactions_with_connection', - return_value=mock_transactions - ): + with patch('src.logic.budget.get_user_transactions_with_connection') as mock_transactions_query, \ + patch('src.logic.budget.get_user_goals') as mock_goals_query: + + mock_transactions_query.return_value = mock_transactions + mock_goals_query.return_value = mock_goals + # Generate budget budget = generate_budget(period, user_id) diff --git a/app/server/services/python/analytics/test/test_debts.py b/app/server/services/python/analytics/test/test_debts.py new file mode 100644 index 0000000..54b9d25 --- /dev/null +++ b/app/server/services/python/analytics/test/test_debts.py @@ -0,0 +1,52 @@ +import pytest +from src.logic.debt import generate_debt_payoff_stages +from src.models.schemas import BadRequestError, DebtPayoffRequest, DebtPayoffResponse + +class TestDebts: + def test_generate_debt_payoff(self, mock_debt_payoff_request): + result = generate_debt_payoff_stages(mock_debt_payoff_request) + + assert result.id == mock_debt_payoff_request.id + assert result.category == mock_debt_payoff_request.category + assert len(result.debtStages) > 0 + + # final debt should be zero + assert result.debtStages[-1].remainingDebt == 0 + + + def test_generate_debt_payoff_no_interest(self, mock_debt_payoff_request): + mock_debt_payoff_request.interestRate = 0 + + result = generate_debt_payoff_stages(mock_debt_payoff_request) + + # No interest should be charged + for stage in result.debtStages: + assert stage.interestAmount == 0 + + assert len(result.debtStages) == 5 + + + def test_last_payment_adjustment(self, mock_debt_payoff_request): + mock_debt_payoff_request.remainingAmount = 450 + mock_debt_payoff_request.minimumPayment = 200 + mock_debt_payoff_request.interestRate = 0 + + result = generate_debt_payoff_stages(mock_debt_payoff_request) + + last_stage = result.debtStages[-1] + + # Last payment should exactly clear the debt + assert last_stage.remainingDebt == 0 + assert last_stage.principalAmount <= mock_debt_payoff_request.minimumPayment + + + def test_min_payment_too_low(self, mock_debt_payoff_request): + mock_debt_payoff_request.minimumPayment = 1 + mock_debt_payoff_request.interestRate = 100 # Very high interest + + with pytest.raises(BadRequestError) as exc: + generate_debt_payoff_stages(mock_debt_payoff_request) + + assert "too low" in str(exc.value) + + diff --git a/app/server/services/python/analytics/test/test_dependencies.py b/app/server/services/python/analytics/test/test_dependencies.py index df09d1d..b148cde 100644 --- a/app/server/services/python/analytics/test/test_dependencies.py +++ b/app/server/services/python/analytics/test/test_dependencies.py @@ -1,3 +1,5 @@ +import importlib + import pytest from unittest.mock import patch, MagicMock import mysql.connector @@ -5,7 +7,6 @@ from fastapi import HTTPException import os import jwt -from jwt import InvalidSignatureError class TestDatabaseConnection: @@ -33,7 +34,7 @@ def test_get_db_connection_missing_env_vars(self, mock_connect): @patch('mysql.connector.connect') def test_get_db_connection_failure(self, mock_connect): - mock_connect.side_effect = mysql.connector.Error("Connection failed") + mock_connect.side_effect = mysql.connector.Error('Connection failed') with patch.dict(os.environ, {'MYSQL_HOST': 'localhost', 'MYSQL_USER': 'test_user', 'MYSQL_PASSWORD': 'test_password', 'DB_NAME': 'test_db'}, clear=True): with pytest.raises(mysql.connector.Error): get_db_connection() @@ -41,11 +42,29 @@ def test_get_db_connection_failure(self, mock_connect): class TestJWTAuthentication: + + @pytest.mark.asyncio + async def test_get_current_user_perfect_case(self): + payload = {'sub': '123'} + token = jwt.encode(payload, 'test', algorithm='HS256') + + if isinstance(token, bytes): + token = token.decode('utf-8') + + with patch.dict(os.environ, {'JWT_SECRET': 'test'}): + import src.dependencies + importlib.reload(src.dependencies) + from src.dependencies import get_current_user, SECRET_KEY + + assert SECRET_KEY == 'test' + + result = await get_current_user(authorization=f'Bearer {token}') + assert result == 123 @pytest.mark.asyncio async def test_get_current_user_missing_user_id(self): - payload = {'email': 'test@example.com'} + payload = {'sub': None} token = jwt.encode(payload, 'test_secret_key_12345', algorithm='HS256') # Ensure token is string @@ -54,11 +73,11 @@ async def test_get_current_user_missing_user_id(self): with patch.dict(os.environ, {'JWT_SECRET': 'test_secret_key_12345'}): with pytest.raises(HTTPException) as exc_info: - await get_current_user(authorization=f"Bearer {token}") + await get_current_user(authorization=f'Bearer {token}') - assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "User ID not found in token" - + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio async def test_get_current_user_invalid_signature(self): @@ -70,7 +89,38 @@ async def test_get_current_user_invalid_signature(self): with patch.dict(os.environ, {'JWT_SECRET': 'test_secret_key_12345'}): with pytest.raises(HTTPException) as exc_info: - await get_current_user(authorization=f"Bearer {token}") + await get_current_user(authorization=f'Bearer {token}') + + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_get_current_user_no_auth_header(self): + + payload = {'sub': '123'} + token = jwt.encode(payload, 'wrong_secret', algorithm='HS256') + + if isinstance(token, bytes): + token = token.decode('utf-8') + + with patch.dict(os.environ, {'JWT_SECRET': 'test_secret_key_12345'}): + with pytest.raises(HTTPException) as exc_info: + await get_current_user(authorization=None) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == 'Authorization header missing' + + @pytest.mark.asyncio + async def test_get_current_user_no_bearer(self): + + payload = {'sub': '123'} + token = jwt.encode(payload, 'test_secret_key_12345', algorithm='HS256') + + if isinstance(token, bytes): + token = token.decode('utf-8') + + with patch.dict(os.environ, {'JWT_SECRET': 'test_secret_key_12345'}): + with pytest.raises(HTTPException) as exc_info: + await get_current_user(authorization=f'something {token}') assert exc_info.value.status_code == 401 - assert "token" in exc_info.value.detail.lower() \ No newline at end of file + # assert exc_info.value.detail == '401: Invalid authentication scheme' \ No newline at end of file diff --git a/app/server/services/python/analytics/test/test_savings.py b/app/server/services/python/analytics/test/test_savings.py index 02d7216..53e4ed2 100644 --- a/app/server/services/python/analytics/test/test_savings.py +++ b/app/server/services/python/analytics/test/test_savings.py @@ -1,10 +1,34 @@ +from src.models.schemas import ProjectedSavingsRequest import pytest import pandas as pd -from datetime import datetime +from datetime import datetime, timedelta from unittest.mock import patch, MagicMock -from src.logic.savings import calculate_savings_over_time +from src.logic.savings import calculate_savings_over_time, calculate_compound_interest, generate_savings_growth_rate, compute_monthly_growth_rates, get_monthly_balances class TestSavings: + + @pytest.fixture + def mock_savings_transactions(self): + base_date = pd.Timestamp('2024-03-15') + return [ + {'financialAccount_id': 1, 'amount': 100, 'date': base_date - timedelta(days=5)}, + {'financialAccount_id': 1, 'amount': 200, 'date': base_date - timedelta(days=10)}, + {'financialAccount_id': 2, 'amount': -50, 'date': base_date - timedelta(days=7)}, + {'financialAccount_id': 2, 'amount': 150, 'date': base_date - timedelta(days=15)}, + {'financialAccount_id': 1, 'amount': -75, 'date': base_date - timedelta(days=20)}, + {'financialAccount_id': 1, 'amount': -25, 'date': base_date - timedelta(days=5)}, + {'financialAccount_id': 1, 'amount': -25, 'date': base_date - timedelta(days=5)}, + {'financialAccount_id': 1, 'amount': 25, 'date': base_date}, + {'financialAccount_id': 1, 'amount': 25, 'date': base_date}, + {'financialAccount_id': 1, 'amount': 25, 'date': base_date - timedelta(days=62)}, + ] + + @pytest.fixture + def mock_savings_accounts(self): + return [ + {'id': 1, 'balance': 5000}, + {'id': 2, 'balance': 3000}, + ] def test_calculate_savings_over_time_weekly(self, mock_savings_accounts, mock_savings_transactions): end_date = pd.Timestamp('2024-03-15') @@ -21,10 +45,9 @@ def test_calculate_savings_over_time_weekly(self, mock_savings_accounts, mock_sa assert 'labels' in result assert 'datasets' in result - assert len(result['labels']) == 8 # 7 days + 1? Check your implementation + assert len(result['labels']) == 8 assert result['datasets'][0]['label'] == 'Weekly Savings' - - # Verify data is numeric + for value in result['datasets'][0]['data']: assert isinstance(value, (int, float)) @@ -96,4 +119,37 @@ def test_calculate_savings_over_time_no_accounts(self): # Should return empty structure assert result['labels'] == [] - assert result['datasets'][0]['data'] == [] \ No newline at end of file + assert result['datasets'][0]['data'] == [] + + def test_get_monthly_balances(self, mock_savings_transactions_by_financial_account): + + result = get_monthly_balances(mock_savings_transactions_by_financial_account) + + # January = 150, February = 200 + assert result.loc[result['month'] == 1, 'amount'].values[0] == 150 + assert result.loc[result['month'] == 2, 'amount'].values[0] == 200 + + # cumulative balance + assert result.loc[result['month'] == 1, 'balance'].values[0] == 150 + assert result.loc[result['month'] == 2, 'balance'].values[0] == 350 + + def test_compute_growth_rates_handles_nan_and_inf(self, mock_monthly_diff): + + growth_rates = compute_monthly_growth_rates(mock_monthly_diff) + + # Should not contain NaN or inf + assert not growth_rates.isnull().any() + assert not (growth_rates == float("inf")).any() + + def test_generate_growth_rate(self, mock_transactions_with_same_amount): + + result = generate_savings_growth_rate(mock_transactions_with_same_amount) + + assert result is not None + assert result.best_case >= result.expected_case + assert result.expected_case >= result.worst_case + + def test_generate_growth_rate_empty(self): + result = generate_savings_growth_rate([]) + + assert result is None \ No newline at end of file diff --git a/app/server/services/python/market/Dockerfile.integration.test b/app/server/services/python/market/Dockerfile.integration.test new file mode 100644 index 0000000..d0d3e0e --- /dev/null +++ b/app/server/services/python/market/Dockerfile.integration.test @@ -0,0 +1,13 @@ +FROM measureonecodetwice/finus-market:local + +WORKDIR /app + +COPY integration ./integration +COPY requirements.txt . +COPY requirements-test.txt . + +RUN pip install -r requirements-test.txt + +ENV CI=true + +CMD ["python", "-m", "pytest", "integration/", "-v"] diff --git a/app/server/services/python/market/Dockerfile.mutation.test b/app/server/services/python/market/Dockerfile.mutation.test new file mode 100644 index 0000000..6f01ab3 --- /dev/null +++ b/app/server/services/python/market/Dockerfile.mutation.test @@ -0,0 +1,21 @@ +FROM measureonecodetwice/finus-market:local + +WORKDIR /app + +COPY src ./src +COPY test ./test +COPY requirements.txt . +COPY requirements-test.txt . +COPY pyproject.toml . + +RUN pip install -r requirements-test.txt + +RUN cp -r src market_src +RUN cp -r test mutation_test +RUN find mutation_test -type f -name "*.py" -exec perl -0pi -e "s/\\bfrom src import\\b/from market_src import/g; s/\\bimport src\\./import market_src./g; s/\\bsrc\\./market_src./g" {} + +RUN rm -rf src test + +ENV PYTHONPATH=/app +ENV CI=true + +CMD ["mutmut", "run"] diff --git a/app/server/services/python/market/Dockerfile.test b/app/server/services/python/market/Dockerfile.test new file mode 100644 index 0000000..81be651 --- /dev/null +++ b/app/server/services/python/market/Dockerfile.test @@ -0,0 +1,15 @@ +FROM measureonecodetwice/finus-market:local + +WORKDIR /app + +COPY test ./test +COPY requirements.txt . +COPY requirements-test.txt . + +RUN pip install -r requirements-test.txt + +ENV PYTHONPATH=/app +ENV JWT_SECRET=test_secret_key_12345 +ENV CI=true + +CMD ["python", "-m", "pytest", "test/", "-v", "--cov=src"] diff --git a/app/server/services/python/market/integration/test_market_integration.py b/app/server/services/python/market/integration/test_market_integration.py new file mode 100644 index 0000000..a50b45c --- /dev/null +++ b/app/server/services/python/market/integration/test_market_integration.py @@ -0,0 +1,181 @@ +import os +import time + +import httpx +import pytest + + +MARKET_SERVICE_ADDR = os.getenv("MARKET_SERVICE_ADDR", "http://market:8000") +READY_TIMEOUT_SECONDS = float(os.getenv("MARKET_READY_TIMEOUT_SECONDS", "60")) + + +def wait_for_market_service(base_url: str, timeout_seconds: float) -> None: + deadline = time.monotonic() + timeout_seconds + last_error = "market service never became reachable" + + while time.monotonic() < deadline: + try: + response = httpx.get(f"{base_url}/health", timeout=5.0) + if response.status_code == 200 and response.json() == "ok": + return + last_error = f"unexpected health response: {response.status_code} {response.text}" + except httpx.HTTPError as exc: + last_error = str(exc) + + time.sleep(1) + + pytest.fail(f"Market service was not ready within {timeout_seconds}s: {last_error}") + + +@pytest.fixture(scope="session") +def market_client() -> httpx.Client: + wait_for_market_service(MARKET_SERVICE_ADDR, READY_TIMEOUT_SECONDS) + + with httpx.Client(base_url=MARKET_SERVICE_ADDR, timeout=15.0) as client: + yield client + + +def test_market_health_endpoint(market_client: httpx.Client) -> None: + response = market_client.get("/health") + + assert response.status_code == 200 + assert response.json() == "ok" + + +def test_search_endpoint_returns_enriched_market_results( + market_client: httpx.Client, +) -> None: + response = market_client.get("/markets/search", params={"q": "", "limit": 2}) + + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, list) + assert 0 < len(payload) <= 2 + + result = payload[0] + assert result["symbol"] + assert result["displaySymbol"] + assert result["name"] + assert result["type"] in {"stock", "forex"} + assert isinstance(result["price"], (int, float)) + assert isinstance(result["timestamp"], int) + assert result["source"] == "yahoo" + + +def test_search_endpoint_respects_limit_param(market_client: httpx.Client) -> None: + response = market_client.get("/markets/search", params={"q": "", "limit": 1}) + + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, list) + assert len(payload) == 1 + + +def test_search_endpoint_can_return_forex_results_for_forex_query( + market_client: httpx.Client, +) -> None: + response = market_client.get("/markets/search", params={"q": "EUR", "limit": 8}) + + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, list) + assert any(item["type"] == "forex" for item in payload) + + +def test_search_endpoint_rejects_limit_above_maximum( + market_client: httpx.Client, +) -> None: + response = market_client.get("/markets/search", params={"q": "", "limit": 21}) + + assert response.status_code == 422 + + +def test_search_endpoint_rejects_limit_below_minimum( + market_client: httpx.Client, +) -> None: + response = market_client.get("/markets/search", params={"q": "", "limit": 0}) + + assert response.status_code == 422 + + +def test_quote_endpoint_returns_live_snapshot(market_client: httpx.Client) -> None: + response = market_client.get("/markets/quote", params={"symbol": "AAPL"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["symbol"] == "AAPL" + assert isinstance(payload["price"], (int, float)) + assert isinstance(payload["timestamp"], int) + assert payload["source"] == "yahoo" + + +def test_quote_endpoint_requires_symbol(market_client: httpx.Client) -> None: + response = market_client.get("/markets/quote") + + assert response.status_code == 422 + + +def test_history_endpoint_returns_points_for_supported_period( + market_client: httpx.Client, +) -> None: + response = market_client.get( + "/markets/history", + params={"symbol": "AAPL", "period": "1mo", "interval": "1d"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["symbol"] == "AAPL" + assert payload["period"] == "1mo" + assert payload["interval"] == "1d" + assert payload["source"] == "yahoo" + assert isinstance(payload["points"], list) + assert payload["points"] + assert isinstance(payload["points"][0]["timestamp"], int) + assert isinstance(payload["points"][0]["price"], (int, float)) + + +def test_history_endpoint_points_are_in_chronological_order( + market_client: httpx.Client, +) -> None: + response = market_client.get( + "/markets/history", + params={"symbol": "AAPL", "period": "1mo", "interval": "1d"}, + ) + + assert response.status_code == 200 + timestamps = [point["timestamp"] for point in response.json()["points"]] + assert timestamps == sorted(timestamps) + + +def test_history_endpoint_requires_symbol(market_client: httpx.Client) -> None: + response = market_client.get( + "/markets/history", + params={"period": "1mo", "interval": "1d"}, + ) + + assert response.status_code == 422 + + +def test_history_endpoint_rejects_unsupported_period( + market_client: httpx.Client, +) -> None: + response = market_client.get( + "/markets/history", + params={"symbol": "AAPL", "period": "bad", "interval": "1d"}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Unsupported period." + + +def test_history_endpoint_rejects_unsupported_interval( + market_client: httpx.Client, +) -> None: + response = market_client.get( + "/markets/history", + params={"symbol": "AAPL", "period": "1mo", "interval": "bad"}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Unsupported interval." diff --git a/app/server/services/python/market/pyproject.toml b/app/server/services/python/market/pyproject.toml new file mode 100644 index 0000000..34fe554 --- /dev/null +++ b/app/server/services/python/market/pyproject.toml @@ -0,0 +1,8 @@ +[tool.mutmut] +paths_to_mutate = ["market_src/"] +pytest_add_cli_args_test_selection = ["mutation_test/"] +pytest_add_cli_args = ["-q"] +mutate_only_covered_lines = true +do_not_mutate = ["market_src/__init__.py"] +also_copy = ["mutation_test/"] +debug = true diff --git a/app/server/services/python/market/requirements-test.txt b/app/server/services/python/market/requirements-test.txt new file mode 100644 index 0000000..9924071 --- /dev/null +++ b/app/server/services/python/market/requirements-test.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest>=7.0.0 +pytest-cov>=4.1.0 +httpx>=0.27.0,<0.28.0 +mutmut>=3.2.3 diff --git a/app/server/services/python/market/src/__init__.py b/app/server/services/python/market/src/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/server/services/python/market/src/__init__.py @@ -0,0 +1 @@ + diff --git a/app/server/services/python/market/src/config.py b/app/server/services/python/market/src/config.py new file mode 100644 index 0000000..d57f798 --- /dev/null +++ b/app/server/services/python/market/src/config.py @@ -0,0 +1,124 @@ +YAHOO_SEARCH_URL = "https://query1.finance.yahoo.com/v1/finance/search" +YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" +YAHOO_SYMBOL_SAFE_CHARS = "=^.-" +SUPPORTED_PERIODS = {"1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y"} +SUPPORTED_INTERVALS = {"5m", "15m", "1d", "1wk", "1mo"} +DEFAULT_SEARCH_LIMIT = 8 +REQUEST_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ) +} + +FEATURED_STOCKS = [ + { + "symbol": "AAPL", + "displaySymbol": "AAPL", + "name": "Apple Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "MSFT", + "displaySymbol": "MSFT", + "name": "Microsoft Corporation", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "NVDA", + "displaySymbol": "NVDA", + "name": "NVIDIA Corporation", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "AMZN", + "displaySymbol": "AMZN", + "name": "Amazon.com, Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "GOOGL", + "displaySymbol": "GOOGL", + "name": "Alphabet Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "TSLA", + "displaySymbol": "TSLA", + "name": "Tesla, Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, +] + +FOREX_CATALOG = [ + { + "symbol": "EURUSD=X", + "displaySymbol": "EUR/USD", + "name": "Euro to US Dollar", + "type": "forex", + "currency": "USD", + }, + { + "symbol": "GBPUSD=X", + "displaySymbol": "GBP/USD", + "name": "British Pound to US Dollar", + "type": "forex", + "currency": "USD", + }, + { + "symbol": "USDJPY=X", + "displaySymbol": "USD/JPY", + "name": "US Dollar to Japanese Yen", + "type": "forex", + "currency": "JPY", + }, + { + "symbol": "USDCAD=X", + "displaySymbol": "USD/CAD", + "name": "US Dollar to Canadian Dollar", + "type": "forex", + "currency": "CAD", + }, + { + "symbol": "AUDUSD=X", + "displaySymbol": "AUD/USD", + "name": "Australian Dollar to US Dollar", + "type": "forex", + "currency": "USD", + }, + { + "symbol": "NZDUSD=X", + "displaySymbol": "NZD/USD", + "name": "New Zealand Dollar to US Dollar", + "type": "forex", + "currency": "USD", + }, + { + "symbol": "USDCHF=X", + "displaySymbol": "USD/CHF", + "name": "US Dollar to Swiss Franc", + "type": "forex", + "currency": "CHF", + }, + { + "symbol": "EURGBP=X", + "displaySymbol": "EUR/GBP", + "name": "Euro to British Pound", + "type": "forex", + "currency": "GBP", + }, +] + diff --git a/app/server/services/python/market/src/main.py b/app/server/services/python/market/src/main.py index e967fe8..2a4c945 100644 --- a/app/server/services/python/market/src/main.py +++ b/app/server/services/python/market/src/main.py @@ -1,14 +1,72 @@ -from fastapi import FastAPI, HTTPException -import uvicorn import os +import uvicorn +from fastapi import FastAPI, HTTPException, Query + +from .config import DEFAULT_SEARCH_LIMIT, SUPPORTED_INTERVALS, SUPPORTED_PERIODS +from .service import build_history, enrich_search_results, fetch_quote_snapshot +from .yahoo_client import ( + search_forex_catalog, + search_stock_catalog, + yahoo_search, +) + app = FastAPI() -@app.get('/health') + +@app.get("/health") def test_endpoint(): - return 'ok' + return "ok" + + +@app.get("/markets/search") +def search_markets( + q: str = Query(default="", min_length=0), + limit: int = Query(default=DEFAULT_SEARCH_LIMIT, ge=1, le=20), +): + forex_matches = search_forex_catalog(q, limit) + stock_matches = search_stock_catalog(q, limit) + yahoo_matches = yahoo_search(q, limit) + + combined = [] + seen_symbols = set() + for item in [*stock_matches, *forex_matches, *yahoo_matches]: + if item["symbol"] in seen_symbols: + continue + seen_symbols.add(item["symbol"]) + combined.append(item) + if len(combined) >= limit: + break + + return enrich_search_results(combined) + + +@app.get("/markets/quote") +def get_market_quote(symbol: str = Query(min_length=1)): + return fetch_quote_snapshot(symbol) + + +@app.get("/markets/history") +def get_market_history( + symbol: str = Query(min_length=1), + period: str = Query(default="6mo"), + interval: str = Query(default="1d"), +): + if period not in SUPPORTED_PERIODS: + raise HTTPException(status_code=400, detail="Unsupported period.") + if interval not in SUPPORTED_INTERVALS: + raise HTTPException(status_code=400, detail="Unsupported interval.") + + points, source = build_history(symbol, period, interval) + return { + "symbol": symbol, + "period": period, + "interval": interval, + "source": source, + "points": points, + } + if __name__ == "__main__": - port = int(os.getenv("PORT", 8000)) uvicorn.run(app, port=port) diff --git a/app/server/services/python/market/src/service.py b/app/server/services/python/market/src/service.py new file mode 100644 index 0000000..ab964f7 --- /dev/null +++ b/app/server/services/python/market/src/service.py @@ -0,0 +1,58 @@ +from fastapi import HTTPException + +from .utils import format_change, to_float +from .yahoo_client import extract_chart_points, fetch_yahoo_chart + + +def fetch_quote_snapshot(symbol: str) -> dict: + chart_result = fetch_yahoo_chart(symbol, "1mo", "1d") + points = extract_chart_points(chart_result) + meta = chart_result.get("meta") or {} + + latest_point = points[-1] + latest_price = to_float(meta.get("regularMarketPrice")) + if latest_price is None: + latest_price = latest_point["price"] + + previous_close = to_float(meta.get("chartPreviousClose")) + if previous_close is None: + previous_close = to_float(meta.get("previousClose")) + if previous_close is None and len(points) > 1: + previous_close = points[-2]["price"] + + change, change_percent = format_change(latest_price, previous_close) + + return { + "symbol": symbol, + "price": latest_price, + "change": change, + "changePercent": change_percent, + "timestamp": latest_point["timestamp"], + "source": "yahoo", + } + + +def build_history(symbol: str, period: str, interval: str) -> tuple[list[dict], str]: + chart_result = fetch_yahoo_chart(symbol, period, interval) + return extract_chart_points(chart_result), "yahoo" + + +def enrich_search_results(items: list[dict]) -> list[dict]: + enriched = [] + for item in items: + try: + snapshot = fetch_quote_snapshot(item["symbol"]) + enriched.append( + { + **item, + "price": snapshot["price"], + "change": snapshot["change"], + "changePercent": snapshot["changePercent"], + "timestamp": snapshot["timestamp"], + "source": snapshot["source"], + } + ) + except HTTPException: + continue + + return enriched diff --git a/app/server/services/python/market/src/utils.py b/app/server/services/python/market/src/utils.py new file mode 100644 index 0000000..9b060f5 --- /dev/null +++ b/app/server/services/python/market/src/utils.py @@ -0,0 +1,46 @@ +import math +from datetime import datetime, timezone + + +def is_number(value) -> bool: + return isinstance(value, (int, float)) and math.isfinite(value) + + +def to_float(value) -> float | None: + if is_number(value): + return round(float(value), 6) + return None + + +def to_timestamp(value) -> int | None: + if value is None: + return None + + if hasattr(value, "to_pydatetime"): + value = value.to_pydatetime() + + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return int(value.timestamp()) + + return None + + +def format_change( + current: float | None, previous: float | None +) -> tuple[float | None, float | None]: + if current is None or previous is None: + return None, None + + change = round(current - previous, 6) + if previous == 0: + return change, None + + change_percent = round((change / previous) * 100, 4) + return change, change_percent + + +def normalize_query(value: str) -> str: + return "".join(char.lower() for char in value if char.isalnum()) + diff --git a/app/server/services/python/market/src/yahoo_client.py b/app/server/services/python/market/src/yahoo_client.py new file mode 100644 index 0000000..a0e612a --- /dev/null +++ b/app/server/services/python/market/src/yahoo_client.py @@ -0,0 +1,171 @@ +from typing import Literal +from urllib.parse import quote + +import requests +from fastapi import HTTPException + +from .config import ( + FEATURED_STOCKS, + FOREX_CATALOG, + REQUEST_HEADERS, + YAHOO_CHART_URL, + YAHOO_SEARCH_URL, + YAHOO_SYMBOL_SAFE_CHARS, +) +from .utils import normalize_query, to_float + + +def fetch_yahoo_chart(symbol: str, period: str, interval: str) -> dict: + try: + response = requests.get( + YAHOO_CHART_URL.format(symbol=quote(symbol, safe=YAHOO_SYMBOL_SAFE_CHARS)), + params={ + "range": period, + "interval": interval, + "includePrePost": "false", + "events": "div,splits", + }, + headers=REQUEST_HEADERS, + timeout=8, + ) + response.raise_for_status() + payload = response.json() + except (requests.RequestException, ValueError) as exc: + raise HTTPException( + status_code=502, + detail="Unable to reach Yahoo market data.", + ) from exc + + chart = payload.get("chart") or {} + if chart.get("error"): + description = chart["error"].get("description") + raise HTTPException( + status_code=404, + detail=description or "Yahoo market data not found for symbol.", + ) + + results = chart.get("result") or [] + if not results: + raise HTTPException( + status_code=404, + detail="Yahoo market data not found for symbol.", + ) + + return results[0] + + +def extract_chart_points(chart_result: dict) -> list[dict]: + timestamps = chart_result.get("timestamp") or [] + indicators = chart_result.get("indicators") or {} + quotes = indicators.get("quote") or [] + if not quotes: + raise HTTPException( + status_code=404, + detail="Historical data not found for symbol.", + ) + + first_quote = quotes[0] or {} + closes = first_quote.get("close") or [] + points = [] + for timestamp, close in zip(timestamps, closes): + close_price = to_float(close) + if close_price is None: + continue + + points.append({"timestamp": int(timestamp), "price": close_price}) + + if not points: + raise HTTPException( + status_code=404, + detail="Historical data not found for symbol.", + ) + + return points + + +def search_forex_catalog(query: str, limit: int) -> list[dict]: + normalized_query = normalize_query(query) + if not normalized_query: + return FOREX_CATALOG[:limit] + + matches = [] + for item in FOREX_CATALOG: + haystack = normalize_query( + f"{item['symbol']} {item['displaySymbol']} {item['name']}" + ) + if normalized_query in haystack: + matches.append(item) + + return matches[:limit] + + +def search_stock_catalog(query: str, limit: int) -> list[dict]: + normalized_query = normalize_query(query) + if not normalized_query: + return FEATURED_STOCKS[:limit] + + matches = [] + for item in FEATURED_STOCKS: + haystack = normalize_query( + f"{item['symbol']} {item['displaySymbol']} {item['name']}" + ) + if normalized_query in haystack: + matches.append(item) + + return matches[:limit] + + +def yahoo_search(query: str, limit: int) -> list[dict]: + try: + response = requests.get( + YAHOO_SEARCH_URL, + params={"q": query, "quotesCount": limit * 2, "newsCount": 0}, + headers=REQUEST_HEADERS, + timeout=6, + ) + response.raise_for_status() + payload = response.json() + except (requests.RequestException, ValueError): + return [] + + results = [] + for item in payload.get("quotes") or []: + quote_type = item.get("quoteType") + symbol = item.get("symbol") + if not symbol: + continue + + if quote_type == "EQUITY": + instrument_type: Literal["stock", "forex"] = "stock" + elif quote_type == "CURRENCY": + instrument_type = "forex" + else: + continue + + display_symbol = symbol if instrument_type == "stock" else symbol.replace("=X", "") + name = item.get("longname") or item.get("shortname") or symbol + currency = item.get("currency") or "USD" + + results.append( + { + "symbol": symbol, + "displaySymbol": display_symbol, + "name": name, + "type": instrument_type, + "currency": currency, + "exchange": item.get("exchange"), + } + ) + + unique_results = [] + seen_symbols = set() + for result in results: + if result["symbol"] in seen_symbols: + continue + seen_symbols.add(result["symbol"]) + unique_results.append(result) + if len(unique_results) >= limit: + break + + return unique_results + diff --git a/app/server/services/python/market/test/__init__.py b/app/server/services/python/market/test/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/server/services/python/market/test/__init__.py @@ -0,0 +1 @@ + diff --git a/app/server/services/python/market/test/test_config.py b/app/server/services/python/market/test/test_config.py new file mode 100644 index 0000000..bde78e1 --- /dev/null +++ b/app/server/services/python/market/test/test_config.py @@ -0,0 +1,149 @@ +from src import config + + +def test_market_config_core_constants(): + assert ( + config.YAHOO_SEARCH_URL + == "https://query1.finance.yahoo.com/v1/finance/search" + ) + assert ( + config.YAHOO_CHART_URL + == "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" + ) + assert config.YAHOO_SYMBOL_SAFE_CHARS == "=^.-" + assert config.DEFAULT_SEARCH_LIMIT == 8 + assert config.SUPPORTED_PERIODS == { + "1d", + "5d", + "1mo", + "3mo", + "6mo", + "1y", + "2y", + "5y", + } + assert config.SUPPORTED_INTERVALS == {"5m", "15m", "1d", "1wk", "1mo"} + + +def test_market_config_request_headers(): + assert config.REQUEST_HEADERS == { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ) + } + + +def test_market_config_featured_stocks_catalog_contents(): + assert config.FEATURED_STOCKS == [ + { + "symbol": "AAPL", + "displaySymbol": "AAPL", + "name": "Apple Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "MSFT", + "displaySymbol": "MSFT", + "name": "Microsoft Corporation", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "NVDA", + "displaySymbol": "NVDA", + "name": "NVIDIA Corporation", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "AMZN", + "displaySymbol": "AMZN", + "name": "Amazon.com, Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "GOOGL", + "displaySymbol": "GOOGL", + "name": "Alphabet Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "TSLA", + "displaySymbol": "TSLA", + "name": "Tesla, Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + ] + + +def test_market_config_forex_catalog_contents(): + assert config.FOREX_CATALOG == [ + { + "symbol": "EURUSD=X", + "displaySymbol": "EUR/USD", + "name": "Euro to US Dollar", + "type": "forex", + "currency": "USD", + }, + { + "symbol": "GBPUSD=X", + "displaySymbol": "GBP/USD", + "name": "British Pound to US Dollar", + "type": "forex", + "currency": "USD", + }, + { + "symbol": "USDJPY=X", + "displaySymbol": "USD/JPY", + "name": "US Dollar to Japanese Yen", + "type": "forex", + "currency": "JPY", + }, + { + "symbol": "USDCAD=X", + "displaySymbol": "USD/CAD", + "name": "US Dollar to Canadian Dollar", + "type": "forex", + "currency": "CAD", + }, + { + "symbol": "AUDUSD=X", + "displaySymbol": "AUD/USD", + "name": "Australian Dollar to US Dollar", + "type": "forex", + "currency": "USD", + }, + { + "symbol": "NZDUSD=X", + "displaySymbol": "NZD/USD", + "name": "New Zealand Dollar to US Dollar", + "type": "forex", + "currency": "USD", + }, + { + "symbol": "USDCHF=X", + "displaySymbol": "USD/CHF", + "name": "US Dollar to Swiss Franc", + "type": "forex", + "currency": "CHF", + }, + { + "symbol": "EURGBP=X", + "displaySymbol": "EUR/GBP", + "name": "Euro to British Pound", + "type": "forex", + "currency": "GBP", + }, + ] diff --git a/app/server/services/python/market/test/test_main.py b/app/server/services/python/market/test/test_main.py new file mode 100644 index 0000000..e5bf976 --- /dev/null +++ b/app/server/services/python/market/test/test_main.py @@ -0,0 +1,136 @@ +import os +import runpy +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +import src.main + + +client = TestClient(src.main.app) + + +def test_health_endpoint(): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == "ok" + + +def test_search_route_combines_sources_and_enriches_results(): + with patch("src.main.search_forex_catalog", return_value=[{"symbol": "EURUSD=X"}]), patch( + "src.main.search_stock_catalog", return_value=[{"symbol": "AAPL"}] + ), patch("src.main.yahoo_search", return_value=[{"symbol": "AAPL"}, {"symbol": "MSFT"}]), patch( + "src.main.enrich_search_results", + return_value=[{"symbol": "AAPL"}, {"symbol": "EURUSD=X"}, {"symbol": "MSFT"}], + ) as enrich_mock: + response = client.get("/markets/search", params={"q": "apple", "limit": 5}) + + assert response.status_code == 200 + assert response.json() == [{"symbol": "AAPL"}, {"symbol": "EURUSD=X"}, {"symbol": "MSFT"}] + enrich_mock.assert_called_once_with( + [{"symbol": "AAPL"}, {"symbol": "EURUSD=X"}, {"symbol": "MSFT"}] + ) + + +def test_search_route_stops_combining_once_limit_is_reached(): + with patch( + "src.main.search_forex_catalog", + return_value=[{"symbol": "EURUSD=X"}, {"symbol": "GBPUSD=X"}], + ), patch( + "src.main.search_stock_catalog", + return_value=[{"symbol": "AAPL"}, {"symbol": "MSFT"}], + ), patch( + "src.main.yahoo_search", + return_value=[{"symbol": "TSLA"}], + ), patch( + "src.main.enrich_search_results", + side_effect=lambda items: items, + ) as enrich_mock: + response = client.get("/markets/search", params={"q": "a", "limit": 2}) + + assert response.status_code == 200 + assert response.json() == [{"symbol": "AAPL"}, {"symbol": "MSFT"}] + enrich_mock.assert_called_once_with([{"symbol": "AAPL"}, {"symbol": "MSFT"}]) + + +def test_search_route_uses_default_query_and_limit(): + with patch("src.main.search_forex_catalog", return_value=[]) as forex_mock, patch( + "src.main.search_stock_catalog", return_value=[] + ) as stock_mock, patch("src.main.yahoo_search", return_value=[]) as yahoo_mock, patch( + "src.main.enrich_search_results", + return_value=[], + ) as enrich_mock: + response = client.get("/markets/search") + + assert response.status_code == 200 + assert response.json() == [] + forex_mock.assert_called_once_with("", src.main.DEFAULT_SEARCH_LIMIT) + stock_mock.assert_called_once_with("", src.main.DEFAULT_SEARCH_LIMIT) + yahoo_mock.assert_called_once_with("", src.main.DEFAULT_SEARCH_LIMIT) + enrich_mock.assert_called_once_with([]) + + +def test_quote_route_delegates_to_service(): + with patch("src.main.fetch_quote_snapshot", return_value={"symbol": "AAPL"}) as quote_mock: + response = client.get("/markets/quote", params={"symbol": "AAPL"}) + + assert response.status_code == 200 + assert response.json() == {"symbol": "AAPL"} + quote_mock.assert_called_once_with("AAPL") + + +def test_history_route_returns_payload_and_validation_errors(): + with patch( + "src.main.build_history", + return_value=([{"timestamp": 1, "price": 10.0}], "yahoo"), + ) as history_mock: + response = client.get( + "/markets/history", + params={"symbol": "AAPL", "period": "1mo", "interval": "1d"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "symbol": "AAPL", + "period": "1mo", + "interval": "1d", + "source": "yahoo", + "points": [{"timestamp": 1, "price": 10.0}], + } + history_mock.assert_called_once_with("AAPL", "1mo", "1d") + + invalid_period = client.get( + "/markets/history", + params={"symbol": "AAPL", "period": "bad", "interval": "1d"}, + ) + assert invalid_period.status_code == 400 + assert invalid_period.json()["detail"] == "Unsupported period." + + invalid_interval = client.get( + "/markets/history", + params={"symbol": "AAPL", "period": "1mo", "interval": "bad"}, + ) + assert invalid_interval.status_code == 400 + assert invalid_interval.json()["detail"] == "Unsupported interval." + + +def test_history_route_uses_default_period_and_interval(): + with patch( + "src.main.build_history", + return_value=([{"timestamp": 1, "price": 10.0}], "yahoo"), + ) as history_mock: + response = client.get("/markets/history", params={"symbol": "AAPL"}) + + assert response.status_code == 200 + assert response.json()["period"] == "6mo" + assert response.json()["interval"] == "1d" + history_mock.assert_called_once_with("AAPL", "6mo", "1d") + + +def test_main_module_runs_uvicorn_when_executed_as_script(): + with patch.dict(os.environ, {"PORT": "9999"}), patch("uvicorn.run") as run_mock: + runpy.run_module("src.main", run_name="__main__") + + run_mock.assert_called_once() + assert run_mock.call_args.kwargs["port"] == 9999 diff --git a/app/server/services/python/market/test/test_service.py b/app/server/services/python/market/test/test_service.py new file mode 100644 index 0000000..f009734 --- /dev/null +++ b/app/server/services/python/market/test/test_service.py @@ -0,0 +1,447 @@ +from unittest.mock import patch + +from fastapi import HTTPException + +from src import service + + +def test_fetch_quote_snapshot_uses_market_price_and_previous_close(): + chart_result = { + "meta": { + "regularMarketPrice": 151.23, + "chartPreviousClose": 149.0, + } + } + points = [ + {"timestamp": 1700000000, "price": 149.0}, + {"timestamp": 1700003600, "price": 150.5}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot == { + "symbol": "AAPL", + "price": 151.23, + "change": 2.23, + "changePercent": 1.4966, + "timestamp": 1700003600, + "source": "yahoo", + } + + +def test_fetch_quote_snapshot_handles_missing_meta_key(): + chart_result = {} + points = [ + {"timestamp": 1700000000, "price": 149.0}, + {"timestamp": 1700003600, "price": 150.5}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot == { + "symbol": "AAPL", + "price": 150.5, + "change": 1.5, + "changePercent": 1.0067, + "timestamp": 1700003600, + "source": "yahoo", + } + + +def test_fetch_quote_snapshot_requests_the_expected_chart_defaults(): + chart_result = {"meta": {"regularMarketPrice": 151.23, "chartPreviousClose": 149.0}} + points = [ + {"timestamp": 1700000000, "price": 149.0}, + {"timestamp": 1700003600, "price": 150.5}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result) as chart_mock, patch( + "src.service.extract_chart_points", return_value=points + ) as extract_mock: + service.fetch_quote_snapshot("AAPL") + + chart_mock.assert_called_once_with("AAPL", "1mo", "1d") + extract_mock.assert_called_once_with(chart_result) + + +def test_fetch_quote_snapshot_handles_missing_meta_key(): + chart_result = {} + points = [ + {"timestamp": 1700000000, "price": 149.0}, + {"timestamp": 1700003600, "price": 150.5}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot == { + "symbol": "AAPL", + "price": 150.5, + "change": 1.5, + "changePercent": 1.0067, + "timestamp": 1700003600, + "source": "yahoo", + } + + +def test_fetch_quote_snapshot_falls_back_to_chart_points_when_meta_missing(): + chart_result = {"meta": {}} + points = [ + {"timestamp": 1700000000, "price": 149.0}, + {"timestamp": 1700003600, "price": 150.5}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot == { + "symbol": "AAPL", + "price": 150.5, + "change": 1.5, + "changePercent": 1.0067, + "timestamp": 1700003600, + "source": "yahoo", + } + + +def test_fetch_quote_snapshot_falls_back_to_previous_close_meta(): + chart_result = {"meta": {"regularMarketPrice": 151.23, "previousClose": 150.0}} + points = [ + {"timestamp": 1700000000, "price": 149.0}, + {"timestamp": 1700003600, "price": 150.5}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot == { + "symbol": "AAPL", + "price": 151.23, + "change": 1.23, + "changePercent": 0.82, + "timestamp": 1700003600, + "source": "yahoo", + } + + +def test_fetch_quote_snapshot_falls_back_to_previous_point_without_meta_closes(): + chart_result = {"meta": {}} + points = [ + {"timestamp": 1700000000, "price": 100.0}, + {"timestamp": 1700003600, "price": 110.0}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot == { + "symbol": "AAPL", + "price": 110.0, + "change": 10.0, + "changePercent": 10.0, + "timestamp": 1700003600, + "source": "yahoo", + } + + +def test_fetch_quote_snapshot_returns_none_change_with_single_point(): + chart_result = {"meta": {}} + points = [{"timestamp": 1700003600, "price": 150.5}] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot == { + "symbol": "AAPL", + "price": 150.5, + "change": None, + "changePercent": None, + "timestamp": 1700003600, + "source": "yahoo", + } + + +def test_fetch_quote_snapshot_prefers_chart_previous_close_over_previous_close(): + chart_result = { + "meta": { + "regularMarketPrice": 151.23, + "chartPreviousClose": 149.0, + "previousClose": 140.0, + } + } + points = [ + {"timestamp": 1700000000, "price": 140.0}, + {"timestamp": 1700003600, "price": 150.5}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot["change"] == 2.23 + assert snapshot["changePercent"] == 1.4966 + + +def test_fetch_quote_snapshot_falls_back_when_meta_values_are_not_numeric(): + chart_result = { + "meta": { + "regularMarketPrice": "bad", + "chartPreviousClose": "bad", + "previousClose": "bad", + } + } + points = [ + {"timestamp": 1700000000, "price": 149.0}, + {"timestamp": 1700003600, "price": 150.5}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot == { + "symbol": "AAPL", + "price": 150.5, + "change": 1.5, + "changePercent": 1.0067, + "timestamp": 1700003600, + "source": "yahoo", + } + + +def test_fetch_quote_snapshot_preserves_zero_market_price_and_previous_close(): + chart_result = { + "meta": { + "regularMarketPrice": 0.0, + "chartPreviousClose": 0.0, + } + } + points = [ + {"timestamp": 1700000000, "price": 10.0}, + {"timestamp": 1700003600, "price": 12.0}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot == { + "symbol": "AAPL", + "price": 0.0, + "change": 0.0, + "changePercent": None, + "timestamp": 1700003600, + "source": "yahoo", + } + + +def test_fetch_quote_snapshot_uses_last_point_timestamp_and_price_fallback(): + chart_result = {"meta": {}} + points = [ + {"timestamp": 1700000000, "price": 100.0}, + {"timestamp": 1700003600, "price": 110.0}, + {"timestamp": 1700007200, "price": 120.0}, + ] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result), patch( + "src.service.extract_chart_points", return_value=points + ): + snapshot = service.fetch_quote_snapshot("AAPL") + + assert snapshot["price"] == 120.0 + assert snapshot["timestamp"] == 1700007200 + assert snapshot["change"] == 10.0 + assert snapshot["changePercent"] == 9.0909 + + +def test_build_history_returns_points_and_source(): + chart_result = {"meta": {"symbol": "AAPL"}} + points = [{"timestamp": 1700000000, "price": 123.45}] + + with patch("src.service.fetch_yahoo_chart", return_value=chart_result) as chart_mock, patch( + "src.service.extract_chart_points", return_value=points + ) as extract_mock: + assert service.build_history("AAPL", "1mo", "1d") == (points, "yahoo") + chart_mock.assert_called_once_with("AAPL", "1mo", "1d") + extract_mock.assert_called_once_with(chart_result) + + +def test_enrich_search_results_skips_items_that_raise_http_exceptions(): + with patch( + "src.service.fetch_quote_snapshot", + side_effect=[ + { + "symbol": "AAPL", + "price": 100.0, + "change": 1.0, + "changePercent": 1.0, + "timestamp": 1700000000, + "source": "yahoo", + }, + HTTPException(status_code=404, detail="missing"), + ], + ): + enriched = service.enrich_search_results( + [ + {"symbol": "AAPL", "name": "Apple"}, + {"symbol": "MISS", "name": "Missing"}, + ] + ) + + assert enriched == [ + { + "symbol": "AAPL", + "name": "Apple", + "price": 100.0, + "change": 1.0, + "changePercent": 1.0, + "timestamp": 1700000000, + "source": "yahoo", + } + ] + + +def test_enrich_search_results_preserves_existing_fields(): + with patch( + "src.service.fetch_quote_snapshot", + return_value={ + "symbol": "AAPL", + "price": 100.0, + "change": 1.0, + "changePercent": 1.0, + "timestamp": 1700000000, + "source": "yahoo", + }, + ): + enriched = service.enrich_search_results( + [ + { + "symbol": "AAPL", + "name": "Apple", + "type": "stock", + "currency": "USD", + } + ] + ) + + assert enriched == [ + { + "symbol": "AAPL", + "name": "Apple", + "type": "stock", + "currency": "USD", + "price": 100.0, + "change": 1.0, + "changePercent": 1.0, + "timestamp": 1700000000, + "source": "yahoo", + } + ] + + +def test_enrich_search_results_returns_empty_for_empty_input(): + with patch("src.service.fetch_quote_snapshot") as snapshot_mock: + enriched = service.enrich_search_results([]) + + assert enriched == [] + snapshot_mock.assert_not_called() + + +def test_enrich_search_results_propagates_non_http_exceptions(): + with patch( + "src.service.fetch_quote_snapshot", + side_effect=RuntimeError("boom"), + ): + try: + service.enrich_search_results([{"symbol": "AAPL"}]) + except RuntimeError as exc: + assert str(exc) == "boom" + else: + raise AssertionError("Expected RuntimeError to propagate") + + +def test_enrich_search_results_calls_snapshot_for_each_symbol_in_order(): + with patch( + "src.service.fetch_quote_snapshot", + side_effect=[ + { + "symbol": "AAPL", + "price": 1.0, + "change": 0.1, + "changePercent": 10.0, + "timestamp": 1, + "source": "yahoo", + }, + { + "symbol": "MSFT", + "price": 2.0, + "change": 0.2, + "changePercent": 11.0, + "timestamp": 2, + "source": "yahoo", + }, + ], + ) as snapshot_mock: + enriched = service.enrich_search_results( + [ + {"symbol": "AAPL", "name": "Apple"}, + {"symbol": "MSFT", "name": "Microsoft"}, + ] + ) + + assert [item["symbol"] for item in enriched] == ["AAPL", "MSFT"] + assert [call.args[0] for call in snapshot_mock.call_args_list] == ["AAPL", "MSFT"] + + +def test_enrich_search_results_skips_failed_item_and_continues_afterward(): + with patch( + "src.service.fetch_quote_snapshot", + side_effect=[ + HTTPException(status_code=404, detail="missing"), + { + "symbol": "MSFT", + "price": 2.0, + "change": 0.2, + "changePercent": 11.0, + "timestamp": 2, + "source": "yahoo", + }, + ], + ): + enriched = service.enrich_search_results( + [ + {"symbol": "MISS", "name": "Missing"}, + {"symbol": "MSFT", "name": "Microsoft"}, + ] + ) + + assert enriched == [ + { + "symbol": "MSFT", + "name": "Microsoft", + "price": 2.0, + "change": 0.2, + "changePercent": 11.0, + "timestamp": 2, + "source": "yahoo", + } + ] diff --git a/app/server/services/python/market/test/test_utils.py b/app/server/services/python/market/test/test_utils.py new file mode 100644 index 0000000..31b4928 --- /dev/null +++ b/app/server/services/python/market/test/test_utils.py @@ -0,0 +1,117 @@ +import os +import time +from datetime import datetime, timezone + +from src import utils + + +class FakeTimestamp: + def __init__(self, value: datetime): + self.value = value + + def to_pydatetime(self) -> datetime: + return self.value + + +class TrackingTimestamp: + def __init__(self, value: datetime): + self.value = value + self.calls = 0 + + def to_pydatetime(self) -> datetime: + self.calls += 1 + return self.value + + +class BadTimestamp: + def __init__(self, value): + self.value = value + + def to_pydatetime(self): + return self.value + + +def test_is_number_and_to_float_helpers(): + assert utils.is_number(3.14) is True + assert utils.is_number("3.14") is False + assert utils.is_number(float("nan")) is False + assert utils.to_float(12.3456789) == 12.345679 + assert utils.to_float("12") is None + + +def test_to_timestamp_handles_datetime_like_objects(): + timestamp = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) + naive_timestamp = datetime(2024, 1, 1, 12, 0) + assert utils.to_timestamp(timestamp) == int(timestamp.timestamp()) + assert utils.to_timestamp(naive_timestamp) == int( + naive_timestamp.replace(tzinfo=timezone.utc).timestamp() + ) + assert utils.to_timestamp(FakeTimestamp(timestamp)) == int(timestamp.timestamp()) + assert utils.to_timestamp(None) is None + assert utils.to_timestamp("bad-value") is None + + +def test_to_timestamp_calls_to_pydatetime_once_and_normalizes_naive_values(): + naive_timestamp = datetime(2024, 1, 1, 12, 0) + wrapped = TrackingTimestamp(naive_timestamp) + + assert utils.to_timestamp(wrapped) == int( + naive_timestamp.replace(tzinfo=timezone.utc).timestamp() + ) + assert wrapped.calls == 1 + + +def test_to_timestamp_returns_none_when_to_pydatetime_is_not_a_datetime(): + assert utils.to_timestamp(BadTimestamp("not-a-datetime")) is None + + +def test_to_timestamp_preserves_timezone_aware_offsets(): + offset_timestamp = datetime.fromisoformat("2024-01-01T12:00:00+02:00") + + assert utils.to_timestamp(offset_timestamp) == int(offset_timestamp.timestamp()) + + +def test_to_timestamp_treats_naive_datetimes_as_utc_even_when_local_tz_differs(): + original_tz = os.environ.get("TZ") + try: + os.environ["TZ"] = "America/Winnipeg" + time.tzset() + naive_timestamp = datetime(2024, 1, 1, 12, 0) + + assert utils.to_timestamp(naive_timestamp) == int( + naive_timestamp.replace(tzinfo=timezone.utc).timestamp() + ) + finally: + if original_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original_tz + time.tzset() + + +def test_format_change_and_normalize_query(): + assert utils.format_change(11.0, 10.0) == (1.0, 10.0) + assert utils.format_change(11.0, 0.0) == (11.0, None) + assert utils.format_change(None, 10.0) == (None, None) + assert utils.normalize_query("EUR/USD Inc.") == "eurusdinc" + + +def test_is_number_rejects_infinity_values(): + assert utils.is_number(float("inf")) is False + assert utils.is_number(float("-inf")) is False + + +def test_format_change_returns_none_when_previous_missing(): + assert utils.format_change(11.0, None) == (None, None) + + +def test_format_change_handles_negative_moves(): + assert utils.format_change(9.0, 10.0) == (-1.0, -10.0) + + +def test_normalize_query_preserves_digits_while_removing_symbols(): + assert utils.normalize_query("BTC-USD 2024!") == "btcusd2024" + + +def test_format_change_applies_rounding_precision(): + assert utils.format_change(10.1234567, 9.7654321) == (0.358025, 3.6662) diff --git a/app/server/services/python/market/test/test_yahoo_client.py b/app/server/services/python/market/test/test_yahoo_client.py new file mode 100644 index 0000000..ed00cf0 --- /dev/null +++ b/app/server/services/python/market/test/test_yahoo_client.py @@ -0,0 +1,783 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException + +from src import yahoo_client + + +def build_response(payload): + response = MagicMock() + response.raise_for_status.return_value = None + response.json.return_value = payload + return response + + +def test_fetch_yahoo_chart_returns_first_result(): + payload = {"chart": {"result": [{"meta": {"symbol": "AAPL"}}], "error": None}} + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)) as mock_get: + result = yahoo_client.fetch_yahoo_chart("AAPL", "1mo", "1d") + + assert result == {"meta": {"symbol": "AAPL"}} + mock_get.assert_called_once_with( + yahoo_client.YAHOO_CHART_URL.format(symbol="AAPL"), + params={ + "range": "1mo", + "interval": "1d", + "includePrePost": "false", + "events": "div,splits", + }, + headers=yahoo_client.REQUEST_HEADERS, + timeout=8, + ) + + +def test_fetch_yahoo_chart_handles_request_and_json_errors(): + with patch("src.yahoo_client.requests.get", side_effect=ValueError("bad json")): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.fetch_yahoo_chart("AAPL", "1mo", "1d") + + assert exc_info.value.status_code == 502 + assert exc_info.value.detail == "Unable to reach Yahoo market data." + + with patch( + "src.yahoo_client.requests.get", + side_effect=yahoo_client.requests.RequestException("network"), + ): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.fetch_yahoo_chart("AAPL", "1mo", "1d") + + assert exc_info.value.status_code == 502 + assert exc_info.value.detail == "Unable to reach Yahoo market data." + + +def test_fetch_yahoo_chart_handles_raise_for_status_errors(): + response = MagicMock() + response.raise_for_status.side_effect = yahoo_client.requests.RequestException("bad status") + + with patch("src.yahoo_client.requests.get", return_value=response): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.fetch_yahoo_chart("AAPL", "1mo", "1d") + + assert exc_info.value.status_code == 502 + assert exc_info.value.detail == "Unable to reach Yahoo market data." + + +def test_fetch_yahoo_chart_prefers_first_result_when_multiple_exist(): + payload = { + "chart": { + "result": [{"meta": {"symbol": "FIRST"}}, {"meta": {"symbol": "SECOND"}}], + "error": None, + } + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + result = yahoo_client.fetch_yahoo_chart("AAPL", "1mo", "1d") + + assert result == {"meta": {"symbol": "FIRST"}} + + +def test_fetch_yahoo_chart_quotes_symbols_with_reserved_characters(): + payload = {"chart": {"result": [{"meta": {"symbol": "EUR/USD"}}], "error": None}} + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)) as mock_get: + yahoo_client.fetch_yahoo_chart("EUR/USD", "1mo", "1d") + + assert mock_get.call_args.args[0].endswith("/EUR%2FUSD") + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)) as mock_get: + yahoo_client.fetch_yahoo_chart("EURUSD=X", "1mo", "1d") + + assert mock_get.call_args.args[0].endswith("/EURUSD=X") + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)) as mock_get: + yahoo_client.fetch_yahoo_chart("^GSPC.-", "1mo", "1d") + + assert mock_get.call_args.args[0].endswith("/^GSPC.-") + + +def test_fetch_yahoo_chart_handles_yahoo_error_payload(): + payload = {"chart": {"result": None, "error": {"description": "missing"}}} + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.fetch_yahoo_chart("AAPL", "1mo", "1d") + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "missing" + + +def test_fetch_yahoo_chart_uses_default_message_when_description_missing(): + payload = {"chart": {"result": None, "error": {"description": None}}} + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.fetch_yahoo_chart("AAPL", "1mo", "1d") + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Yahoo market data not found for symbol." + + +def test_fetch_yahoo_chart_handles_missing_results(): + payload = {"chart": {"result": [], "error": None}} + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.fetch_yahoo_chart("AAPL", "1mo", "1d") + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Yahoo market data not found for symbol." + + +def test_fetch_yahoo_chart_handles_missing_chart_payload(): + with patch("src.yahoo_client.requests.get", return_value=build_response({})): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.fetch_yahoo_chart("AAPL", "1mo", "1d") + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Yahoo market data not found for symbol." + + +def test_fetch_yahoo_chart_handles_chart_none_payload(): + with patch("src.yahoo_client.requests.get", return_value=build_response({"chart": None})): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.fetch_yahoo_chart("AAPL", "1mo", "1d") + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Yahoo market data not found for symbol." + + +def test_extract_chart_points_success_and_failures(): + result = yahoo_client.extract_chart_points( + { + "timestamp": [1700000000, 1700003600], + "indicators": {"quote": [{"close": [123.45, 124.0]}]}, + } + ) + assert result == [ + {"timestamp": 1700000000, "price": 123.45}, + {"timestamp": 1700003600, "price": 124.0}, + ] + + with pytest.raises(HTTPException): + yahoo_client.extract_chart_points({"indicators": {}}) + + with pytest.raises(HTTPException): + yahoo_client.extract_chart_points( + {"timestamp": [1], "indicators": {"quote": [{"close": [None]}]}} + ) + + with pytest.raises(HTTPException): + yahoo_client.extract_chart_points( + {"timestamp": [1], "indicators": {"quote": [{"open": [10.0]}]}} + ) + + +def test_extract_chart_points_uses_only_the_first_quote_entry(): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.extract_chart_points( + { + "timestamp": [1], + "indicators": { + "quote": [ + {"close": [None]}, + {"close": [123.45]}, + ] + }, + } + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Historical data not found for symbol." + + +def test_extract_chart_points_handles_first_quote_none(): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.extract_chart_points( + { + "timestamp": [1], + "indicators": {"quote": [None]}, + } + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Historical data not found for symbol." + + +def test_extract_chart_points_preserves_zero_price_points(): + result = yahoo_client.extract_chart_points( + { + "timestamp": [1, 2], + "indicators": {"quote": [{"close": [0.0, 1.5]}]}, + } + ) + + assert result == [ + {"timestamp": 1, "price": 0.0}, + {"timestamp": 2, "price": 1.5}, + ] + + +def test_extract_chart_points_skips_non_numeric_values(): + result = yahoo_client.extract_chart_points( + { + "timestamp": [1, 2, 3], + "indicators": {"quote": [{"close": [10.0, "bad", 12.0]}]}, + } + ) + + assert result == [ + {"timestamp": 1, "price": 10.0}, + {"timestamp": 3, "price": 12.0}, + ] + + +def test_extract_chart_points_skips_invalid_prices_but_keeps_valid_points(): + result = yahoo_client.extract_chart_points( + { + "timestamp": [1700000000, 1700003600, 1700007200], + "indicators": {"quote": [{"close": [123.45, None, 124.0]}]}, + } + ) + + assert result == [ + {"timestamp": 1700000000, "price": 123.45}, + {"timestamp": 1700007200, "price": 124.0}, + ] + + +def test_extract_chart_points_casts_timestamps_to_ints(): + result = yahoo_client.extract_chart_points( + { + "timestamp": ["1700000000"], + "indicators": {"quote": [{"close": [123.45]}]}, + } + ) + + assert result == [{"timestamp": 1700000000, "price": 123.45}] + + +def test_extract_chart_points_handles_missing_timestamps(): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.extract_chart_points( + {"indicators": {"quote": [{"close": [123.45]}]}} + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Historical data not found for symbol." + + +def test_extract_chart_points_handles_missing_indicators(): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.extract_chart_points({"timestamp": [1]}) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Historical data not found for symbol." + + +def test_extract_chart_points_truncates_to_shortest_series(): + result = yahoo_client.extract_chart_points( + { + "timestamp": [1, 2, 3], + "indicators": {"quote": [{"close": [10.0, 11.0]}]}, + } + ) + + assert result == [ + {"timestamp": 1, "price": 10.0}, + {"timestamp": 2, "price": 11.0}, + ] + + +def test_extract_chart_points_raises_when_quote_list_is_empty(): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.extract_chart_points( + {"timestamp": [1], "indicators": {"quote": []}} + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Historical data not found for symbol." + + +def test_extract_chart_points_raises_when_all_numeric_points_are_filtered_out(): + with pytest.raises(HTTPException) as exc_info: + yahoo_client.extract_chart_points( + { + "timestamp": [1, 2], + "indicators": {"quote": [{"close": ["bad", None]}]}, + } + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Historical data not found for symbol." + + +def test_search_catalog_helpers_cover_empty_and_filtered_paths(): + assert len(yahoo_client.search_forex_catalog("", 2)) == 2 + assert yahoo_client.search_forex_catalog("euro", 5)[0]["symbol"] == "EURUSD=X" + assert len(yahoo_client.search_stock_catalog("", 2)) == 2 + assert yahoo_client.search_stock_catalog("tesla", 5)[0]["symbol"] == "TSLA" + + +def test_search_catalog_helpers_honor_limit_for_matching_results(): + assert yahoo_client.search_forex_catalog("usd", 1) == [yahoo_client.FOREX_CATALOG[0]] + assert yahoo_client.search_stock_catalog("inc", 2) == [ + yahoo_client.FEATURED_STOCKS[0], + yahoo_client.FEATURED_STOCKS[3], + ] + + +def test_search_catalog_helpers_return_empty_for_unknown_queries(): + assert yahoo_client.search_forex_catalog("zzzz", 5) == [] + assert yahoo_client.search_stock_catalog("zzzz", 5) == [] + + +def test_yahoo_search_filters_deduplicates_and_falls_back_on_error(): + payload = { + "quotes": [ + { + "quoteType": "EQUITY", + "symbol": "AAPL", + "longname": "Apple Inc.", + "currency": "USD", + "exchange": "NMS", + }, + { + "quoteType": "EQUITY", + "symbol": "AAPL", + "longname": "Apple Duplicate", + "currency": "USD", + "exchange": "NMS", + }, + { + "quoteType": "CURRENCY", + "symbol": "EURUSD=X", + "shortname": "Euro FX", + "currency": "USD", + "exchange": "CCY", + }, + { + "quoteType": "MUTUALFUND", + "symbol": "SKIP", + }, + { + "quoteType": "EQUITY", + "symbol": None, + }, + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("apple", 10) + + assert results == [ + { + "symbol": "AAPL", + "displaySymbol": "AAPL", + "name": "Apple Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "EURUSD=X", + "displaySymbol": "EURUSD", + "name": "Euro FX", + "type": "forex", + "currency": "USD", + "exchange": "CCY", + }, + ] + + with patch("src.yahoo_client.requests.get", side_effect=RuntimeError("boom")): + with pytest.raises(RuntimeError): + yahoo_client.yahoo_search("apple", 10) + + with patch( + "src.yahoo_client.requests.get", + side_effect=yahoo_client.requests.RequestException("network"), + ): + assert yahoo_client.yahoo_search("apple", 10) == [] + + with patch("src.yahoo_client.requests.get", side_effect=ValueError("bad json")): + assert yahoo_client.yahoo_search("apple", 10) == [] + + +def test_yahoo_search_skips_quotes_without_symbols_or_supported_types(): + payload = { + "quotes": [ + {"quoteType": "EQUITY", "symbol": None}, + {"quoteType": "MUTUALFUND", "symbol": "SKIP"}, + {"quoteType": None, "symbol": "ALSO_SKIP"}, + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("apple", 10) + + assert results == [] + + +def test_yahoo_search_continues_after_missing_symbol_and_unsupported_type(): + payload = { + "quotes": [ + {"quoteType": "EQUITY", "symbol": None}, + {"quoteType": "MUTUALFUND", "symbol": "SKIP"}, + { + "quoteType": "EQUITY", + "symbol": "AAPL", + "longname": "Apple Inc.", + "currency": "USD", + "exchange": "NMS", + }, + { + "quoteType": "CURRENCY", + "symbol": "EURUSD=X", + "shortname": "Euro FX", + "currency": "USD", + "exchange": "CCY", + }, + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("mixed", 10) + + assert results == [ + { + "symbol": "AAPL", + "displaySymbol": "AAPL", + "name": "Apple Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "EURUSD=X", + "displaySymbol": "EURUSD", + "name": "Euro FX", + "type": "forex", + "currency": "USD", + "exchange": "CCY", + }, + ] + + +def test_yahoo_search_returns_empty_when_quotes_key_is_missing(): + with patch("src.yahoo_client.requests.get", return_value=build_response({})): + results = yahoo_client.yahoo_search("apple", 10) + + assert results == [] + + +def test_yahoo_search_returns_empty_when_quotes_is_none(): + with patch( + "src.yahoo_client.requests.get", + return_value=build_response({"quotes": None}), + ): + results = yahoo_client.yahoo_search("apple", 10) + + assert results == [] + + +def test_yahoo_search_uses_expected_request_shape(): + payload = {"quotes": []} + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)) as mock_get: + results = yahoo_client.yahoo_search("apple", 3) + + assert results == [] + mock_get.assert_called_once_with( + yahoo_client.YAHOO_SEARCH_URL, + params={"q": "apple", "quotesCount": 6, "newsCount": 0}, + headers=yahoo_client.REQUEST_HEADERS, + timeout=6, + ) + + +def test_yahoo_search_uses_shortname_when_longname_missing(): + payload = { + "quotes": [ + { + "quoteType": "EQUITY", + "symbol": "AAPL", + "shortname": "Apple Short", + "currency": "USD", + "exchange": "NMS", + } + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("apple", 10) + + assert results == [ + { + "symbol": "AAPL", + "displaySymbol": "AAPL", + "name": "Apple Short", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + } + ] + + +def test_yahoo_search_skips_empty_string_symbol(): + payload = { + "quotes": [ + { + "quoteType": "EQUITY", + "symbol": "", + "longname": "Empty", + "currency": "USD", + "exchange": "NMS", + } + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("empty", 10) + + assert results == [] + + +def test_yahoo_search_uses_symbol_when_names_are_missing_for_forex(): + payload = { + "quotes": [ + { + "quoteType": "CURRENCY", + "symbol": "USDJPY=X", + "currency": "JPY", + "exchange": "CCY", + } + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("yen", 10) + + assert results == [ + { + "symbol": "USDJPY=X", + "displaySymbol": "USDJPY", + "name": "USDJPY=X", + "type": "forex", + "currency": "JPY", + "exchange": "CCY", + } + ] + + +def test_yahoo_search_formats_forex_display_symbol_and_preserves_exchange_none(): + payload = { + "quotes": [ + { + "quoteType": "CURRENCY", + "symbol": "EURUSD=X", + "shortname": "Euro FX", + "currency": "", + "exchange": None, + } + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("eur", 10) + + assert results == [ + { + "symbol": "EURUSD=X", + "displaySymbol": "EURUSD", + "name": "Euro FX", + "type": "forex", + "currency": "USD", + "exchange": None, + } + ] + + +def test_yahoo_search_prefers_longname_over_shortname(): + payload = { + "quotes": [ + { + "quoteType": "EQUITY", + "symbol": "AAPL", + "longname": "Apple Long", + "shortname": "Apple Short", + "currency": "USD", + "exchange": "NMS", + } + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("apple", 10) + + assert results[0]["name"] == "Apple Long" + + +def test_yahoo_search_keeps_equity_display_symbol_even_when_symbol_ends_with_x(): + payload = { + "quotes": [ + { + "quoteType": "EQUITY", + "symbol": "ABC=X", + "longname": "Equity With X", + "currency": "USD", + "exchange": "NMS", + } + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("abc", 10) + + assert results == [ + { + "symbol": "ABC=X", + "displaySymbol": "ABC=X", + "name": "Equity With X", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + } + ] + + +def test_yahoo_search_deduplicates_before_applying_limit(): + payload = { + "quotes": [ + { + "quoteType": "EQUITY", + "symbol": "AAPL", + "longname": "Apple Inc.", + "currency": "USD", + "exchange": "NMS", + }, + { + "quoteType": "EQUITY", + "symbol": "AAPL", + "longname": "Apple Duplicate", + "currency": "USD", + "exchange": "NMS", + }, + { + "quoteType": "EQUITY", + "symbol": "MSFT", + "longname": "Microsoft Corporation", + "currency": "USD", + "exchange": "NMS", + }, + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("tech", 2) + + assert results == [ + { + "symbol": "AAPL", + "displaySymbol": "AAPL", + "name": "Apple Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + { + "symbol": "MSFT", + "displaySymbol": "MSFT", + "name": "Microsoft Corporation", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + }, + ] + + +def test_yahoo_search_preserves_result_order_across_supported_types(): + payload = { + "quotes": [ + { + "quoteType": "CURRENCY", + "symbol": "EURUSD=X", + "shortname": "Euro FX", + "currency": "USD", + "exchange": "CCY", + }, + { + "quoteType": "EQUITY", + "symbol": "AAPL", + "longname": "Apple Inc.", + "currency": "USD", + "exchange": "NMS", + }, + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("mixed", 10) + + assert [item["symbol"] for item in results] == ["EURUSD=X", "AAPL"] + + +def test_yahoo_search_stops_when_limit_is_reached(): + payload = { + "quotes": [ + { + "quoteType": "EQUITY", + "symbol": "AAPL", + "longname": "Apple Inc.", + "currency": "USD", + "exchange": "NMS", + }, + { + "quoteType": "CURRENCY", + "symbol": "EURUSD=X", + "shortname": "Euro FX", + "currency": "USD", + "exchange": "CCY", + }, + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("a", 1) + + assert results == [ + { + "symbol": "AAPL", + "displaySymbol": "AAPL", + "name": "Apple Inc.", + "type": "stock", + "currency": "USD", + "exchange": "NMS", + } + ] + + +def test_yahoo_search_falls_back_to_symbol_name_and_usd_currency(): + payload = { + "quotes": [ + { + "quoteType": "EQUITY", + "symbol": "BRK-B", + "exchange": "NYQ", + } + ] + } + + with patch("src.yahoo_client.requests.get", return_value=build_response(payload)): + results = yahoo_client.yahoo_search("berkshire", 10) + + assert results == [ + { + "symbol": "BRK-B", + "displaySymbol": "BRK-B", + "name": "BRK-B", + "type": "stock", + "currency": "USD", + "exchange": "NYQ", + } + ] diff --git a/app/server/services/ts/api-gateway/src/index.ts b/app/server/services/ts/api-gateway/src/index.ts index 072a253..0313249 100644 --- a/app/server/services/ts/api-gateway/src/index.ts +++ b/app/server/services/ts/api-gateway/src/index.ts @@ -2,11 +2,41 @@ import { PORT } from "@/port"; import { onExit } from "@/hooks"; import express from "express"; import { createProxyMiddleware } from "http-proxy-middleware"; -import { buildCorsConfig } from "@/expressUtils"; +import { buildCorsConfig } from "@/expressUtils.ts"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; const app = express(); app.use(buildCorsConfig()); +const CLIENT_STATE_DIR = + process.env.CLIENT_STATE_DIR ?? "/var/lib/finus-client-state"; +const CLIENT_STATE_RESET_KEY_PATH = path.join( + CLIENT_STATE_DIR, + "reset-key.txt", +); + +function resolveClientStateResetKey() { + try { + const existingKey = fs + .readFileSync(CLIENT_STATE_RESET_KEY_PATH, "utf8") + .trim(); + if (existingKey.length > 0) { + return existingKey; + } + } catch { + // Fall through and create a new reset key. + } + + const nextKey = randomUUID(); + fs.mkdirSync(CLIENT_STATE_DIR, { recursive: true }); + fs.writeFileSync(CLIENT_STATE_RESET_KEY_PATH, nextKey, "utf8"); + return nextKey; +} + +const clientStateResetKey = resolveClientStateResetKey(); + const pathMatches = (path, valid) => { path = path.replace("/api/", ""); return valid.includes(path); @@ -22,7 +52,14 @@ app.use( }), ); -const USER_PATHS = ["accounts", "profiles", "charts/expenses"]; +const USER_PATHS = [ + "accounts", + "profiles", + "/charts/expenses", + "/table/transactions", + "/table/snapshot", + "/goals", +]; app.use( createProxyMiddleware({ pathFilter: (path) => pathMatches(path, USER_PATHS), @@ -32,6 +69,16 @@ app.use( }), ); +const MARKET_PATHS = ["markets/search", "markets/quote", "markets/history"]; +app.use( + createProxyMiddleware({ + pathFilter: (path) => pathMatches(path, MARKET_PATHS), + target: process.env.MARKET_SERVICE_ADDR, + changeOrigin: true, + pathRewrite: { "^/api": "" }, + }), +); + app.use( createProxyMiddleware({ pathFilter: "/api/transactions", @@ -41,16 +88,6 @@ app.use( }), ); -// const ANALYTICS_PATHS = ["charts/savings", "charts/incomeflow"]; -// app.use( -// createProxyMiddleware({ -// pathFilter: (path) => pathMatches(path, ANALYTICS_PATHS), -// target: process.env.ANALYTICS_SERVICE_ADDR, -// changeOrigin: true, -// pathRewrite: { "^/api": "" }, -// }), -// ); - app.get("/health", async (req: express.Request, res: express.Response) => { const result: { [key: string]: string } = {}; const services: string[] = Object.keys(process.env).filter((x) => @@ -68,78 +105,113 @@ app.get("/health", async (req: express.Request, res: express.Response) => { res.json(result); }); +app.get( + "/client-state/reset-key", + (_req: express.Request, res: express.Response) => { + res.json({ resetKey: clientStateResetKey }); + }, +); + const server = app.listen(PORT, () => { console.log(`API Gateway running on port ${PORT}`); }); onExit(async () => await server.close()); -//API gateway sits on port 3000 and is accessible from there. Go to browser and type http://localhost:3000/health and you should see which services are up. -// app.get('/health', async (req: express.Request, res: express.Response) => { -// const result: { [serviceName: string]: string } = {}; -// const services: string[] = Object.keys(process.env).filter((x) => /^.*_SERVICE_ADDR$/.test(x)); +//expenses bar chart in user service +// app.use( +// createProxyMiddleware({ +// pathFilter: ["/charts/expenses"], +// target: process.env.USER_SERVICE_ADDR, +// changeOrigin: true, +// }), +// ); -// for(const service of services) { -// const serviceName = service.split('_')[0]; -// if (serviceName) -// result[serviceName] = await fetch(`${process.env[service]}/health`) -// .then((res) => res.text()) -// .catch((err) => err.message); -// console.log("received response"); -// } +//transactions table in user service +// app.use( +// createProxyMiddleware({ +// pathFilter: ["/table/transactions"], +// target: process.env.USER_SERVICE_ADDR, +// changeOrigin: true, +// }), +// ); -// res.json(result); -// }); +//snapshot of total values like debt, savings, etc from user service +// app.use( +// createProxyMiddleware({ +// pathFilter: ["/table/snapshot"], +// target: process.env.USER_SERVICE_ADDR, +// changeOrigin: true, +// }), +// ); -//expenses bar chart in user service +//savings chart from analytics service app.use( createProxyMiddleware({ - pathFilter: ["/charts/expenses"], - target: process.env.USER_SERVICE_ADDR, + pathFilter: ["/charts/savings"], + target: process.env.ANALYTICS_SERVICE_ADDR, changeOrigin: true, }), ); -//transactions table in user service +//income flow chart from analytics service - this is the sankey chart app.use( createProxyMiddleware({ - pathFilter: ["/table/transactions"], - target: process.env.USER_SERVICE_ADDR, + pathFilter: ["/charts/incomeflow"], + target: process.env.ANALYTICS_SERVICE_ADDR, changeOrigin: true, }), ); -//snapshot of total values like debt, savings, etc from user service +//budget-expenditure chart from analytics service app.use( createProxyMiddleware({ - pathFilter: ["/table/snapshot"], - target: process.env.USER_SERVICE_ADDR, + pathFilter: ["/charts/budget-expenditure"], + target: process.env.ANALYTICS_SERVICE_ADDR, changeOrigin: true, }), ); -//savings chart from analytics service +//compound interest from analytics service app.use( createProxyMiddleware({ - pathFilter: ["/charts/savings"], + pathFilter: ["/compound-interest"], target: process.env.ANALYTICS_SERVICE_ADDR, changeOrigin: true, }), ); -//income flow chart from analytics service - this is the sankey chart +//debt payoff prediction from analytics service app.use( createProxyMiddleware({ - pathFilter: ["/charts/incomeflow"], + pathFilter: ["/predict-debt-payoff"], target: process.env.ANALYTICS_SERVICE_ADDR, changeOrigin: true, }), ); -//budget-expenditure chart from analytics service +//debt-related request app.use( createProxyMiddleware({ - pathFilter: ["/charts/budget-expenditure"], - target: process.env.ANALYTICS_SERVICE_ADDR, + pathFilter: "/api/debts", + target: process.env.USER_SERVICE_ADDR, + changeOrigin: true, + pathRewrite: { "^/api/debts": "/debts" }, + }), +); +//savings-related request +app.use( + createProxyMiddleware({ + pathFilter: "/api/savings", + target: process.env.USER_SERVICE_ADDR, + changeOrigin: true, + pathRewrite: { "^/api/savings": "/savings" }, + }), +); +// market search, quote, and history endpoints from market service +app.use( + createProxyMiddleware({ + pathFilter: ["/markets/search", "/markets/quote", "/markets/history"], + target: process.env.MARKET_SERVICE_ADDR, changeOrigin: true, }), ); diff --git a/app/server/services/ts/bun.lock b/app/server/services/ts/bun.lock index cdfef39..ad93995 100644 --- a/app/server/services/ts/bun.lock +++ b/app/server/services/ts/bun.lock @@ -5,16 +5,17 @@ "": { "dependencies": { "@types/cors": "^2.8.19", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.1", "cors": "^2.8.6", "express": "^5.2.1", "mysql2": "^3.18.1", + "stryker-mutator-bun-runner": "^0.4.0", }, "devDependencies": { "@eslint/css": "^0.14.1", "@eslint/js": "^10.0.1", "@eslint/json": "^1.0.0", - "eslint": "^9", + "eslint": "^10.1.0", "globals": "^17.3.0", "husky": "^9.1.7", "jiti": "^2.6.1", @@ -29,12 +30,68 @@ }, }, "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.0", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-decorators": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA=="], + + "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw=="], + + "@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-transform-destructuring": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], @@ -95,9 +152,9 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + "@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.3", "", { "dependencies": { "@eslint/core": "^1.1.1" } }, "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw=="], "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], @@ -105,13 +162,11 @@ "@eslint/css-tree": ["@eslint/css-tree@3.6.9", "", { "dependencies": { "mdn-data": "2.23.0", "source-map-js": "^1.0.1" } }, "sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.4", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" } }, "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ=="], - "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], "@eslint/json": ["@eslint/json@1.0.1", "", { "dependencies": { "@eslint/core": "^1.1.0", "@eslint/plugin-kit": "^0.6.0", "@humanwhocodes/momoa": "^3.3.10", "natural-compare": "^1.4.0" } }, "sha512-bE2nGv8/U+uRvQEJWOgCsZCa65XsCBgxyyx/sXtTHVv0kqdauACLzyp7A1C3yNn7pRaWjIt5acxY+TAbSyIJXw=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], @@ -125,12 +180,74 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.4", "", {}, "sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@5.1.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PubpMPO2nJgMufkoB3P2wwxNXEMUXnBIKi/ACzDUYfaoPuM7gSTmuxJeMscoLVEsR4qqrCMf5p0SiYGWnVJ8kw=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-tiNyA73pgpQ0FQ7axqtoLUe4GDYjNCDcVsbgcA5anvwg2z6i+suEngLKKJrWKJolT//GFPZHwN30binDIHgSgQ=="], + + "@inquirer/core": ["@inquirer/core@11.1.7", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-1BiBNDk9btIwYIzNZpkikIHXWeNzNncJePPqwDyVMhXhD1ebqbpn1mKGctpoqAbzywZfdG0O4tvmsGIcOevAPQ=="], + + "@inquirer/editor": ["@inquirer/editor@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/external-editor": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VJx4XyaKea7t8hEApTw5dxeIyMtWXre2OiyJcICCRZI4hkoHsMoCnl/KbUnJJExLbH9csLLHMVR144ZhFE1CwA=="], + + "@inquirer/expand": ["@inquirer/expand@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fC0UHJPXsTRvY2fObiwuQYaAnHrp3aDqfwKUJSdfpgv18QUG054ezGbaRNStk/BKD5IPijeMKWej8VV8O5Q/eQ=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@2.0.4", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Prenuv9C1PHj2Itx0BcAOVBTonz02Hc2Nd2DbU67PdGUaqn0nPCnV34oDyyoaZHnmfRxkpuhh/u51ThkrO+RdA=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.4", "", {}, "sha512-eLBsjlS7rPS3WEhmOmh1znQ5IsQrxWzxWDxO51e4urv+iVrSnIHbq4zqJIOiyNdYLa+BVjwOtdetcQx1lWPpiQ=="], + + "@inquirer/input": ["@inquirer/input@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nvZ6qEVeX/zVtZ1dY2hTGDQpVGD3R7MYPLODPgKO8Y+RAqxkrP3i/3NwF3fZpLdaMiNuK0z2NaYIx9tPwiSegQ=="], + + "@inquirer/number": ["@inquirer/number@4.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Ht8OQstxiS3APMGjHV0aYAjRAysidWdwurWEo2i8yI5xbhOBWqizT0+MU1S2GCcuhIBg+3SgWVjEoXgfhY+XaA=="], + + "@inquirer/password": ["@inquirer/password@5.0.10", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QbNyvIE8q2GTqKLYSsA8ATG+eETo+m31DSR0+AU7x3d2FhaTWzqQek80dj3JGTo743kQc6mhBR0erMjYw5jQ0A=="], + + "@inquirer/prompts": ["@inquirer/prompts@8.3.2", "", { "dependencies": { "@inquirer/checkbox": "^5.1.2", "@inquirer/confirm": "^6.0.10", "@inquirer/editor": "^5.0.10", "@inquirer/expand": "^5.0.10", "@inquirer/input": "^5.0.10", "@inquirer/number": "^4.0.10", "@inquirer/password": "^5.0.10", "@inquirer/rawlist": "^5.2.6", "@inquirer/search": "^4.1.6", "@inquirer/select": "^5.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yFroiSj2iiBFlm59amdTvAcQFvWS6ph5oKESls/uqPBect7rTU2GbjyZO2DqxMGuIwVA8z0P4K6ViPcd/cp+0w=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@5.2.6", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-jfw0MLJ5TilNsa9zlJ6nmRM0ZFVZhhTICt4/6CU2Dv1ndY7l3sqqo1gIYZyMMDw0LvE1u1nzJNisfHEhJIxq5w=="], + + "@inquirer/search": ["@inquirer/search@4.1.6", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3/6kTRae98hhDevENScy7cdFEuURnSpM3JbBNg8yfXLw88HgTOl+neUuy/l9W0No5NzGsLVydhBzTIxZP7yChQ=="], + + "@inquirer/select": ["@inquirer/select@5.1.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kTK8YIkHV+f02y7bWCh7E0u2/11lul5WepVTclr3UMBtBr05PgcZNWfMa7FY57ihpQFQH/spLMHTcr0rXy50tA=="], + + "@inquirer/type": ["@inquirer/type@4.0.4", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA=="], + + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/8IzqSu4/OWGRs7Fs2ROzGVwJMFTBQkgAp6sAthkBYoN7OiM4rY/CpPVs2X9w9N1W61CHSkEdNKi8HrLZKfK3g=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-TT7eUihnAzxM2tlZesusuC75PAOYKvUBgVU/Nm/lakZ/DpyuqhNkzUfcxSgmmK9IjVWzMmezLIGZl16XGCGJng=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-CYjIHWaQG7T4phfjErHr6BiXRs0K/9DqMeiohJmuYSBF+H2m56vFslOenLCguGYQL9jeiiCZBeoVCpwjxZrMgQ=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-8XMLyRNxHF4jfLajkWt+F8UDxsWbzysyxQVMZKUXwoeGvaxB0rVd07r3YbgDtG8U6khhRFM3oaGp+CQ0whwmdA=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-jBwYCLG5Eb+PqtFrc3Wp2WMYlw1Id75gUcsdP+ApCOpf5oQhHxkFWCjZmcDoioDmEhMWAiM3wtwSrTlPg+sI6Q=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-z3GFCk1UBzDOOiEBHL32lVP7Edi26BhOjKb6bIc0nRyabbRiyON4++GR0zmd/H5zM5S0+UcXFgCGnD+b8avTLw=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-KZlf1jKtf4jai8xiQv/0XRjxVVhHnw/HtUKtLdOeQpTOQ1fQFhLoz2FGGtVRd0LVa/yiRbSz9HlWIzWlmJClng=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-ADImD4yCHNpqZu718E2chWcCaAHvua90yhmpzzV6fF4zOhwkGGbPCgUWmKyJ83uz+DXaPdYxX0ttDvtolrzx3Q=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-J+qz4Al05PrNIOdj7xsWVTyx0c/gjUauG5nKV3Rrx0Q+5JO+1pPVlnfNmWbOF9pKG4f3IGad8KXJUfGMORld+Q=="], + + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-UOdkwScHRkGPz+n9ZJU7sTkTvqV7rD1SLCLaru1xH8WRsV7tDorPqNCzEN1msOIiPRK825nvAtEm9UsomO1GsA=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-E51tyWDP1l0CbjZYhiUxhDGPaY8Hf5YBREx0PHBff1LM1/q3qsJ6ZvRUa8YbbOO0Ax9QP6GHjD9vf3n6bXZ7QA=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-cCsXK9AQ9Zf18QlVnbrFu2IKfr4sf2sfbErkF2jfCzyCO9Bnhl0KRx63zlN+Ni1xU7gcBLAssgcui5R400N2eA=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], @@ -181,14 +298,28 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@stryker-mutator/api": ["@stryker-mutator/api@9.6.0", "", { "dependencies": { "mutation-testing-metrics": "3.7.2", "mutation-testing-report-schema": "3.7.2", "tslib": "~2.8.0", "typed-inject": "~5.0.0" } }, "sha512-kJEEwOVoWDXGEIXuM+9efT6LSJ7nyxnQQvjEoKg8GSZXbDUjfD0tqA0aBD06U1SzQLKCM7ffjgPffr154MHZKw=="], + + "@stryker-mutator/core": ["@stryker-mutator/core@9.6.0", "", { "dependencies": { "@inquirer/prompts": "^8.0.0", "@stryker-mutator/api": "9.6.0", "@stryker-mutator/instrumenter": "9.6.0", "@stryker-mutator/util": "9.6.0", "ajv": "~8.18.0", "chalk": "~5.6.0", "commander": "~14.0.0", "diff-match-patch": "1.0.5", "emoji-regex": "~10.6.0", "execa": "~9.6.0", "json-rpc-2.0": "^1.7.0", "lodash.groupby": "~4.6.0", "minimatch": "~10.2.4", "mutation-server-protocol": "~0.4.0", "mutation-testing-elements": "3.7.2", "mutation-testing-metrics": "3.7.2", "mutation-testing-report-schema": "3.7.2", "npm-run-path": "~6.0.0", "progress": "~2.0.3", "rxjs": "~7.8.1", "semver": "^7.6.3", "source-map": "~0.7.4", "tree-kill": "~1.2.2", "tslib": "2.8.1", "typed-inject": "~5.0.0", "typed-rest-client": "~2.2.0" }, "bin": { "stryker": "bin/stryker.js" } }, "sha512-oSbw01l6HXHt0iW9x5fQj7yHGGT8ZjCkXSkI7Bsu0juO7Q6vRMXk7XcvKpCBgRgzKXi1osg8+iIzj7acHuxepQ=="], + + "@stryker-mutator/instrumenter": ["@stryker-mutator/instrumenter@9.6.0", "", { "dependencies": { "@babel/core": "~7.29.0", "@babel/generator": "~7.29.0", "@babel/parser": "~7.29.0", "@babel/plugin-proposal-decorators": "~7.29.0", "@babel/plugin-transform-explicit-resource-management": "^7.28.0", "@babel/preset-typescript": "~7.28.0", "@stryker-mutator/api": "9.6.0", "@stryker-mutator/util": "9.6.0", "angular-html-parser": "~10.4.0", "semver": "~7.7.0", "tslib": "2.8.1", "weapon-regex": "~1.3.2" } }, "sha512-tWdRYfm9LF4Go7cNOos0xEIOEnN7ZOSj38rfXvGZS9IINlvYBrBCl2xcz/67v6l5A7xksMWWByZRIq2bgdnnUg=="], + + "@stryker-mutator/util": ["@stryker-mutator/util@9.6.0", "", {}, "sha512-gw7fJOFNHEj9inAEOodD9RrrMEMhZmWJ46Ww/kDJAXlSsBBmdwCzeomNLngmLTvgp14z7Tfq85DHYwvmNMdOxA=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -215,7 +346,7 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.1", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.1", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.1.1", "vitest": "4.1.1" }, "optionalPeers": ["@vitest/browser"] }, "sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw=="], "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], @@ -239,44 +370,50 @@ "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "angular-html-parser": ["angular-html-parser@10.4.0", "", {}, "sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "bun": ["bun@1.3.11", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.11", "@oven/bun-darwin-x64": "1.3.11", "@oven/bun-darwin-x64-baseline": "1.3.11", "@oven/bun-linux-aarch64": "1.3.11", "@oven/bun-linux-aarch64-musl": "1.3.11", "@oven/bun-linux-x64": "1.3.11", "@oven/bun-linux-x64-baseline": "1.3.11", "@oven/bun-linux-x64-musl": "1.3.11", "@oven/bun-linux-x64-musl-baseline": "1.3.11", "@oven/bun-windows-aarch64": "1.3.11", "@oven/bun-windows-x64": "1.3.11", "@oven/bun-windows-x64-baseline": "1.3.11" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-AvXWYFO6j/ZQ7bhGm4X6eilq2JHsDVC90ZM32k2B7/srhC2gs3Sdki1QTbwrdRCo8o7eT+167vcB1yzOvPdbjA=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -293,10 +430,18 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="], + + "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "electron-to-chromium": ["electron-to-chromium@1.5.325", "", {}, "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -309,17 +454,19 @@ "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="], + "eslint": ["eslint@10.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.3", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA=="], - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], @@ -333,6 +480,8 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -343,8 +492,18 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -355,6 +514,8 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -365,10 +526,16 @@ "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@17.3.0", "", {}, "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw=="], @@ -385,14 +552,14 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -403,10 +570,16 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -415,28 +588,38 @@ "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-rpc-2.0": ["json-rpc-2.0@1.7.1", "", {}, "sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -457,10 +640,24 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@5.1.8", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mutation-server-protocol": ["mutation-server-protocol@0.4.1", "", { "dependencies": { "zod": "^4.1.12" } }, "sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g=="], + + "mutation-testing-elements": ["mutation-testing-elements@3.7.2", "", {}, "sha512-i7X2Q4X5eYon72W2QQ9HND7plVhQcqTnv+Xc3KeYslRZSJ4WYJoal8LFdbWm7dKWLNE0rYkCUrvboasWzF3MMA=="], + + "mutation-testing-metrics": ["mutation-testing-metrics@3.7.2", "", { "dependencies": { "mutation-testing-report-schema": "3.7.2" } }, "sha512-ichXZSC4FeJbcVHYOWzWUhNuTJGogc0WiQol8lqEBrBSp+ADl3fmcZMqrx0ogInEUiImn+A8JyTk6uh9vd25TQ=="], + + "mutation-testing-report-schema": ["mutation-testing-report-schema@3.7.2", "", {}, "sha512-fN5M61SDzIOeJyatMOhGPLDOFz5BQIjTNPjo4PcHIEUWrejO4i4B5PFuQ/2l43709hEsTxeiXX00H73WERKcDw=="], + + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "mysql2": ["mysql2@3.19.0", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-760Ay9HViAUT7V8QkYxrIx/LHJPGWtE+D/31VuiDm1eu2IFaUIqMcK7+hVHnmgnC7JnnwreFKsZoa8K6BnGl8Q=="], "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], @@ -471,6 +668,10 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -487,7 +688,9 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -495,6 +698,8 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -509,6 +714,10 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -519,12 +728,14 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -549,6 +760,10 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="], @@ -559,7 +774,9 @@ "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "stryker-mutator-bun-runner": ["stryker-mutator-bun-runner@0.4.0", "", { "dependencies": { "@stryker-mutator/api": "^9.0.1", "@stryker-mutator/util": "^9.0.1", "execa": "^9.6.0", "glob": "^11.0.3", "semver": "^7.5.4" }, "peerDependencies": { "@stryker-mutator/core": "^9.0.0", "bun": ">=1.0.0" } }, "sha512-wx2nfdfKFWRxE5fiAYzRBVJoUMtoeYIE2uAtWS1fu4rGeay6XVm3wBmqi2bKosVH6zHPShsciSCDKDGifZHvSA=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -573,20 +790,36 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typed-inject": ["typed-inject@5.0.0", "", {}, "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA=="], + + "typed-rest-client": ["typed-rest-client@2.2.0", "", { "dependencies": { "des.js": "^1.1.0", "js-md4": "^0.3.2", "qs": "^6.14.1", "tunnel": "0.0.6", "underscore": "^1.12.1" } }, "sha512-/e2Rk9g20N0r44kaQLb3v6QGuryOD8SPb53t43Y5kqXXA+SqWuU7zLiMxetw61jNn/JFrxTdr5nPDhGY/eTNhQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -595,6 +828,8 @@ "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "weapon-regex": ["weapon-regex@1.3.6", "", {}, "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -603,38 +838,78 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@eslint/config-array/minimatch": ["minimatch@3.1.4", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "@eslint/eslintrc/minimatch": ["minimatch@3.1.4", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/config-array/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "@eslint/config-helpers/@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="], "@eslint/json/@eslint/core": ["@eslint/core@1.1.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="], "@eslint/json/@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.0", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="], + "@stryker-mutator/core/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "@stryker-mutator/core/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="], + + "@vitest/coverage-v8/std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "eslint/@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="], - "eslint/@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="], + "eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="], - "eslint/minimatch": ["minimatch@3.1.4", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw=="], + "eslint/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], - "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], - "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + + "@stryker-mutator/core/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@stryker-mutator/core/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], - "eslint/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="], + + "eslint/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + + "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@stryker-mutator/core/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/app/server/services/ts/common/expressUtils.ts b/app/server/services/ts/common/expressUtils.ts index 30d7c2d..70a6c31 100644 --- a/app/server/services/ts/common/expressUtils.ts +++ b/app/server/services/ts/common/expressUtils.ts @@ -1,6 +1,6 @@ import cors from "cors"; -const defaultMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]; +const defaultMethods = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]; const defaultOrigins = ["http://localhost", "http://localhost:8080"]; export function buildCorsConfig(opts?: { origins?: string | string[]; diff --git a/app/server/services/ts/market/.gitignore b/app/server/services/ts/market/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/app/server/services/ts/market/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/app/server/services/ts/market/Dockerfile b/app/server/services/ts/market/Dockerfile new file mode 100644 index 0000000..600d6cb --- /dev/null +++ b/app/server/services/ts/market/Dockerfile @@ -0,0 +1,21 @@ +FROM oven/bun:1.3.10 + +WORKDIR /project +COPY package.json bun.lockb . +RUN --mount=type=cache,target=/root/.bun/install/cache \ +bun install + +WORKDIR /project/app +COPY market/package.json market/bun.lockb . +RUN --mount=type=cache,target=/root/.bun/install/cache \ +bun install + +WORKDIR /project +COPY tsconfig.json . +COPY common ./common + +WORKDIR /project/app +COPY market/src ./src + +CMD ["bun", "--no-env-file", "src/index.ts"] + diff --git a/app/server/services/ts/market/Dockerfile.test b/app/server/services/ts/market/Dockerfile.test new file mode 100644 index 0000000..a8718cd --- /dev/null +++ b/app/server/services/ts/market/Dockerfile.test @@ -0,0 +1,11 @@ +FROM measureonecodetwice/finus-user:local + +COPY market /project/market + +COPY vitest.config.ts /project/vitest.config.ts + +WORKDIR /project + +ENV CI=true + +CMD ["bun", "x", "vitest", "--run"] diff --git a/app/server/services/ts/market/README.md b/app/server/services/ts/market/README.md new file mode 100644 index 0000000..6431ed1 --- /dev/null +++ b/app/server/services/ts/market/README.md @@ -0,0 +1,15 @@ +# market + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.8. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/app/server/services/ts/market/bun.lock b/app/server/services/ts/market/bun.lock new file mode 100644 index 0000000..c8b4458 --- /dev/null +++ b/app/server/services/ts/market/bun.lock @@ -0,0 +1,179 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "market", + "dependencies": { + "vitest": "^4.1.1", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm" }, "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.11", "", { "os": "none", "cpu": "arm64" }, "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "x64" }, "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@vitest/expect": ["@vitest/expect@4.1.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.1", "", { "dependencies": { "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="], + + "@vitest/runner": ["@vitest/runner@4.1.1", "", { "dependencies": { "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg=="], + + "@vitest/spy": ["@vitest/spy@4.1.1", "", {}, "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA=="], + + "@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "rolldown": ["rolldown@1.0.0-rc.11", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.11" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-x64": "1.0.0-rc.11", "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="], + + "vitest": ["vitest@4.1.1", "", { "dependencies": { "@vitest/expect": "4.1.1", "@vitest/mocker": "4.1.1", "@vitest/pretty-format": "4.1.1", "@vitest/runner": "4.1.1", "@vitest/snapshot": "4.1.1", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.1", "@vitest/browser-preview": "4.1.1", "@vitest/browser-webdriverio": "4.1.1", "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + } +} diff --git a/app/server/services/ts/market/index.ts b/app/server/services/ts/market/index.ts new file mode 100644 index 0000000..2a5e4b8 --- /dev/null +++ b/app/server/services/ts/market/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); diff --git a/app/server/services/ts/market/package.json b/app/server/services/ts/market/package.json new file mode 100644 index 0000000..1ebfd03 --- /dev/null +++ b/app/server/services/ts/market/package.json @@ -0,0 +1,15 @@ +{ + "name": "market", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "vitest": "^4.1.1" + } +} diff --git a/app/server/services/ts/market/tests/market.test.ts b/app/server/services/ts/market/tests/market.test.ts new file mode 100644 index 0000000..6e3667b --- /dev/null +++ b/app/server/services/ts/market/tests/market.test.ts @@ -0,0 +1,141 @@ +import { + getIntervalForPeriod, + formatMarketPrice, + formatSignedValue, + formatSignedPercent, + formatMarketTimestamp, + isPositiveChange, + toChartSeries, + mergeInstrumentQuote, +} from "../../../../../client/src/utils/market.ts"; + +import { describe, it, expect } from "vitest"; + +describe("getIntervalForPeriod", () => { + it("returns correct intervals", () => { + expect(getIntervalForPeriod("1d")).toBe("5m"); + expect(getIntervalForPeriod("5d")).toBe("15m"); + expect(getIntervalForPeriod("1y")).toBe("1wk"); + expect(getIntervalForPeriod("5y")).toBe("1mo"); + expect(getIntervalForPeriod("1mo")).toBe("1d"); + }); +}); + +describe("formatMarketPrice", () => { + it("returns N/A for null", () => { + expect(formatMarketPrice(null)).toBe("N/A"); + }); + + it("formats USD with correct precision", () => { + expect(formatMarketPrice(123.45, "USD")).toBe("$123.45"); + expect(formatMarketPrice(0.1234, "USD")).toBe("$0.1234"); + }); + + it("formats JPY correctly", () => { + expect(formatMarketPrice(500, "JPY")).toBe("¥500"); + }); + + it("treats any 3‑letter code as a currency (Intl formatting)", () => { + const result = formatMarketPrice(1.234567, "XYZ"); + expect(result.endsWith("1.2346")).toBe(true); + expect(result.startsWith("XYZ")).toBe(true); + }); +}); + +describe("formatSignedValue", () => { + it("handles null", () => { + expect(formatSignedValue(null)).toBe("N/A"); + }); + + it("adds + for positive values", () => { + expect(formatSignedValue(1.23)).toBe("+1.23"); + }); + + it("keeps negative sign", () => { + expect(formatSignedValue(-2.5)).toBe("-2.50"); + }); +}); + +describe("formatSignedPercent", () => { + it("handles null", () => { + expect(formatSignedPercent(null)).toBe("N/A"); + }); + + it("formats positive percent", () => { + expect(formatSignedPercent(1.234)).toBe("+1.23%"); + }); + + it("formats negative percent", () => { + expect(formatSignedPercent(-0.5)).toBe("-0.50%"); + }); +}); + +describe("formatMarketTimestamp", () => { + it("returns Unavailable for null", () => { + expect(formatMarketTimestamp(null)).toBe("Unavailable"); + }); + + it("formats a valid timestamp", () => { + const ts = 1700000000; + const result = formatMarketTimestamp(ts); + expect(result).toBeTypeOf("string"); + expect(result.length).toBeGreaterThan(5); + }); +}); + +describe("isPositiveChange", () => { + it("treats null as zero", () => { + expect(isPositiveChange(null)).toBe(true); + }); + + it("detects positive", () => { + expect(isPositiveChange(5)).toBe(true); + }); + + it("detects negative", () => { + expect(isPositiveChange(-1)).toBe(false); + }); +}); + +describe("toChartSeries", () => { + it("converts points to chart series", () => { + const points = [ + { timestamp: 1700000000, price: 100 }, + { timestamp: 1700003600, price: 105 }, + ]; + + const series = toChartSeries(points); + + expect(series.length).toBe(2); + expect(series[0]).toHaveProperty("date"); + expect(series[0]).toHaveProperty("label"); + expect(series[0]).toHaveProperty("price", 100); + }); +}); + +describe("mergeInstrumentQuote", () => { + it("merges quote fields into instrument", () => { + const instrument = { + symbol: "AAPL", + name: "Apple", + price: 0, + change: 0, + changePercent: 0, + timestamp: 0, + }; + + const quote = { + price: 150, + change: 2, + changePercent: 1.5, + timestamp: 1700000000, + }; + + const merged = mergeInstrumentQuote(instrument, quote); + + expect(merged.price).toBe(150); + expect(merged.change).toBe(2); + expect(merged.changePercent).toBe(1.5); + expect(merged.timestamp).toBe(1700000000); + }); +}); diff --git a/app/server/services/ts/market/tsconfig.json b/app/server/services/ts/market/tsconfig.json new file mode 100644 index 0000000..72a9658 --- /dev/null +++ b/app/server/services/ts/market/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/app/server/services/ts/market/types/HistoricalPoint.ts b/app/server/services/ts/market/types/HistoricalPoint.ts new file mode 100644 index 0000000..b714b8f --- /dev/null +++ b/app/server/services/ts/market/types/HistoricalPoint.ts @@ -0,0 +1,5 @@ +// timestamp, price model for charts +export interface HistoricalPoint { + timestamp: number; + price: number; +} diff --git a/app/server/services/ts/market/types/MarketModel.ts b/app/server/services/ts/market/types/MarketModel.ts new file mode 100644 index 0000000..ad2b881 --- /dev/null +++ b/app/server/services/ts/market/types/MarketModel.ts @@ -0,0 +1,9 @@ +// model for current market data +export interface MarketModel { + pair: string; + name: string; + type: "stock" | "forex"; + price: number; + shift: number; + timestamp: number; +} diff --git a/app/server/services/ts/package.json b/app/server/services/ts/package.json index 5e3c1bf..e8d135a 100644 --- a/app/server/services/ts/package.json +++ b/app/server/services/ts/package.json @@ -7,7 +7,7 @@ "@eslint/css": "^0.14.1", "@eslint/js": "^10.0.1", "@eslint/json": "^1.0.0", - "eslint": "^9", + "eslint": "^10.1.0", "globals": "^17.3.0", "husky": "^9.1.7", "jiti": "^2.6.1", @@ -22,9 +22,10 @@ "type": "module", "dependencies": { "@types/cors": "^2.8.19", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.1", "cors": "^2.8.6", "express": "^5.2.1", - "mysql2": "^3.18.1" + "mysql2": "^3.18.1", + "stryker-mutator-bun-runner": "^0.4.0" } } diff --git a/app/server/services/ts/user/Dockerfile.integration.test b/app/server/services/ts/user/Dockerfile.integration.test new file mode 100644 index 0000000..51af2d2 --- /dev/null +++ b/app/server/services/ts/user/Dockerfile.integration.test @@ -0,0 +1,10 @@ +FROM measureonecodetwice/finus-user:local + +COPY user/integration ./integration + +WORKDIR /project +COPY vitest.config.ts . + +ENV CI=true + +CMD ["bun", "test", "integration", "--coverage"] diff --git a/app/server/services/ts/user/Dockerfile.mutation.test b/app/server/services/ts/user/Dockerfile.mutation.test new file mode 100644 index 0000000..ec890cd --- /dev/null +++ b/app/server/services/ts/user/Dockerfile.mutation.test @@ -0,0 +1,10 @@ +FROM measureonecodetwice/finus-user:local + +COPY user/test ./test + +WORKDIR /project +COPY vitest.config.ts . + +ENV CI=true + +CMD [ "bun", "stryker", "run"] diff --git a/app/server/services/ts/user/bun.lock b/app/server/services/ts/user/bun.lock index d88bf07..8cb3341 100644 --- a/app/server/services/ts/user/bun.lock +++ b/app/server/services/ts/user/bun.lock @@ -10,6 +10,8 @@ "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "mysql2": "^3.15.3", + "stryker-mutator-bun-runner": "^0.4.0", + "supertest": "^7.2.2", "vitest": "^4.0.18", }, "devDependencies": { @@ -17,12 +19,14 @@ "@types/bun": "latest", "@types/express": "^5.0.6", "@types/node": "^25.1.0", + "@types/supertest": "^7.2.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", - "eslint": "^9.39.2", + "eslint": "^10.1.0", "eslint-plugin-react": "^7.37.5", "globals": "^17.3.0", "jiti": "^2.6.1", + "supertest": "^7.2.2", "typescript-eslint": "^8.54.0", }, "peerDependencies": { @@ -31,75 +35,91 @@ }, }, "packages": { - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.0", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-decorators": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-transform-destructuring": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + "@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.3", "", { "dependencies": { "@eslint/core": "^1.1.1" } }, "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw=="], - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + "@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.4", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" } }, "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ=="], + "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], - "@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], - - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -109,72 +129,146 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.4", "", {}, "sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@5.1.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PubpMPO2nJgMufkoB3P2wwxNXEMUXnBIKi/ACzDUYfaoPuM7gSTmuxJeMscoLVEsR4qqrCMf5p0SiYGWnVJ8kw=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-tiNyA73pgpQ0FQ7axqtoLUe4GDYjNCDcVsbgcA5anvwg2z6i+suEngLKKJrWKJolT//GFPZHwN30binDIHgSgQ=="], + + "@inquirer/core": ["@inquirer/core@11.1.7", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-1BiBNDk9btIwYIzNZpkikIHXWeNzNncJePPqwDyVMhXhD1ebqbpn1mKGctpoqAbzywZfdG0O4tvmsGIcOevAPQ=="], + + "@inquirer/editor": ["@inquirer/editor@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/external-editor": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VJx4XyaKea7t8hEApTw5dxeIyMtWXre2OiyJcICCRZI4hkoHsMoCnl/KbUnJJExLbH9csLLHMVR144ZhFE1CwA=="], + + "@inquirer/expand": ["@inquirer/expand@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fC0UHJPXsTRvY2fObiwuQYaAnHrp3aDqfwKUJSdfpgv18QUG054ezGbaRNStk/BKD5IPijeMKWej8VV8O5Q/eQ=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@2.0.4", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Prenuv9C1PHj2Itx0BcAOVBTonz02Hc2Nd2DbU67PdGUaqn0nPCnV34oDyyoaZHnmfRxkpuhh/u51ThkrO+RdA=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.4", "", {}, "sha512-eLBsjlS7rPS3WEhmOmh1znQ5IsQrxWzxWDxO51e4urv+iVrSnIHbq4zqJIOiyNdYLa+BVjwOtdetcQx1lWPpiQ=="], + + "@inquirer/input": ["@inquirer/input@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nvZ6qEVeX/zVtZ1dY2hTGDQpVGD3R7MYPLODPgKO8Y+RAqxkrP3i/3NwF3fZpLdaMiNuK0z2NaYIx9tPwiSegQ=="], + + "@inquirer/number": ["@inquirer/number@4.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Ht8OQstxiS3APMGjHV0aYAjRAysidWdwurWEo2i8yI5xbhOBWqizT0+MU1S2GCcuhIBg+3SgWVjEoXgfhY+XaA=="], + + "@inquirer/password": ["@inquirer/password@5.0.10", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QbNyvIE8q2GTqKLYSsA8ATG+eETo+m31DSR0+AU7x3d2FhaTWzqQek80dj3JGTo743kQc6mhBR0erMjYw5jQ0A=="], + + "@inquirer/prompts": ["@inquirer/prompts@8.3.2", "", { "dependencies": { "@inquirer/checkbox": "^5.1.2", "@inquirer/confirm": "^6.0.10", "@inquirer/editor": "^5.0.10", "@inquirer/expand": "^5.0.10", "@inquirer/input": "^5.0.10", "@inquirer/number": "^4.0.10", "@inquirer/password": "^5.0.10", "@inquirer/rawlist": "^5.2.6", "@inquirer/search": "^4.1.6", "@inquirer/select": "^5.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yFroiSj2iiBFlm59amdTvAcQFvWS6ph5oKESls/uqPBect7rTU2GbjyZO2DqxMGuIwVA8z0P4K6ViPcd/cp+0w=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@5.2.6", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-jfw0MLJ5TilNsa9zlJ6nmRM0ZFVZhhTICt4/6CU2Dv1ndY7l3sqqo1gIYZyMMDw0LvE1u1nzJNisfHEhJIxq5w=="], + + "@inquirer/search": ["@inquirer/search@4.1.6", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3/6kTRae98hhDevENScy7cdFEuURnSpM3JbBNg8yfXLw88HgTOl+neUuy/l9W0No5NzGsLVydhBzTIxZP7yChQ=="], + + "@inquirer/select": ["@inquirer/select@5.1.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kTK8YIkHV+f02y7bWCh7E0u2/11lul5WepVTclr3UMBtBr05PgcZNWfMa7FY57ihpQFQH/spLMHTcr0rXy50tA=="], + + "@inquirer/type": ["@inquirer/type@4.0.4", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA=="], + + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mysql/xdevapi": ["@mysql/xdevapi@8.0.35", "", { "dependencies": { "google-protobuf": "3.19.4", "lossless-json": "2.0.1", "parsimmon": "1.18.1" } }, "sha512-l7HoBt1l0GwdCBXBrqri4kPBbJrqsyvaa4eqXvq50aWWArOTc5XGV06FWF1epxT+RORBy7scZTWFANxpr4Sfuw=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/8IzqSu4/OWGRs7Fs2ROzGVwJMFTBQkgAp6sAthkBYoN7OiM4rY/CpPVs2X9w9N1W61CHSkEdNKi8HrLZKfK3g=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-TT7eUihnAzxM2tlZesusuC75PAOYKvUBgVU/Nm/lakZ/DpyuqhNkzUfcxSgmmK9IjVWzMmezLIGZl16XGCGJng=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-CYjIHWaQG7T4phfjErHr6BiXRs0K/9DqMeiohJmuYSBF+H2m56vFslOenLCguGYQL9jeiiCZBeoVCpwjxZrMgQ=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-8XMLyRNxHF4jfLajkWt+F8UDxsWbzysyxQVMZKUXwoeGvaxB0rVd07r3YbgDtG8U6khhRFM3oaGp+CQ0whwmdA=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-jBwYCLG5Eb+PqtFrc3Wp2WMYlw1Id75gUcsdP+ApCOpf5oQhHxkFWCjZmcDoioDmEhMWAiM3wtwSrTlPg+sI6Q=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-z3GFCk1UBzDOOiEBHL32lVP7Edi26BhOjKb6bIc0nRyabbRiyON4++GR0zmd/H5zM5S0+UcXFgCGnD+b8avTLw=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-KZlf1jKtf4jai8xiQv/0XRjxVVhHnw/HtUKtLdOeQpTOQ1fQFhLoz2FGGtVRd0LVa/yiRbSz9HlWIzWlmJClng=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-ADImD4yCHNpqZu718E2chWcCaAHvua90yhmpzzV6fF4zOhwkGGbPCgUWmKyJ83uz+DXaPdYxX0ttDvtolrzx3Q=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-J+qz4Al05PrNIOdj7xsWVTyx0c/gjUauG5nKV3Rrx0Q+5JO+1pPVlnfNmWbOF9pKG4f3IGad8KXJUfGMORld+Q=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-UOdkwScHRkGPz+n9ZJU7sTkTvqV7rD1SLCLaru1xH8WRsV7tDorPqNCzEN1msOIiPRK825nvAtEm9UsomO1GsA=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-E51tyWDP1l0CbjZYhiUxhDGPaY8Hf5YBREx0PHBff1LM1/q3qsJ6ZvRUa8YbbOO0Ax9QP6GHjD9vf3n6bXZ7QA=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-cCsXK9AQ9Zf18QlVnbrFu2IKfr4sf2sfbErkF2jfCzyCO9Bnhl0KRx63zlN+Ni1xU7gcBLAssgcui5R400N2eA=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@stryker-mutator/api": ["@stryker-mutator/api@9.6.0", "", { "dependencies": { "mutation-testing-metrics": "3.7.2", "mutation-testing-report-schema": "3.7.2", "tslib": "~2.8.0", "typed-inject": "~5.0.0" } }, "sha512-kJEEwOVoWDXGEIXuM+9efT6LSJ7nyxnQQvjEoKg8GSZXbDUjfD0tqA0aBD06U1SzQLKCM7ffjgPffr154MHZKw=="], + + "@stryker-mutator/core": ["@stryker-mutator/core@9.6.0", "", { "dependencies": { "@inquirer/prompts": "^8.0.0", "@stryker-mutator/api": "9.6.0", "@stryker-mutator/instrumenter": "9.6.0", "@stryker-mutator/util": "9.6.0", "ajv": "~8.18.0", "chalk": "~5.6.0", "commander": "~14.0.0", "diff-match-patch": "1.0.5", "emoji-regex": "~10.6.0", "execa": "~9.6.0", "json-rpc-2.0": "^1.7.0", "lodash.groupby": "~4.6.0", "minimatch": "~10.2.4", "mutation-server-protocol": "~0.4.0", "mutation-testing-elements": "3.7.2", "mutation-testing-metrics": "3.7.2", "mutation-testing-report-schema": "3.7.2", "npm-run-path": "~6.0.0", "progress": "~2.0.3", "rxjs": "~7.8.1", "semver": "^7.6.3", "source-map": "~0.7.4", "tree-kill": "~1.2.2", "tslib": "2.8.1", "typed-inject": "~5.0.0", "typed-rest-client": "~2.2.0" }, "bin": { "stryker": "bin/stryker.js" } }, "sha512-oSbw01l6HXHt0iW9x5fQj7yHGGT8ZjCkXSkI7Bsu0juO7Q6vRMXk7XcvKpCBgRgzKXi1osg8+iIzj7acHuxepQ=="], + + "@stryker-mutator/instrumenter": ["@stryker-mutator/instrumenter@9.6.0", "", { "dependencies": { "@babel/core": "~7.29.0", "@babel/generator": "~7.29.0", "@babel/parser": "~7.29.0", "@babel/plugin-proposal-decorators": "~7.29.0", "@babel/plugin-transform-explicit-resource-management": "^7.28.0", "@babel/preset-typescript": "~7.28.0", "@stryker-mutator/api": "9.6.0", "@stryker-mutator/util": "9.6.0", "angular-html-parser": "~10.4.0", "semver": "~7.7.0", "tslib": "2.8.1", "weapon-regex": "~1.3.2" } }, "sha512-tWdRYfm9LF4Go7cNOos0xEIOEnN7ZOSj38rfXvGZS9IINlvYBrBCl2xcz/67v6l5A7xksMWWByZRIq2bgdnnUg=="], + + "@stryker-mutator/util": ["@stryker-mutator/util@9.6.0", "", {}, "sha512-gw7fJOFNHEj9inAEOodD9RrrMEMhZmWJ46Ww/kDJAXlSsBBmdwCzeomNLngmLTvgp14z7Tfq85DHYwvmNMdOxA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], @@ -185,9 +279,11 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], @@ -195,39 +291,43 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], + "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], + + "@types/supertest": ["@types/supertest@7.2.0", "", { "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.2", "@typescript-eslint/types": "^8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2" } }, "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.2", "@typescript-eslint/tsconfig-utils": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw=="], - "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + "@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="], - "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], + "@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="], - "@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="], + "@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="], - "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="], - "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + "@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="], - "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + "@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -237,9 +337,7 @@ "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "angular-html-parser": ["angular-html-parser@10.4.0", "", {}, "sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], @@ -255,6 +353,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], @@ -265,17 +365,23 @@ "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], - "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + "axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "bun": ["bun@1.3.11", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.11", "@oven/bun-darwin-x64": "1.3.11", "@oven/bun-darwin-x64-baseline": "1.3.11", "@oven/bun-linux-aarch64": "1.3.11", "@oven/bun-linux-aarch64-musl": "1.3.11", "@oven/bun-linux-x64": "1.3.11", "@oven/bun-linux-x64-baseline": "1.3.11", "@oven/bun-linux-x64-musl": "1.3.11", "@oven/bun-linux-x64-musl-baseline": "1.3.11", "@oven/bun-windows-aarch64": "1.3.11", "@oven/bun-windows-x64": "1.3.11", "@oven/bun-windows-x64-baseline": "1.3.11" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-AvXWYFO6j/ZQ7bhGm4X6eilq2JHsDVC90ZM32k2B7/srhC2gs3Sdki1QTbwrdRCo8o7eT+167vcB1yzOvPdbjA=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -285,28 +391,36 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -329,6 +443,14 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], + + "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -337,6 +459,10 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "electron-to-chromium": ["electron-to-chromium@1.5.328", "", {}, "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], @@ -345,9 +471,9 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-iterator-helpers": ["es-iterator-helpers@1.2.2", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "safe-array-concat": "^1.1.3" } }, "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w=="], + "es-iterator-helpers": ["es-iterator-helpers@1.3.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" } }, "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], @@ -357,21 +483,21 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], - "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="], + "eslint": ["eslint@10.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.3", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], @@ -385,6 +511,8 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -395,8 +523,20 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -405,14 +545,18 @@ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - "flatted": ["flatted@3.3.4", "", {}, "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA=="], + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@4.0.5", "", { "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" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -429,12 +573,18 @@ "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="], @@ -447,8 +597,6 @@ "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], @@ -461,12 +609,12 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -505,6 +653,8 @@ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], @@ -515,12 +665,16 @@ "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], @@ -533,18 +687,26 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-rpc-2.0": ["json-rpc-2.0@1.7.1", "", {}, "sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], @@ -557,8 +719,34 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], @@ -571,8 +759,6 @@ "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -581,6 +767,8 @@ "lossless-json": ["lossless-json@2.0.1", "", {}, "sha512-KW/FSL426qblKVvf4ImeMVGr0Je6J9aXvAMUOIU8AzelDj06q47mn6QJ+56lBBd+A8kjrncrxdKQs6ZssAXTmw=="], + "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -591,15 +779,33 @@ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + + "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "mysql2": ["mysql2@3.18.2", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw=="], + "mutation-server-protocol": ["mutation-server-protocol@0.4.1", "", { "dependencies": { "zod": "^4.1.12" } }, "sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g=="], + + "mutation-testing-elements": ["mutation-testing-elements@3.7.2", "", {}, "sha512-i7X2Q4X5eYon72W2QQ9HND7plVhQcqTnv+Xc3KeYslRZSJ4WYJoal8LFdbWm7dKWLNE0rYkCUrvboasWzF3MMA=="], + + "mutation-testing-metrics": ["mutation-testing-metrics@3.7.2", "", { "dependencies": { "mutation-testing-report-schema": "3.7.2" } }, "sha512-ichXZSC4FeJbcVHYOWzWUhNuTJGogc0WiQol8lqEBrBSp+ADl3fmcZMqrx0ogInEUiImn+A8JyTk6uh9vd25TQ=="], + + "mutation-testing-report-schema": ["mutation-testing-report-schema@3.7.2", "", {}, "sha512-fN5M61SDzIOeJyatMOhGPLDOFz5BQIjTNPjo4PcHIEUWrejO4i4B5PFuQ/2l43709hEsTxeiXX00H73WERKcDw=="], + + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + + "mysql2": ["mysql2@3.20.0", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg=="], "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], @@ -611,6 +817,10 @@ "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -639,7 +849,9 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -651,13 +863,15 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "path-to-regexp": ["path-to-regexp@8.4.0", "", {}, "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -665,11 +879,15 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -685,14 +903,16 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], - "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + "rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -731,6 +951,10 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="], @@ -739,7 +963,7 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -753,23 +977,33 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "stryker-mutator-bun-runner": ["stryker-mutator-bun-runner@0.4.0", "", { "dependencies": { "@stryker-mutator/api": "^9.0.1", "@stryker-mutator/util": "^9.0.1", "execa": "^9.6.0", "glob": "^11.0.3", "semver": "^7.5.4" }, "peerDependencies": { "@stryker-mutator/core": "^9.0.0", "bun": ">=1.0.0" } }, "sha512-wx2nfdfKFWRxE5fiAYzRBVJoUMtoeYIE2uAtWS1fu4rGeay6XVm3wBmqi2bKosVH6zHPShsciSCDKDGifZHvSA=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "superagent": ["superagent@10.3.0", "", { "dependencies": { "component-emitter": "^1.3.1", "cookiejar": "^2.1.4", "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.5", "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.14.1" } }, "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ=="], + + "supertest": ["supertest@7.2.2", "", { "dependencies": { "cookie-signature": "^1.2.2", "methods": "^1.1.2", "superagent": "^10.3.0" } }, "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -783,23 +1017,35 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + "typed-inject": ["typed-inject@5.0.0", "", {}, "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA=="], + + "typed-rest-client": ["typed-rest-client@2.2.0", "", { "dependencies": { "des.js": "^1.1.0", "js-md4": "^0.3.2", "qs": "^6.14.1", "tunnel": "0.0.6", "underscore": "^1.12.1" } }, "sha512-/e2Rk9g20N0r44kaQLb3v6QGuryOD8SPb53t43Y5kqXXA+SqWuU7zLiMxetw61jNn/JFrxTdr5nPDhGY/eTNhQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], + "typescript-eslint": ["typescript-eslint@8.57.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.2", "@typescript-eslint/parser": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], - "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="], + + "weapon-regex": ["weapon-regex@1.3.6", "", {}, "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -817,30 +1063,44 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@stryker-mutator/core/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@stryker-mutator/core/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "@stryker-mutator/instrumenter/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "jsonwebtoken/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "stryker-mutator-bun-runner/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@stryker-mutator/core/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/app/server/services/ts/user/integration/accounts.test.ts b/app/server/services/ts/user/integration/accounts.test.ts new file mode 100644 index 0000000..00743ea --- /dev/null +++ b/app/server/services/ts/user/integration/accounts.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import request from "supertest"; +import { createAuthenticatedAccount, createSignupBody } from "./setup"; + +const BASE_URL = process.env.API_GATEWAY_ADDR; + +let token: string; +let accountId: number; + +beforeAll(async () => { + const setup = await createAuthenticatedAccount(BASE_URL, "accounts", { + name: "Chequing", + type: "chequing", + balance: 1000, + value: 1000, + subtype: "na", + }); + + token = setup.token; + accountId = setup.accountId; +}, 30000); + +describe("Accounts Integration (Docker)", () => { + it("lists accounts for the authenticated user", async () => { + const res = await request(BASE_URL) + .get("/api/accounts") + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + }); + + it("updates an account", async () => { + const res = await request(BASE_URL) + .put("/api/accounts") + .set("Authorization", `Bearer ${token}`) + .send({ + id: accountId, + name: "Updated Chequing", + type: "chequing", + balance: 1500, + value: 1500, + subtype: "na", + }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe("Account successfully updated"); + }); + + it("prevents unauthorized account updates", async () => { + const otherUser = createSignupBody("accounts-other"); + otherUser.age = 22; + otherUser.password = "password123"; + + const signup = await request(BASE_URL).post("/api/signup").send(otherUser); + expect(signup.status).toBe(201); + + const login2 = await request(BASE_URL) + .post("/api/login") + .send({ email: otherUser.email, password: otherUser.password }); + + expect(login2.status).toBe(200); + + const token2 = login2.body.token; + + const res = await request(BASE_URL) + .put("/api/accounts") + .set("Authorization", `Bearer ${token2}`) + .send({ + id: accountId, + name: "Hacked Account", + type: "chequing", + balance: 9999, + value: 9999, + subtype: "na", + }); + + expect(res.status).toBe(401); + }); + + it("deletes an account", async () => { + const res = await request(BASE_URL) + .delete("/api/accounts") + .set("Authorization", `Bearer ${token}`) + .send({ id: accountId }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe("Account successfully deleted"); + }); + + it("rejects account creation without auth", async () => { + const res = await request(BASE_URL).post("/api/accounts").send({ + name: "Unauthorized", + type: "chequing", + }); + + expect(res.status).toBe(400); + }); + + it("validates account input", async () => { + const res = await request(BASE_URL) + .post("/api/accounts") + .set("Authorization", `Bearer ${token}`) + .send({ + name: "", // invalid + type: "chequing", + }); + + expect(res.status).toBe(400); + }); +}); diff --git a/app/server/services/ts/user/integration/goals.test.ts b/app/server/services/ts/user/integration/goals.test.ts new file mode 100644 index 0000000..77404ba --- /dev/null +++ b/app/server/services/ts/user/integration/goals.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import request from "supertest"; +import { setupUserWithGoals } from "./setup.ts"; +import type { Goal } from "../src/types/Goals.ts"; + +const BASE_URL = process.env.API_GATEWAY_ADDR; + +let token: string; + +beforeAll(async () => { + const setup = await setupUserWithGoals(BASE_URL, "goals-test", [ + { + name: "Reduce Grocery Spending", + type: "reduce_spending", + category: "groceries", + target: 400, + period: "m", + }, + { + type: "save", + name: "New Goal", + category: "Unknown", + target: 100, + period: "m", + }, + ]); + token = setup.token; +}, 30000); + +describe("Goals Integration (Docker)", () => { + let createdGoalId: number; + + it("creates a new spending reduction goal", async () => { + const goalData = { + name: "Reduce Grocery Spending", + type: "reduce_spending", + category: "groceries", + target: 400, + period: "m", + }; + + const res = await request(BASE_URL) + .post("/goals") + .set("Authorization", `Bearer ${token}`) + .send(goalData); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("id"); + expect(res.body.name).toBe(goalData.name); + expect(res.body.type).toBe(goalData.type); + expect(res.body.target).toBe("400.00"); + expect(res.body.period).toBe(goalData.period); + + createdGoalId = res.body.id; + }); + + it("creates a savings goal", async () => { + const goalData = { + type: "save", + name: "New Goal", + category: "Unknown", + target: 100, + period: "m", + }; + + const res = await request(BASE_URL) + .post("/goals") + .set("Authorization", `Bearer ${token}`) + .send(goalData); + + expect(res.status).toBe(201); + expect(res.body.name).toBe(goalData.name); + expect(res.body.type).toBe(goalData.type); + expect(res.body.target).toBe("100.00"); + }); + + it("lists all goals for the user", async () => { + const res = await request(BASE_URL) + .get("/goals") + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + }); + + it("updates a goal (using query parameter gid)", async () => { + const updates = { + id: createdGoalId, + name: "Updated Grocery Goal", + profile_id: 1, + target: 350, + }; + + const res = await request(BASE_URL) + .patch(`/goals?gid=${createdGoalId}`) + .set("Authorization", `Bearer ${token}`) + .send(updates); + + expect(res.status).toBe(200); + expect(res.body.name).toBe(updates.name); + expect(res.body.target).toBe("350.00"); + }); + + it("deletes a goal (using query parameter gid)", async () => { + // Create a temporary goal to delete + const createRes = await request(BASE_URL) + .post("/goals") + .set("Authorization", `Bearer ${token}`) + .send({ + name: "Temp Goal", + type: "save", + category: "temp", + target: 100, + period: "m", + }); + + const tempGoalId = createRes.body.id; + + const deleteRes = await request(BASE_URL) + .delete(`/goals?gid=${tempGoalId}`) + .set("Authorization", `Bearer ${token}`); + + expect(deleteRes.status).toBe(204); + + // Verify it's gone - should return 404 + const getRes = await request(BASE_URL) + .get("/goals") + .set("Authorization", `Bearer ${token}`); + + const stillExists = getRes.body.some( + (goal: Goal) => goal.id === tempGoalId, + ); + expect(stillExists).toBe(false); + }); + + it("respects the 5-goal limit", async () => { + // First, delete all existing goals to start fresh + const listRes = await request(BASE_URL) + .get("/goals") + .set("Authorization", `Bearer ${token}`); + + for (const goal of listRes.body) { + const result = await request(BASE_URL) + .delete(`/goals?gid=${goal.id}`) + .set("Authorization", `Bearer ${token}`); + + expect(result.status).toBe(204); + } + + const listResPostDelete = await request(BASE_URL) + .get("/goals") + .set("Authorization", `Bearer ${token}`); + + expect(listResPostDelete.body.length).toBe(0); + + // Create 5 goals + for (let i = 0; i < 5; i++) { + const res = await request(BASE_URL) + .post("/goals") + .set("Authorization", `Bearer ${token}`) + .send({ + name: `Goal ${i}`, + type: "save", + category: `category${i}`, + target: 100, + period: "m", + }); + expect(res.status).toBe(201); + } + + // Try to create a 6th + const res = await request(BASE_URL) + .post("/goals") + .set("Authorization", `Bearer ${token}`) + .send({ + name: "Sixth Goal", + profile_id: 1, + type: "save", + category: "extra", + target: 100, + period: "m", + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Maximum 5 goals allowed per profile"); + }); + + it("rejects goal creation without required fields", async () => { + // Missing period for reduce_spending + const res = await request(BASE_URL) + .post("/goals") + .set("Authorization", `Bearer ${token}`) + .send({ + name: "Invalid Goal", + type: "reduce_spending", + category: "groceries", + target: 400, + // missing period + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Missing required fields"); + }); + + it("rejects goal creation without auth", async () => { + const res = await request(BASE_URL).post("/goals").send({ + name: "Unauthorized Goal", + profile_id: 1, + type: "save", + category: "test", + target: 100, + period: "m", + }); + + expect(res.status).toBe(401); + }); + + it("rejects update without gid parameter", async () => { + const res = await request(BASE_URL) + .patch("/goals") + .set("Authorization", `Bearer ${token}`) + .send({ + name: "Should Fail", + }); + + // Without gid, should return 400 or 404 + expect(res.status).toBe(404); + }); + + it("rejects delete without gid parameter", async () => { + const res = await request(BASE_URL) + .delete("/goals") + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(404); + }); +}); diff --git a/app/server/services/ts/user/integration/setup.ts b/app/server/services/ts/user/integration/setup.ts new file mode 100644 index 0000000..de537fa --- /dev/null +++ b/app/server/services/ts/user/integration/setup.ts @@ -0,0 +1,175 @@ +import request from "supertest"; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function createSignupBody(prefix: string) { + const safePrefix = prefix.replace(/[^\w]/g, ""); + const unique = `${safePrefix}${Date.now()}${Math.random().toString(36).slice(2, 8)}`; + + return { + username: unique, + email: `${unique}@example.com`, + first_name: "Test", + last_name: "User", + age: 30, + password: "123ABC!7", + }; +} + +export async function createAuthenticatedAccount( + baseUrl: string, + prefix: string, + accountOverrides?: { + name?: string; + type?: string; + balance?: number; + value?: number; + subtype?: string; + }, +) { + const deadline = Date.now() + 20000; + let lastFailure = "setup did not start"; + + while (Date.now() < deadline) { + const signupBody = createSignupBody(prefix); + + try { + const signup = await request(baseUrl) + .post("/api/signup") + .send(signupBody); + if (signup.status !== 201) { + lastFailure = `signup=${signup.status} ${JSON.stringify(signup.body)}`; + await sleep(500); + continue; + } + + const login = await request(baseUrl) + .post("/api/login") + .send({ email: signupBody.email, password: signupBody.password }); + if (login.status !== 200 || !login.body.token) { + lastFailure = `login=${login.status} ${JSON.stringify(login.body)}`; + await sleep(500); + continue; + } + + const token = login.body.token as string; + + // Create the account + const account = await request(baseUrl) + .post("/api/accounts") + .set("Authorization", `Bearer ${token}`) + .send({ + name: accountOverrides?.name ?? "Chequing", + type: accountOverrides?.type ?? "chequing", + balance: accountOverrides?.balance ?? 1000, + value: accountOverrides?.value ?? 1000, + subtype: accountOverrides?.subtype ?? "na", + }); + + console.log("Account creation response:", account.status, account.body); + + if (account.status !== 200 || !account.body.id) { + lastFailure = `account=${account.status} ${JSON.stringify(account.body)}`; + await sleep(500); + continue; + } + + return { + token, + accountId: account.body.id as number, + }; + } catch (error) { + lastFailure = + error instanceof Error ? error.message : "unknown setup error"; + console.log("Setup error:", lastFailure); + await sleep(500); + } + } + + throw new Error(`Timed out creating authenticated account: ${lastFailure}`); +} + +export interface GoalData { + name: string; + type: "save" | "reduce_spending"; + category: string; + target: number; + period?: "w" | "m"; +} + +export async function createGoal( + baseUrl: string, + token: string, + goalData: GoalData, +) { + const response = await request(baseUrl) + .post("/goals") + .set("Authorization", `Bearer ${token}`) + .send(goalData); + + if (response.status !== 201) { + throw new Error( + `Failed to create goal: ${response.status} ${JSON.stringify(response.body)}`, + ); + } + + return response.body; +} + +export async function createMultipleGoals( + baseUrl: string, + token: string, + goalsData: GoalData[], +) { + const createdGoals = []; + for (const goalData of goalsData) { + const goal = await createGoal(baseUrl, token, goalData); + createdGoals.push(goal); + } + return createdGoals; +} + +export async function deleteAllGoals(baseUrl: string, token: string) { + // Fetch all goals + const listRes = await request(baseUrl) + .get("/goals") + .set("Authorization", `Bearer ${token}`); + + if (listRes.status === 200 && Array.isArray(listRes.body)) { + // Delete each goal + for (const goal of listRes.body) { + await request(baseUrl) + .delete(`/goals?gid=${goal.id}`) + .set("Authorization", `Bearer ${token}`); + } + } +} + +export async function setupUserWithGoals( + baseUrl: string, + prefix: string, + goalsData: GoalData[], +) { + // First create the user account + const { token } = await createAuthenticatedAccount(baseUrl, prefix, { + name: "Test Account", + type: "chequing", + balance: 5000, + value: 5000, + subtype: "na", + }); + + // Clean up any existing goals (in case of leftover data) + await deleteAllGoals(baseUrl, token); + + // Create the specified goals + const createdGoals = await createMultipleGoals(baseUrl, token, goalsData); + + return { + token, + goals: createdGoals, + deleteAllGoals: () => deleteAllGoals(baseUrl, token), + }; +} diff --git a/app/server/services/ts/user/integration/transaction.test.ts b/app/server/services/ts/user/integration/transaction.test.ts new file mode 100644 index 0000000..9756dba --- /dev/null +++ b/app/server/services/ts/user/integration/transaction.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import request from "supertest"; +import { createAuthenticatedAccount } from "./setup"; + +const BASE_URL = process.env.API_GATEWAY_ADDR; + +let token: string; +let accountId: number; + +beforeAll(async () => { + const setup = await createAuthenticatedAccount(BASE_URL, "transactions", { + name: "all my moola", + type: "chequing", + balance: 1000, + value: 1000, + subtype: "TFSA", + }); + + token = setup.token; + accountId = setup.accountId; +}, 30000); + +describe("Transactions Integration (Docker)", () => { + it("creates a transaction", async () => { + const res = await request(BASE_URL) + .post("/api/transactions") + .set("Authorization", `Bearer ${token}`) + .send({ + financialAccount_id: accountId, + amount: 50, + description: "Groceries", + sender: "Me", + recipient: "Store", + date: "2024-01-01T12:00:00", + category: "Food", + }); + + expect(res.status).toBe(200); + expect(res.body.id).toBeDefined(); + }); + + it("lists transactions for an account", async () => { + // Create a transaction first + await request(BASE_URL) + .post("/api/transactions") + .set("Authorization", `Bearer ${token}`) + .send({ + financialAccount_id: accountId, + amount: 20, + description: "Coffee", + sender: "Me", + recipient: "Cafe", + date: "2024-01-02T10:00:00", + category: "Food", + }); + + const res = await request(BASE_URL) + .get(`/api/transactions?financialAccount_id=${accountId}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + }); + + it("updates a transaction", async () => { + // Create a transaction + const create = await request(BASE_URL) + .post("/api/transactions") + .set("Authorization", `Bearer ${token}`) + .send({ + financialAccount_id: accountId, + amount: 10, + description: "Snack", + sender: "Me", + recipient: "Vending", + date: "2024-01-03T09:00:00", + category: "Food", + }); + + const id = create.body.id; + + const update = await request(BASE_URL) + .put("/api/transactions") + .set("Authorization", `Bearer ${token}`) + .send({ + id, + financialAccount_id: accountId, + amount: 15, + description: "Snack Updated", + sender: "Me", + recipient: "Vending", + date: "2024-01-03T09:30:00", + }); + + expect(update.status).toBe(200); + expect(update.body.message).toBe("Transaction successfully updated"); + }); + + it("deletes a transaction", async () => { + // Create a transaction + const create = await request(BASE_URL) + .post("/api/transactions") + .set("Authorization", `Bearer ${token}`) + .send({ + financialAccount_id: accountId, + amount: 100, + description: "Test", + sender: "Me", + recipient: "Someone", + date: "2024-01-04T12:00:00", + category: "Misc", + }); + + const id = create.body.id; + + const del = await request(BASE_URL) + .delete("/api/transactions") + .set("Authorization", `Bearer ${token}`) + .send({ id, financialAccount_id: accountId }); + + expect(del.status).toBe(200); + expect(del.body.message).toBe("Transaction deleted"); + }); + + it("imports CSV transactions", async () => { + const res = await request(BASE_URL) + .post("/api/transactions/csvTransaction") + .set("Authorization", `Bearer ${token}`) + .send({ + financialAccount_id: accountId, + transactions: [ + { + amount: 12.5, + description: "Lunch", + sender: "Me", + recipient: "Restaurant", + date: "2024-01-05T13:00:00", + category: "Food", + errors: [], + }, + { + amount: null, + description: null, + date: null, + errors: ["Missing amount"], + }, + ], + }); + + expect(res.status).toBe(200); + expect(res.body.inserted).toBe(1); + expect(res.body.skipped).toBe(1); + expect(res.body.transactions.length).toBe(1); + }); + + it("rejects unauthorized access", async () => { + const res = await request(BASE_URL).post("/api/transactions").send({ + financialAccount_id: accountId, + amount: 10, + date: "2024-01-01T12:00:00", + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/app/server/services/ts/user/package.json b/app/server/services/ts/user/package.json index e407a4d..837e93e 100644 --- a/app/server/services/ts/user/package.json +++ b/app/server/services/ts/user/package.json @@ -9,12 +9,14 @@ "@types/bun": "latest", "@types/express": "^5.0.6", "@types/node": "^25.1.0", + "@types/supertest": "^7.2.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", - "eslint": "^9.39.2", + "eslint": "^10.1.0", "eslint-plugin-react": "^7.37.5", "globals": "^17.3.0", "jiti": "^2.6.1", + "supertest": "^7.2.2", "typescript-eslint": "^8.54.0" }, "peerDependencies": { @@ -23,7 +25,9 @@ "scripts": { "dev": "bun --watch src/index.ts", "build": "tsc", - "start": "bun dist/index.js" + "start": "bun dist/index.js", + "test:mutation": "npx stryker run", + "test": "vitest" }, "dependencies": { "@mysql/xdevapi": "^8.0.35", @@ -31,6 +35,8 @@ "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "mysql2": "^3.15.3", + "stryker-mutator-bun-runner": "^0.4.0", + "supertest": "^7.2.2", "vitest": "^4.0.18" } } diff --git a/app/server/services/ts/user/reports/mutation/mutation.html b/app/server/services/ts/user/reports/mutation/mutation.html new file mode 100644 index 0000000..e67e91a --- /dev/null +++ b/app/server/services/ts/user/reports/mutation/mutation.html @@ -0,0 +1,11350 @@ + + + + + + + + + + + + + + + + + + + Your browser doesn't support + custom elements. Please use a latest version of an evergreen browser (Firefox, Chrome, + Safari, Opera, Edge, etc). + + + + diff --git a/app/server/services/ts/user/src/CheckUser.ts b/app/server/services/ts/user/src/CheckUser.ts new file mode 100644 index 0000000..3a92f34 --- /dev/null +++ b/app/server/services/ts/user/src/CheckUser.ts @@ -0,0 +1,39 @@ +import type { RowDataPacket, Connection } from "mysql2/promise"; + +//Checks the user is the owner of the account +export async function checkUserId( + db: Connection, + userId: number, + accountId: number, +): Promise { + let result = false; + try { + //need the user account's profile id first. This should be just a single item returned unless stretch feature 7 is implemented + const [profileRows] = await db.query( + `SELECT profile_id FROM finusAccount_profile WHERE account_id = ?`, + [userId], + ); + + if (!profileRows || (profileRows as RowDataPacket[]).length === 0) { + throw new Error("User profile not found"); + } + + const profileId = (profileRows as RowDataPacket[])[0].profile_id; + + //Checks if the profile has an account with that id + const [rows] = await db.query( + `SELECT 1 FROM profile_financialAccount + WHERE profile_id =? AND financialAccount_id =?`, + [profileId, accountId], + ); + + console.log(rows.length); + if (rows.length > 0) { + result = true; + } + } catch (error) { + console.error(error); + } + + return result; +} diff --git a/app/server/services/ts/user/src/DataConversion.ts b/app/server/services/ts/user/src/DataConversion.ts new file mode 100644 index 0000000..cd5fdff --- /dev/null +++ b/app/server/services/ts/user/src/DataConversion.ts @@ -0,0 +1,4 @@ +//C0j +export function convertToDateTime(date: string) { + return date.slice(0, 19).replace("T", " "); +} diff --git a/app/server/services/ts/user/src/handleJWT.ts b/app/server/services/ts/user/src/handleJWT.ts index 6b0ec12..fc55203 100644 --- a/app/server/services/ts/user/src/handleJWT.ts +++ b/app/server/services/ts/user/src/handleJWT.ts @@ -1,25 +1,31 @@ import jwt from "jsonwebtoken"; import type { Request } from "express"; +import { UnauthorizedAccessError } from "./types/UnauthorizedAccess.js"; const JWT_SECRET = process.env.JWT_SECRET; export const authenticateJWT = (req: Request) => { const authHeader = req.headers.authorization; - //console.log(req.headers); if (!authHeader) { - throw new Error("Authorization header missing"); + throw new UnauthorizedAccessError("Authorization header missing"); } const parts = authHeader.split(" "); if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") { - throw new Error("Invalid authorization header format"); + throw new UnauthorizedAccessError("Invalid authorization header format"); } const token = parts[1]; //add as any if it doesn't work - const decoded = jwt.verify(token, JWT_SECRET); + let decoded; + try { + decoded = jwt.verify(token, JWT_SECRET); + } catch { + throw new Error("Could not verify given JWT."); + } + const userId = decoded.sub; //console.log("user id: " + userId); if (!userId) { - throw new Error("User ID not found in token"); + throw new UnauthorizedAccessError("User ID not found in token"); } return userId; diff --git a/app/server/services/ts/user/src/index.ts b/app/server/services/ts/user/src/index.ts index d05a3f9..aff3b34 100644 --- a/app/server/services/ts/user/src/index.ts +++ b/app/server/services/ts/user/src/index.ts @@ -9,35 +9,23 @@ import { getSnapshotData } from "./logic/snapshot.ts"; import { onExit } from "@/hooks"; import { buildCorsConfig } from "@/expressUtils"; import { PORT } from "@/port"; - -// import express from "express"; +import { + deleteUserGoal, + createUserGoal, + updateUserGoal, + getUserGoals, + getUserGoalById, +} from "./logic/goals.ts"; +import type { GoalType, UpdateGoalInput } from "./types/Goals.ts"; +import { getGoalCountByProfileId } from "./queries/goals.ts"; import { accountsRouter } from "./routes/account.js"; import { profilesRouter } from "./routes/profile.js"; import { transactionsRouter } from "./routes/transaction.js"; -// import { PORT } from "@/port"; -// import { onExit } from "@/hooks"; -// import { buildCorsConfig } from "@/expressUtils"; -// import { getExpensesChartData } from "./logic/expenses.ts"; -// import { authenticateJWT } from "./utils/auth.ts"; -// import { pool } from "./db.ts"; -// import type { Request } from "express"; -// import type { RowDataPacket } from "mysql2/promise"; -// import mysql from "mysql2/promise"; -// import jwt from "jsonwebtoken"; - -// const pool = mysql.createPool({ -// host: process.env.MYSQL_HOST, -// port: Number(process.env.MYSQL_PORT), -// user: process.env.MYSQL_USER, -// password: process.env.MYSQL_PASSWORD, -// database: process.env.DB_NAME, -// }); - -// const JWT_SECRET = process.env.JWT_SECRET; +import { debtRouter } from "./routes/debt.ts"; +import { savingRouter } from "./routes/saving.ts"; const app = express(); app.use(buildCorsConfig()); -onExit(async () => await server.close()); app.use(express.json()); @@ -47,489 +35,249 @@ app.use((req, res, next) => { next(); }); -/* -function verifyToken( - req: express.Request, - res: express.Response, - next: NextFunction, -) { - const authHeader = req.headers.authorization; - console.log(authHeader) - //Determine if jwt header was passed - if (authHeader) { - const token = authHeader.split(" ")[1]; +// Mount routers +app.use("/accounts", accountsRouter); +app.use("/transactions", transactionsRouter); +app.use("/profiles", profilesRouter); +app.use("/debts", debtRouter); +app.use("/savings", savingRouter); + +// ============ Chart Endpoints ============ +app.get( + "/charts/expenses", + async (req: express.Request, res: express.Response) => { try { - const payload = jwt.verify(token, process.env.JWT_SECRET); - - console.log(payload); - - next(); - } catch (err) { - console.error(err); - res.status(401).json({ error: "Invalid token" }); + const userId = authenticateJWT(req); + const period = req.query.period as string; + + if (!validatePeriod(period)) { + return res + .status(400) + .json({ error: "Invalid period. Must be 'w', 'm', or 'y'." }); + } + + const chartData = await getExpensesChartData(pool, userId, period); + res.json(chartData); + } catch (error) { + console.error("Error fetching expenses chart data:", error); + if (error instanceof Error && error.message.includes("Authorization")) { + res.status(401).json({ error: error.message }); + } else { + res.status(500).json({ error: "Failed to fetch expenses chart data" }); + } } - } else { - res - .status(401) - .json({ - error: "Failed to pass token, user not authorized to send request", - }); - } -} -*/ + }, +); -//app.use(verifyToken); +app.get( + "/table/transactions", + async (req: express.Request, res: express.Response) => { + try { + const userId = authenticateJWT(req); + const transactions = await getTransactionsData(pool, userId); -//test endpoint -// app.get("/health", (req: express.Request, res: express.Response) => { -// res.send("ok"); -// }); + if (!transactions || transactions.length === 0) { + return res.json([]); + } -app.use("/accounts", accountsRouter); -app.use("/transactions", transactionsRouter); -app.use("/profiles", profilesRouter); + res.json(transactions); + } catch (error) { + console.error("Error fetching transactions:", error); + if (error instanceof Error && error.message.includes("Authorization")) { + res.status(401).json({ error: error.message }); + } else { + res.status(500).json({ error: "Failed to fetch transactions" }); + } + } + }, +); -const server = app.listen(PORT, () => { - console.log(`User Service running on port ${PORT}`); +app.get( + "/table/snapshot", + async (req: express.Request, res: express.Response) => { + try { + const userId = authenticateJWT(req); + + if (!userId) { + return res.status(401).json({ error: "User not authenticated" }); + } + + const snapshotData = await getSnapshotData(pool, userId); + res.json(snapshotData); + } catch (error) { + console.error("Error fetching snapshot data:", error); + if (error instanceof Error) { + if (error.message.includes("Authorization")) { + res.status(401).json({ error: error.message }); + } else if (error.message.includes("No snapshot data")) { + res.status(404).json({ error: "No snapshot data found" }); + } else { + res.status(500).json({ error: "Failed to fetch snapshot data" }); + } + } else { + res.status(500).json({ error: "Failed to fetch snapshot data" }); + } + } + }, +); + +// ============ Goal Endpoints ============ + +// GET all goals +app.get("/goals", async (req: express.Request, res: express.Response) => { + try { + const userId = authenticateJWT(req); + const goals = await getUserGoals(pool, userId); + res.json(goals); + } catch (error) { + console.error("Error fetching goals:", error); + if (error instanceof Error && error.message.includes("Authorization")) { + res.status(401).json({ error: error.message }); + } else { + res.status(500).json({ error: "Failed to fetch goals" }); + } + } }); -process.on("SIGTERM", () => server.close()); - -// interface Transaction extends RowDataPacket { -// id: number; -// financialAccount_id: string; -// amount: number; -// category: string; -// description: string; -// sender: string; -// recipient: string; -// date: string; -// } - -//authenticaion of JWT - returns user id -// export const authenticateJWT = (req: Request) => { -// const authHeader = req.headers.authorization; -// console.log(req.headers); -// if (!authHeader) { -// throw new Error("Authorization header missing"); -// } - -// const parts = authHeader.split(" "); -// if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") { -// throw new Error("Invalid authorization header format"); -// } -// const token = parts[1]; - -// const decoded = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload; -// const userId = decoded.sub; -// //console.log("user id: " + userId); -// if (!userId) { -// throw new Error("User ID not found in token"); -// } - -// return userId; -// }; - -// //interface for expense data so that rows of the query result can be typed and traversed -// interface ExpenseRow { -// label: string; -// total_expenses: number; -// date_group?: string; -// } - -app.get("/charts/expenses", async (req, res) => { + +// POST create new goal +app.post("/goals", async (req: express.Request, res: express.Response) => { try { const userId = authenticateJWT(req); - const period = req.query.period as string; + const { name, type, category, target, period } = req.body; - if (!validatePeriod(period)) { + if (!name || !type || !target || !period) { + return res.status(400).json({ error: "Missing required fields" }); + } + + // check goal limit (max 5) + const goalCount = await getGoalCountByProfileId(pool, userId); + if (goalCount >= 5) { return res .status(400) - .json({ error: "Invalid period. Must be 'w', 'm', or 'y'." }); + .json({ error: "Maximum 5 goals allowed per profile" }); } - const chartData = await getExpensesChartData(pool, userId, period); - res.json(chartData); + const newGoal = await createUserGoal(pool, userId, { + name, + type, + category, + target, + period, + }); + res.status(201).json(newGoal); } catch (error) { - console.error("Error fetching expenses chart data:", error); + console.error("Error creating goal:", error); if (error instanceof Error && error.message.includes("Authorization")) { res.status(401).json({ error: error.message }); } else { - res.status(500).json({ error: "Failed to fetch expenses chart data" }); + res.status(500).json({ error: "Failed to create goal" }); } } }); -app.get("/table/transactions", async (req, res) => { +// PATCH update goal +app.patch("/goals", async (req: express.Request, res: express.Response) => { try { const userId = authenticateJWT(req); - const transactions = await getTransactionsData(pool, userId); + const goalId = req.query.gid ? parseInt(req.query.gid as string) : null; + + if (!goalId) { + return res.status(400).json({ error: "Missing goal ID" }); + } + + // verify goal exists and belongs to user + const existingGoal = await getUserGoalById(pool, goalId, userId); + if (!existingGoal) { + return res.status(404).json({ error: "Goal not found" }); + } + + // build updates object with only provided fields + const updates: UpdateGoalInput = {}; + if (req.body.name !== undefined) updates.name = req.body.name; + if (req.body.category !== undefined) + updates.category = req.body.category.toLowerCase(); + if (req.body.target !== undefined) updates.target = req.body.target; + if (req.body.period !== undefined) updates.period = req.body.period; + if (req.body.type !== undefined) updates.type = req.body.type; + + // validate period for reduce_spending goals + if ( + updates.type && + updates.type === ("reduce_spending" as GoalType) && + !updates.period + ) { + return res + .status(400) + .json({ error: "Period is required for reduce_spending goals" }); + } + + // validate target amount + if (updates.target !== undefined && updates.target <= 0) { + return res + .status(400) + .json({ error: "Target amount must be greater than 0" }); + } - if (!transactions || transactions.length === 0) { - return res.json([]); // Return empty array for no transactions + const updatedGoal = await updateUserGoal(pool, goalId, userId, updates); + + if (!updatedGoal) { + return res.status(404).json({ error: "Goal not found" }); } - res.json(transactions); + res.json(updatedGoal); } catch (error) { - console.error("Error fetching transactions:", error); + console.error("Error updating goal:", error); if (error instanceof Error && error.message.includes("Authorization")) { res.status(401).json({ error: error.message }); } else { - res.status(500).json({ error: "Failed to fetch transactions" }); + res.status(500).json({ error: "Failed to update goal" }); } } }); -app.get("/table/snapshot", async (req, res) => { +// DELETE goal +app.delete("/goals", async (req: express.Request, res: express.Response) => { try { const userId = authenticateJWT(req); + const goalId = req.query.gid ? parseInt(req.query.gid as string) : null; + + if (!goalId) { + return res.status(400).json({ error: "Missing goal ID" }); + } - if (!userId) { - return res.status(401).json({ error: "User not authenticated" }); + const deleted = await deleteUserGoal(pool, goalId, userId); + if (!deleted) { + return res.status(404).json({ error: "Goal not found" }); } - const snapshotData = await getSnapshotData(pool, userId); - res.json(snapshotData); + res.status(204).send(); } catch (error) { - console.error("Error fetching snapshot data:", error); - if (error instanceof Error) { - if (error.message.includes("Authorization")) { - res.status(401).json({ error: error.message }); - } else if (error.message.includes("No snapshot data")) { - res.status(404).json({ error: "No snapshot data found" }); - } else { - res.status(500).json({ error: "Failed to fetch snapshot data" }); - } + console.error("Error deleting goal:", error); + if (error instanceof Error && error.message.includes("Authorization")) { + res.status(401).json({ error: error.message }); } else { - res.status(500).json({ error: "Failed to fetch snapshot data" }); + res.status(500).json({ error: "Failed to delete goal" }); } } }); -export { generateDateRange }; //for testing purposes only - -// app.get( -// "/charts/expenses", -// async (req: express.Request, res: express.Response) => { -// try { -// const userId = authenticateJWT(req); -// // console.log("User authenticated with ID:", userId); -// const period = req.query.period as string; -// const connection = await pool.getConnection(); - -// if (!["w", "m", "y"].includes(period)) { -// return res -// .status(400) -// .json({ error: "Invalid period. Must be 'w', 'm', or 'y'." }); -// } -// const endDate = new Date(); -// const startDate = new Date(); -// let dateFormat: string = "%Y-%m-%d"; -// let selectFormat: string; - -// switch (period) { -// case "w": -// startDate.setDate(endDate.getDate() - 7); -// dateFormat = "%Y-%m-%d"; -// selectFormat = "DATE(date)"; -// break; -// case "m": -// startDate.setDate(endDate.getDate() - 30); -// dateFormat = "%Y-%m-%d"; -// selectFormat = "DATE(date)"; -// break; -// case "y": -// startDate.setDate(endDate.getDate() - 365); -// dateFormat = "%Y-%m"; -// selectFormat = 'DATE_FORMAT(date, "%Y-%m-01")'; //first day of month for grouping -// break; -// default: -// return res -// .status(400) -// .json({ error: "Invalid period. Must be 'w', 'm', or 'y'." }); -// } - -// const startDateStr = startDate.toISOString().slice(0, 10); // YYYY-MM-DD -// const endDateStr = endDate.toISOString().slice(0, 10); - -// //grabs all transactions that are less than 0 in amount -// const query = ` -// SELECT -// ${selectFormat} as date_group, -// DATE_FORMAT(t.date, ?) as label, -// SUM(ABS(t.amount)) as total_expenses -// FROM finus.transaction t -// JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id -// JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id -// JOIN finus.profile p ON pfa.profile_id = p.id -// JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id -// JOIN finus.finusAccount u ON uap.account_id = u.id -// WHERE t.amount < 0 -// AND u.id = ? -// AND t.date >= ? -// AND t.date <= ? -// GROUP BY date_group, DATE_FORMAT(t.date, ?) -// ORDER BY date_group ASC -// `; - -// const [rows] = await connection.query(query, [ -// dateFormat, -// userId, -// startDateStr, -// endDateStr, -// dateFormat, -// ]); -// const expenses = rows as ExpenseRow[]; -// connection.release(); - -// //makes a complete date range even with days of no transactions -// const allLabels = generateDateRange(startDate, endDate, period); -// const dataMap = new Map(); - -// if (Array.isArray(expenses)) { -// expenses.forEach((row) => { -// dataMap.set(row.label, Number(row.total_expenses)); -// }); -// } - -// const data = allLabels.map((label) => dataMap.get(label) || 0); - -// const periodLabels = { -// w: "Weekly Expenses", -// m: "Monthly Expenses", -// y: "Yearly Expenses", -// }; -// //console.log("Found data:", data); -// res.json({ -// labels: allLabels, -// datasets: [ -// { -// label: periodLabels[period as keyof typeof periodLabels], -// data, -// }, -// ], -// }); -// } catch (error) { -// console.error("Error fetching expenses chart data:", error); -// res.status(500).json({ error: "Failed to fetch expenses chart data" }); -// } -// }, -// ); - -//this gets all transactions for now - can be capped to a certain amount in the future when any user reaches over 100k transactions -// app.get( -// "/table/trasactions", -// async (req: express.Request, res: express.Response) => { -// try { -// //console.log("Fetching transactions..."); -// const userId = authenticateJWT(req); -// const connection = await pool.getConnection(); -// const query = ` -// SELECT * -// FROM finus.transaction t -// JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id -// JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id -// JOIN finus.profile p ON pfa.profile_id = p.id -// JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id -// JOIN finus.finusAccount u ON uap.account_id = u.id -// WHERE u.id = ? -// ORDER BY t.date DESC -// `; - -// const [rows] = await connection.query(query, [userId]); -// connection.release(); - -// //get first and last names of the user associated with the first transaction -// const first_name = rows[0] ? rows[0].first_name : ""; -// const last_name = rows[0] ? rows[0].last_name : ""; - -// if (Array.isArray(rows)) { -// rows.forEach((row: Transaction) => { -// if (!row.sender) { -// //these cases really only appear because of the populator script -// row.sender = first_name + " " + last_name; -// } -// if (!row.recipient) { -// row.recipient = first_name + " " + last_name; -// } -// }); -// } -// res.json(rows); -// } catch (error) { -// console.error("Error fetching transactions:", error); -// res.status(500).json({ error: "Failed to fetch transactions" }); -// } -// }, -// ); - -// interface SnapshotRow extends RowDataPacket { -// total_balance: number; -// current_income: number; -// total_expenses: number; -// transaction_count: number; -// current_debt: number; -// total_savings: number; -// } - -//gets the following totals as massively aggregated values: -// total balance - combined sum of all account balances -// current income - YTD sum of all positive transactions -// average expenses - YTD average of all negative transactions -// current debt - sum of all credit_card accounts of subtype 'loan' -// total savings - sum of all savings accounts -// app.get( -// "/table/snapshot", -// async (req: express.Request, res: express.Response) => { -// let connection; -// try { -// const userId = authenticateJWT(req); -// if (!userId) { -// return res.status(401).json({ error: "User not authenticated" }); -// } - -// connection = await pool.getConnection(); - -// const today = new Date(); -// const ytdStart = new Date(today.getFullYear(), 0, 1); -// const ytdStartStr = ytdStart.toISOString().slice(0, 10); -// const todayStr = today.toISOString().slice(0, 10); -// const monthsPassed = today.getMonth() + 1; -// //single massive query to get all the data - this is apparently more efficient than multiple queries -// const [results] = await connection.query( -// ` -// SELECT -// -- Total Balance -// (SELECT COALESCE(SUM(fa.balance), 0) -// FROM finus.financialAccount fa -// JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id -// JOIN finus.profile p ON pfa.profile_id = p.id -// JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id -// WHERE uap.account_id = ?) as total_balance, - -// -- Current Income (YTD) -// (SELECT COALESCE(SUM(t.amount), 0) -// FROM finus.transaction t -// JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id -// JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id -// JOIN finus.profile p ON pfa.profile_id = p.id -// JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id -// WHERE uap.account_id = ? -// AND t.amount > 0 -// AND t.date BETWEEN ? AND ?) as current_income, - -// -- Total Expenses (YTD) -// (SELECT COALESCE(SUM(ABS(t.amount)), 0) -// FROM finus.transaction t -// JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id -// JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id -// JOIN finus.profile p ON pfa.profile_id = p.id -// JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id -// WHERE uap.account_id = ? -// AND t.amount < 0 -// AND t.date BETWEEN ? AND ?) as total_expenses, - -// -- Transaction Count (for averaging) -// (SELECT COUNT(*) -// FROM finus.transaction t -// JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id -// JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id -// JOIN finus.profile p ON pfa.profile_id = p.id -// JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id -// WHERE uap.account_id = ? -// AND t.amount < 0 -// AND t.date BETWEEN ? AND ?) as transaction_count, - -// -- Current Debt (credit_card with loan subtype) -// (SELECT COALESCE(SUM(fa.balance), 0) -// FROM finus.financialAccount fa -// JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id -// JOIN finus.profile p ON pfa.profile_id = p.id -// JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id -// WHERE uap.account_id = ? -// AND fa.type = 'credit_card' -// AND fa.subtype = 'loan') as current_debt, - -// -- Total Savings -// (SELECT COALESCE(SUM(fa.balance), 0) -// FROM finus.financialAccount fa -// JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id -// JOIN finus.profile p ON pfa.profile_id = p.id -// JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id -// WHERE uap.account_id = ? -// AND fa.type = 'savings') as total_savings -// `, -// [ -// userId, -// userId, -// ytdStartStr, -// todayStr, // for total_balance and current_income -// userId, -// ytdStartStr, -// todayStr, // for total_expenses -// userId, -// ytdStartStr, -// todayStr, // for transaction_count -// userId, // for current_debt -// userId, // for total_savings -// ], -// ); - -// connection.release(); - -// const data = results[0]; -// const avgMonthlyExpenses = -// monthsPassed > 0 ? data.total_expenses / monthsPassed : 0; -// const response = { -// totalBalance: data.total_balance || 0, -// currentIncome: data.current_income || 0, -// averageExpenses: Math.round(avgMonthlyExpenses * 100) / 100, -// currentDebt: data.current_debt || 0, -// totalSavings: data.total_savings || 0, -// metadata: { -// ytdPeriod: { -// start: ytdStartStr, -// end: todayStr, -// }, -// monthsInYTD: monthsPassed, -// transactionCount: data.transaction_count || 0, -// }, -// }; - -// res.json(response); -// } catch (error) { -// console.error("Error fetching snapshot data:", error); -// if (connection) { -// connection.release(); -// } -// res.status(500).json({ error: "Failed to fetch snapshot data" }); -// } -// }, -// ); - -//helper method for making a complete date range -// function generateDateRange(start: Date, end: Date, period: string): string[] { -// const dates: string[] = []; -// const current = new Date(start); - -// current.setHours(0, 0, 0, 0); -// const endDate = new Date(end); -// endDate.setHours(0, 0, 0, 0); - -// while (current <= endDate) { -// if (period === "y") { -// //monthly labels for year period -// const year = current.getFullYear(); -// const month = String(current.getMonth() + 1).padStart(2, "0"); -// dates.push(`${year}-${month}`); -// current.setMonth(current.getMonth() + 1); -// } else { -// //day-basis labels for everything else - week and month -// const year = current.getFullYear(); -// const month = String(current.getMonth() + 1).padStart(2, "0"); -// const day = String(current.getDate()).padStart(2, "0"); -// dates.push(`${year}-${month}-${day}`); -// current.setDate(current.getDate() + 1); -// } -// } - -// return dates; -// } +// ============ Server Setup ============ +const server = app.listen(PORT, () => { + console.log(`User Service running on port ${PORT}`); +}); + +async function cleanup() { + try { + server.close(); + await pool.end(); + } catch (error) { + console.log(error); + } +} + +process.on("SIGTERM", cleanup); +process.on("SIGINT", cleanup); +onExit(async () => await cleanup()); + +// For testing purposes only +export { generateDateRange }; diff --git a/app/server/services/ts/user/src/logic/debt.ts b/app/server/services/ts/user/src/logic/debt.ts new file mode 100644 index 0000000..615be3d --- /dev/null +++ b/app/server/services/ts/user/src/logic/debt.ts @@ -0,0 +1,21 @@ +import type { FinancialAccountRequest } from "../types/FinancialAccountRequest.ts"; +import { addDebt, findDebtsBy } from "../queries/debt.ts"; +import type { DebtInfoResponse } from "../types/DebtInfoResponse.ts"; +// import { BadRequestError } from "../types/BadRequestError.ts"; +import { getConnectionPool } from "@/sqlUtil.ts"; + +const db = getConnectionPool(); +export async function getDebts(userId: string): Promise { + return await findDebtsBy(db, userId); +} +export async function createNewDebt( + debt: FinancialAccountRequest, + userId: string, +): Promise { + try { + return await addDebt(db, debt, userId); + } catch (err) { + console.error("Error creating a new saving account: ", err); + throw err; + } +} diff --git a/app/server/services/ts/user/src/logic/goals.ts b/app/server/services/ts/user/src/logic/goals.ts new file mode 100644 index 0000000..5fd2f97 --- /dev/null +++ b/app/server/services/ts/user/src/logic/goals.ts @@ -0,0 +1,260 @@ +import { Pool } from "mysql2/promise"; +import * as goalsQueries from "../queries/goals.ts"; +import * as transactionsQueries from "../queries/transactions.ts"; +import type { + Goal, + CreateGoalInput, + UpdateGoalInput, + GoalWithProgress, +} from "../types/Goals.ts"; + +const MAX_GOALS_PER_PROFILE = 5; + +//get current spending/achievement for a goal based on transactions +export async function calculateCurrentAmount( + pool: Pool, + goal: Goal, + profileId: number | null, +): Promise { + if (!profileId) { + // console.log("No profileId provided"); + // throw new Error("No profileId provided"); + return 0; + } + + if (goal.type === "reduce_spending") { + const period = goal.period; + const now = new Date(); + let startDate: Date; + + if (period === "w") { + // Start of week (Sunday) + startDate = new Date(now); + startDate.setDate(now.getDate() - now.getDay()); + startDate.setHours(0, 0, 0, 0); + // console.log(`Weekly period: ${startDate.toISOString()} to ${now.toISOString()}`); + } else { + // Start of month + startDate = new Date(now.getFullYear(), now.getMonth(), 1); + startDate.setHours(0, 0, 0, 0); + // console.log(`Monthly period: ${startDate.toISOString()} to ${now.toISOString()}`); + } + + //get expenses (negative amounts) for this category in the period + const expenses = await transactionsQueries.getDateCategoryTransactionsQuery( + pool, + profileId, + goal.category!, + startDate, + now, + ); + + //sum the amounts (they are negative, so sum will be negative) + const totalSpending = expenses.reduce( + (total, transaction) => total + transaction.amount, + 0, + ); + + //return absolute value for display + // console.log(`Total spending for ${goal.category}: ${totalSpending}`); + return Math.abs(totalSpending); + } else if (goal.type === "save") { + // For savings goals, get all transactions for this category + const transactions = + await transactionsQueries.getProfileCategoryTransactionsQuery( + pool, + profileId, + goal.category!, + ); + + //sum all amounts (positive = savings, negative = expenses) + const total = transactions.reduce( + (sum, transaction) => sum + transaction.amount, + 0, + ); + + return total > 0 ? total : 0; + } + + return 0; +} + +export async function getGoalsByProfileId( + pool: Pool, + profileId: number, +): Promise { + const goals = await goalsQueries.getGoalsByProfileId(pool, profileId); + return goals; +} + +//enrich a goal with calculated progress +export async function enrichGoalWithProgress( + pool: Pool, + goal: Goal, + profileId: number, +): Promise { + const current_amount = await calculateCurrentAmount(pool, goal, profileId); + const progress_percentage = Math.min( + (current_amount / goal.target) * 100, + 100, + ); + + console.log( + "enriching goal with progress, cur amount:", + current_amount, + "progress percentage:", + progress_percentage, + " because target is:", + goal.target, + ); + + return { + ...goal, + current_amount, + progress_percentage, + }; +} + +export async function getUserGoals( + pool: Pool, + userId: number, +): Promise { + const profileId = await goalsQueries.getUserProfileId(pool, userId); + if (!profileId) return []; + + const goals = await goalsQueries.getGoalsByProfileId(pool, profileId); + + const goalsWithProgress = await Promise.all( + goals.map((goal) => enrichGoalWithProgress(pool, goal, profileId)), + ); + + return goalsWithProgress; +} + +//gets a single goal by id - this is used for patching goals +export async function getUserGoalById( + pool: Pool, + goalId: number, + userId: number, +): Promise { + const profileId = await goalsQueries.getUserProfileId(pool, userId); + if (!profileId) return null; + + const goal = await goalsQueries.getGoalById(pool, goalId, profileId); + if (!goal) return null; + + return enrichGoalWithProgress(pool, goal, profileId); +} + +export async function createUserGoal( + pool: Pool, + userId: number, + goalData: CreateGoalInput, +): Promise { + const profileId = await goalsQueries.getUserProfileId(pool, userId); + if (!profileId) { + throw new Error("User profile not found"); + } + + //validate goal count + const goalCount = await goalsQueries.getGoalCountByProfileId(pool, profileId); + if (goalCount >= MAX_GOALS_PER_PROFILE) { + throw new Error( + `Maximum ${MAX_GOALS_PER_PROFILE} goals allowed per profile`, + ); + } + + //validate input based on goal type + validateGoalInput(goalData); + + const newGoal = await goalsQueries.createGoal(pool, profileId, goalData); + if (!newGoal) { + throw new Error("Failed to create goal"); + } + + const enrichedGoal = await enrichGoalWithProgress(pool, newGoal, profileId); + if (!enrichedGoal) { + throw new Error("Failed to enrich goal with progress"); + } + + return enrichedGoal; +} + +export async function updateUserGoal( + pool: Pool, + goalId: number, + userId: number, + updates: UpdateGoalInput, +): Promise { + const profileId = await goalsQueries.getUserProfileId(pool, userId); + if (!profileId) { + throw new Error("User profile not found"); + } + + const existingGoal = await goalsQueries.getGoalById(pool, goalId, profileId); + if (!existingGoal) { + throw new Error("Goal not found"); + } + + //validate updates - maybe expand on this if needed + validateGoalUpdates(updates); + + const updated = await goalsQueries.updateGoal( + pool, + goalId, + profileId, + updates, + ); + if (!updated) { + throw new Error("Failed to update goal"); + } + + const updatedWithProgress = await enrichGoalWithProgress( + pool, + updated, + profileId, + ); + if (!updatedWithProgress) { + throw new Error("Failed to enrich goal with progress"); + } + + return updatedWithProgress; +} + +export async function deleteUserGoal( + pool: Pool, + goalId: number, + userId: number, +): Promise { + const profileId = await goalsQueries.getUserProfileId(pool, userId); + if (!profileId) { + throw new Error("User profile not found"); + } + + const deleted = await goalsQueries.deleteGoal(pool, goalId, profileId); + if (!deleted) { + throw new Error("Goal not found or already deleted"); + } + return true; +} + +//validation functions (same as before, but updated for new fields) +function validateGoalInput(input: CreateGoalInput): void { + if (!input.name || input.name.trim().length === 0) { + throw new Error("Goal name is required"); + } + + if (input.target <= 0) { + throw new Error("Target amount must be greater than 0"); + } + + if (input.type === "reduce_spending" && !input.category) { + throw new Error("Spending limit goals require a category"); + } +} + +function validateGoalUpdates(updates: UpdateGoalInput): void { + if (updates.target !== undefined && updates.target <= 0) { + throw new Error("Target amount must be greater than 0"); + } +} diff --git a/app/server/services/ts/user/src/logic/saving.ts b/app/server/services/ts/user/src/logic/saving.ts new file mode 100644 index 0000000..f34b67f --- /dev/null +++ b/app/server/services/ts/user/src/logic/saving.ts @@ -0,0 +1,47 @@ +import { addSavingAccount, findSavingsBy } from "../queries/saving.ts"; +import type { SavingInfoResponse } from "../types/SavingInfoResponse.ts"; +import type { FinancialAccountRequest } from "../types/FinancialAccountRequest.ts"; +import { findTransactionsBy } from "../queries/transactions.ts"; +import type { TransactionDto } from "../types/TransactionDto.ts"; +import { BadRequestError } from "../types/BadRequestError.ts"; +import { getConnectionPool } from "@/sqlUtil.ts"; +const db = getConnectionPool(); +export async function getSavingAccount( + userId: string, +): Promise { + return await findSavingsBy(db, userId); +} +export async function getSavingAccountTransactionBy( + financialAccountId: string | undefined, +): Promise { + if (!financialAccountId) + throw new BadRequestError("Missing financial account id"); + + const transactions = await findTransactionsBy(db, financialAccountId); + const transationDtos: TransactionDto[] = transactions.map((transation) => ({ + id: transation.id, + amount: transation.amount, + category: transation.category, + description: transation.description, + sender: transation.sender, + recipient: transation.recipient, + date: transation.date + ? new Date(transation.date) + .toISOString() + .replace("T", " ") + .replace(/\.\d{3}Z$/, "") + : "N/A", + })); + return transationDtos; +} +export async function createSavingAccount( + savingInfo: FinancialAccountRequest, + userId: string, +): Promise { + try { + return await addSavingAccount(db, savingInfo, userId); + } catch (err) { + console.error("Error creating a new saving account: ", err); + throw err; + } +} diff --git a/app/server/services/ts/user/src/queries/debt.ts b/app/server/services/ts/user/src/queries/debt.ts new file mode 100644 index 0000000..4075a9a --- /dev/null +++ b/app/server/services/ts/user/src/queries/debt.ts @@ -0,0 +1,86 @@ +import type { RowDataPacket } from "mysql2"; +import type { PoolConnection, ResultSetHeader, Pool } from "mysql2/promise"; +import type { DebtInfoResponse } from "../types/DebtInfoResponse.ts"; +import type { FinancialAccountRequest } from "../types/FinancialAccountRequest.ts"; +import type { FinancialAccountType } from "../types/FinancialAccountType.ts"; + +export async function findDebtsBy( + db: Pool, + userId: string, +): Promise { + const query = ` + SELECT fa.id, fa.name, fa.balance, fa.subtype, fa.last_updated + FROM finus.financialAccount fa + JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id + JOIN finus.profile p ON pfa.profile_id = p.id + JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id + WHERE uap.account_id = ? + AND fa.type = 'Credit Card' + AND fa.subtype = 'Loan' + GROUP BY fa.id + `; + + const [rows] = await db.execute(query, [userId]); + console.log(rows); + const result: DebtInfoResponse[] = rows.map((row) => ({ + id: Number(row.id), + name: String(row.name), + balance: Number(row.balance), + type: "Credit" as FinancialAccountType, + subtype: String(row.subtype), + lastUpdated: row.last_updated + ? new Date(row.last_updated).toISOString() + : "N/A", + })); + return result; +} +export async function addDebt( + db: Pool, + newDebt: FinancialAccountRequest, + userId: string, +): Promise { + const connection: PoolConnection = await db.getConnection(); + await connection.beginTransaction(); + + try { + const query = `INSERT INTO finus.financialAccount (name, type, balance, value, subtype) VALUES (?, ?, ?, ?, ?)`; + const params = [ + newDebt.name, + newDebt.type, + newDebt.balance, + newDebt.value, + newDebt.subtype, + ]; + + const [result] = await connection.execute(query, params); + const financialAccountId = result.insertId; + + const profileQuery = `SELECT * from profile p JOIN finusAccount_profile fp on p.id = fp.profile_id where fp.account_id = ?`; + const [profiles] = await connection.execute(profileQuery, [ + userId, + ]); + + if (profiles.length > 0) { + const values = profiles.map((p) => [p.id, financialAccountId]); + const linkQuery = `INSERT INTO finus.profile_financialAccount (profile_id, financialAccount_id) VALUES ?`; + await connection.query(linkQuery, [values]); + } + + await connection.commit(); + + console.log("From db", result); + return { + id: Number(financialAccountId), + name: String(newDebt.name), + balance: Number(newDebt.balance), + subtype: String(newDebt.subtype), + lastUpdated: new Date().toISOString().replace("T", " ") ?? "N/A", + } as DebtInfoResponse; + } catch (err) { + // Rollback if anything fails + await connection.rollback(); + throw err; + } finally { + connection.release(); + } +} diff --git a/app/server/services/ts/user/src/queries/goals.ts b/app/server/services/ts/user/src/queries/goals.ts new file mode 100644 index 0000000..03f4c24 --- /dev/null +++ b/app/server/services/ts/user/src/queries/goals.ts @@ -0,0 +1,201 @@ +import { Pool, RowDataPacket, type ResultSetHeader } from "mysql2/promise"; +import type { Goal, CreateGoalInput, UpdateGoalInput } from "../types/Goals.ts"; + +interface GoalRow extends RowDataPacket, Goal {} + +//helper function to get all user profiles based on user account id +export async function getUserProfileId( + pool: Pool, + userId: number, +): Promise { + const [rows] = await pool.query( + `SELECT profile_id + FROM finus.finusAccount_profile + WHERE account_id = ?`, + [userId], + ); + + // console.log('Tried to find user profile id based on user id, found: ', rows); + return rows[0]?.profile_id || null; //getting the first profile for now, needs to be rewritten if multiple profiles are implemented (stretch) +} + +// Get goals for a specific profile +export async function getGoalsByProfileId( + pool: Pool, + profileId: number, +): Promise { + const [rows] = await pool.query( + `SELECT g.* + FROM finus.goal g + JOIN finus.profile_goal pg ON g.id = pg.goal_id + WHERE pg.profile_id = ? + ORDER BY g.id DESC`, + [profileId], + ); + return rows; +} + +//get a single goal by ID (with profile check) +export async function getGoalById( + pool: Pool, + goalId: number, + profileId: number, +): Promise { + const [rows] = await pool.query( + `SELECT g.* + FROM finus.goal g + JOIN finus.profile_goal pg ON g.id = pg.goal_id + WHERE g.id = ? AND pg.profile_id = ?`, + [goalId, profileId], + ); + return rows[0] || null; +} + +//gets a count of goals for a profile +export async function getGoalCountByProfileId( + pool: Pool, + userId: number, +): Promise { + const profileId = await getUserProfileId(pool, userId); + const [rows] = await pool.query( + `SELECT COUNT(*) as count + FROM finus.goal g + JOIN finus.profile_goal pg ON g.id = pg.goal_id + WHERE pg.profile_id = ?`, + [profileId], + ); + if (!rows[0]) return 0; + return rows[0].count; +} + +//creates a new goal and associate with profile +export async function createGoal( + pool: Pool, + profileId: number, + goalData: CreateGoalInput, +): Promise { + const connection = await pool.getConnection(); + const goalType = goalData.type; + let period = goalData.period; + if (goalType === "save") { + period = "m"; + } + try { + await connection.beginTransaction(); + const [result] = await connection.query( + `INSERT INTO finus.goal (name, type, category, target, period) + VALUES (?, ?, ?, ?, ?)`, + [ + goalData.name, + goalData.type, + goalData.category?.toLowerCase() || "unknown", + goalData.target, + period, + ], + ); + + const goalId = (result as ResultSetHeader).insertId; // result.insertId; + + //association with profile + await connection.query( + `INSERT INTO finus.profile_goal (profile_id, goal_id) + VALUES (?, ?)`, + [profileId, goalId], + ); + + await connection.commit(); + + //fetch and return the created goal for confirmation + const [newGoal] = await connection.query( + `SELECT * FROM finus.goal WHERE id = ?`, + [goalId], + ); + + return newGoal[0]; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} + +//update an existing goal - this is the patch path +export async function updateGoal( + pool: Pool, + goalId: number, + profileId: number, + updates: UpdateGoalInput, +): Promise { + //verify goal belongs to profile + const existing = await getGoalById(pool, goalId, profileId); + if (!existing) return undefined; + + //dynamic update query + const allowedFields = ["name", "category", "target", "period", "type"]; + const setClauses: string[] = []; + const values: unknown[] = []; + + //build update query dunamically since some fields may not be provided + for (const field of allowedFields) { + if (updates[field as keyof UpdateGoalInput] !== undefined) { + setClauses.push(`${field} = ?`); + values.push(updates[field as keyof UpdateGoalInput]); + } + } + + if (setClauses.length === 0) { + return existing; + } + + values.push(goalId); + + await pool.query( + `UPDATE finus.goal SET ${setClauses.join(", ")} WHERE id = ?`, + values, + ); + + //fetches updated goal + const [updated] = await pool.query( + `SELECT * FROM finus.goal WHERE id = ?`, + [goalId], + ); + + return updated[0]; +} + +//deletes a goal (remove from profile_goal association) +export async function deleteGoal( + pool: Pool, + goalId: number, + profileId: number, +): Promise { + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + //remove association first + const [result] = await connection.query( + `DELETE FROM finus.profile_goal + WHERE goal_id = ? AND profile_id = ?`, + [goalId, profileId], + ); + + if ((result as ResultSetHeader).affectedRows === 0) { + await connection.rollback(); + return false; + } + + //delete the goal itself + await connection.query(`DELETE FROM finus.goal WHERE id = ?`, [goalId]); + + await connection.commit(); + return true; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} diff --git a/app/server/services/ts/user/src/queries/saving.ts b/app/server/services/ts/user/src/queries/saving.ts new file mode 100644 index 0000000..38b4e74 --- /dev/null +++ b/app/server/services/ts/user/src/queries/saving.ts @@ -0,0 +1,84 @@ +// import { getConnectionPool } from "@/sqlUtil.ts"; +import type { RowDataPacket } from "mysql2"; +import type { PoolConnection, ResultSetHeader, Pool } from "mysql2/promise"; +import type { SavingInfoResponse } from "../types/SavingInfoResponse.ts"; +import type { FinancialAccountRequest } from "../types/FinancialAccountRequest.ts"; +// import type { FinancialAccountType } from "../types/FinancialAccountType.ts"; +import type { SavingAccountType } from "../types/SavingAccountType.ts"; +// const db = getConnectionPool(); + +export async function findSavingsBy( + db: Pool, + userId: string, +): Promise { + const query = ` + SELECT fa.id, fa.name, fa.balance, fa.subtype, fa.last_updated + FROM finus.financialAccount fa + JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id + JOIN finus.profile p ON pfa.profile_id = p.id + JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id + WHERE uap.account_id = ? + AND fa.type = 'Savings' + GROUP BY fa.id + `; + + const [rows] = await db.execute(query, [userId]); + console.log(rows); + const result: SavingInfoResponse[] = rows.map((row) => ({ + id: Number(row.id), + name: String(row.name), + balance: Number(row.balance), + type: "Savings" as const, + subtype: String(row.subtype) as SavingAccountType, + lastUpdated: row.last_updated + ? new Date(row.last_updated).toISOString() + : "N/A", + })); + return result; +} + +export async function addSavingAccount( + db: Pool, + newSavings: FinancialAccountRequest, + userId: string, +): Promise { + const connection: PoolConnection = await db.getConnection(); + await connection.beginTransaction(); + + try { + const { name, type, balance, value, subtype } = newSavings; + const query = `INSERT INTO finus.financialAccount (name, type, balance, value, subtype) VALUES (?, ?, ?, ?, ?)`; + const params = [name, type, balance, value, subtype]; + + const [result] = await connection.execute(query, params); + const financialAccountId = result.insertId; + + const profileQuery = `SELECT * from profile p JOIN finusAccount_profile fp on p.id = fp.profile_id where fp.account_id = ?`; + const [profiles] = await connection.execute(profileQuery, [ + userId, + ]); + + if (profiles.length > 0) { + const values = profiles.map((p) => [p.id, financialAccountId]); + const linkQuery = `INSERT INTO finus.profile_financialAccount (profile_id, financialAccount_id) VALUES ?`; + await connection.query(linkQuery, [values]); + } + + await connection.commit(); + + console.log("From db", result); + return { + id: Number(financialAccountId), + name: String(newSavings.name), + balance: Number(newSavings.balance), + subtype: String(newSavings.subtype), + lastUpdated: new Date().toISOString().replace("T", " ") ?? "N/A", + } as SavingInfoResponse; + } catch (err) { + // Rollback if anything fails + await connection.rollback(); + throw err; + } finally { + connection.release(); + } +} diff --git a/app/server/services/ts/user/src/queries/transactions.ts b/app/server/services/ts/user/src/queries/transactions.ts index 96dbb46..88e8243 100644 --- a/app/server/services/ts/user/src/queries/transactions.ts +++ b/app/server/services/ts/user/src/queries/transactions.ts @@ -20,3 +20,110 @@ export async function getAllTransactionsQuery( const [rows] = await pool.query(query, [userId]); return rows; } + +export async function findTransactionsBy( + db: Pool, + financialAccountId: string, +): Promise { + const query = ` + SELECT * FROM finus.transaction t + WHERE t.financialAccount_id = ? + ORDER BY t.date DESC + `; + + const [rows] = await db.execute(query, [financialAccountId]); + return rows; +} + +export async function getDateCategoryTransactionsQuery( + pool: Pool, + profileId: number, + category: string, + from: Date, + to: Date, +): Promise { + //convert dates to MySQL format + const fromStr = from.toISOString().slice(0, 19).replace("T", " "); + const toStr = to.toISOString().slice(0, 19).replace("T", " "); + + const query = ` + SELECT t.* + FROM finus.transaction t + JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id + JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id + WHERE pfa.profile_id = ? + AND t.category = ? + AND t.date >= ? + AND t.date <= ? + ORDER BY t.date DESC + `; + + const [rows] = await pool.query(query, [ + profileId, + category, + fromStr, + toStr, + ]); + return rows; +} + +export async function getProfileCategoryTransactionsQuery( + pool: Pool, + profileId: number, + category: string, +): Promise { + const query = ` + SELECT t.* + FROM finus.transaction t + JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id + JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id + WHERE pfa.profile_id = ? + AND t.category = ? + ORDER BY t.date DESC + `; + + const [rows] = await pool.query(query, [profileId, category]); + return rows; +} + +//returns all savings and expenses for a specific transaciton category +export async function getProfileCategorySavingsQuery( + pool: Pool, + profileId: number, + category: string, +): Promise { + const query = ` + SELECT t.* + FROM finus.transaction t + JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id + JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id + WHERE pfa.profile_id = ? + AND t.category = ? + AND t.amount > 0 + ORDER BY t.date DESC + `; + + const [rows] = await pool.query(query, [profileId, category]); + return rows; +} + +//get only expenses (negative amounts) for a category +export async function getProfileCategoryExpensesQuery( + pool: Pool, + profileId: number, + category: string, +): Promise { + const query = ` + SELECT t.* + FROM finus.transaction t + JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id + JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id + WHERE pfa.profile_id = ? + AND t.category = ? + AND t.amount < 0 + ORDER BY t.date DESC + `; + + const [rows] = await pool.query(query, [profileId, category]); + return rows; +} diff --git a/app/server/services/ts/user/src/routes/account.ts b/app/server/services/ts/user/src/routes/account.ts index 8b42f80..3a2ed8e 100644 --- a/app/server/services/ts/user/src/routes/account.ts +++ b/app/server/services/ts/user/src/routes/account.ts @@ -3,23 +3,44 @@ //TODO import db connection import { Router } from "express"; import type { Request, Response } from "express"; -import type { ResultSetHeader } from "mysql2"; -import { getConnectionPool } from "@/sqlUtil"; +import type { + ResultSetHeader, + PoolConnection, + RowDataPacket, +} from "mysql2/promise"; import type { financialAccount } from "@/types.js"; import { authenticateJWT } from "../handleJWT.js"; +import { checkUserId } from "../CheckUser.ts"; +import { pool } from "../db.ts"; //import { authenticateJWT } from "../handleJWT.js"; //import { error } from "node:console"; export const accountsRouter = Router(); -const db = getConnectionPool(); - accountsRouter.post("/", async (req: Request, res: Response) => { + let connection: PoolConnection | undefined; try { + connection = await pool.getConnection(); const { name, type, balance, value, subtype } = req.body; const last_updated = new Date(); + if (!name || name.trim() === "") { + return res.status(400).json({ error: "Name is required" }); + } + + if (!type || typeof type !== "string") { + return res.status(400).json({ error: "Type is required" }); + } + + if (balance === undefined || balance === null || isNaN(balance)) { + return res.status(400).json({ error: "Balance is required" }); + } + + if (value === undefined || value === null || isNaN(value)) { + return res.status(400).json({ error: "Value is required" }); + } + let userId; try { @@ -31,16 +52,28 @@ accountsRouter.post("/", async (req: Request, res: Response) => { .json({ error: "Not authorized to create an account" }); } - const [result] = await db.query( + //need the user account's profile id first. This should be just a single item returned unless stretch feature 7 is implemented + const [profileRows] = await connection.query( + `SELECT profile_id FROM finusAccount_profile WHERE account_id = ?`, + [userId], + ); + + if (!profileRows || (profileRows as RowDataPacket[]).length === 0) { + return res.status(404).json({ error: "User profile not found" }); + } + + const profileId = (profileRows as RowDataPacket[])[0].profile_id; + + const [result] = await connection.query( `INSERT INTO financialAccount (name, type, balance, value, last_updated, subtype) VALUES (?, ?, ?, ?, ?, ?)`, [name, type, balance, value, last_updated, subtype ?? null], ); - await db.query( + await connection.query( `INSERT INTO profile_financialAccount (profile_id, financialAccount_id) - VALUES (?,?)`, - [userId, result.insertId], + VALUES (?, ?)`, + [profileId, result.insertId], ); console.log("Created account " + name); @@ -52,12 +85,16 @@ accountsRouter.post("/", async (req: Request, res: Response) => { } catch (err) { console.error("Account creation failed", err); return res.status(500).json({ error: "Account creation failed" }); + } finally { + if (connection) { + connection.release(); + } } }); accountsRouter.get("/", async (req: Request, res: Response) => { let userId; - + let connection: PoolConnection | undefined; try { userId = authenticateJWT(req); } catch (err) { @@ -67,11 +104,25 @@ accountsRouter.get("/", async (req: Request, res: Response) => { if (userId) { try { - const [rows] = await db.query( + connection = await pool.getConnection(); + + //need the user account's profile id first. This should be just a single item returned unless stretch feature 7 is implemented + const [profileRows] = await connection.query( + `SELECT profile_id FROM finusAccount_profile WHERE account_id = ?`, + [userId], + ); + + if (!profileRows || (profileRows as RowDataPacket[]).length === 0) { + return res.status(404).json({ error: "User profile not found" }); + } + + const profileId = (profileRows as RowDataPacket[])[0].profile_id; + + const [rows] = await connection.query( `SELECT * FROM financialAccount JOIN profile_financialAccount pfa ON financialAccount.id = pfa.financialAccount_id WHERE pfa.profile_id = ?`, - [userId], + [profileId], ); console.log(rows); @@ -80,12 +131,17 @@ accountsRouter.get("/", async (req: Request, res: Response) => { } catch (err) { console.error("Failed to retrieve user's account", err); return res.status(500).json({ error: "Failed to retrieve account(s)" }); + } finally { + if (connection) { + connection.release(); + } } } }); accountsRouter.put("/", async (req: Request, res: Response) => { let userId; + let connection: PoolConnection | undefined; //Checks if was given by the requests if (isAccount(req.body)) { @@ -105,20 +161,22 @@ accountsRouter.put("/", async (req: Request, res: Response) => { .json({ error: "User not authorized to update account" }); } - //Check if the user is the owner of the account that's bineg updated - if (!(await checkUserId(userId, account.id))) { - console.error("User is not own of the account"); - return res - .status(401) - .json({ error: "User is not authorized to update accounts" }); - } - try { + connection = await pool.getConnection(); + + //Check if the user is the owner of the account that's bineg updated + if (!(await checkUserId(connection, userId, account.id))) { + console.error("User is not own of the account"); + return res + .status(401) + .json({ error: "User is not authorized to update accounts" }); + } + const { id, name, type, balance, value, subtype } = req.body; const last_updated = new Date(); - await db.query( + await connection.query( `UPDATE financialAccount SET name = ?, type = ?, balance = ?, value = ?, last_updated = ?, subtype = ? WHERE id= ?`, @@ -133,12 +191,16 @@ accountsRouter.put("/", async (req: Request, res: Response) => { } catch (err) { console.error("Failed to update user's account", err); return res.status(500).json({ error: "Failed to update user's account" }); + } finally { + if (connection) { + connection.release(); + } } }); accountsRouter.delete("/", async (req: Request, res: Response) => { let userId; - + let connection: PoolConnection | undefined; const { id } = req.body; try { @@ -158,52 +220,38 @@ accountsRouter.delete("/", async (req: Request, res: Response) => { .json({ error: "Bad request: No account was givens" }); } - //Checks if user is owner of the acount - if (!(await checkUserId(userId, id))) { - console.error("User cannot delete account they didn't create"); - return res - .status(401) - .json({ error: "User not authorized to delete this account" }); - } - try { - await db.query( + connection = await pool.getConnection(); + + //Checks if user is owner of the acount + if (!(await checkUserId(connection, userId, id))) { + console.error("User cannot delete account they didn't create"); + return res + .status(401) + .json({ error: "User not authorized to delete this account" }); + } + + await connection.query( `DELETE FROM financialAccount WHERE id=?`, [id], ); + await connection.query( + "DELETE FROM transaction WHERE financialAccount_id=?", + [id], + ); + return res.status(200).json({ message: "Account successfully deleted" }); } catch (err) { console.error("Failed to delete user's account", err); return res.status(500).json({ error: "Failed to delete user's account" }); - } -}); - -//Checks the user is the owner of the account -async function checkUserId( - userId: number, - accountId: number, -): Promise { - let result = false; - try { - //Checks if the profile has an account with that id - const [rows] = await db.query( - `SELECT 1 FROM profile_financialAccount - WHERE profile_id =? AND financialAccount_id =?`, - [userId, accountId], - ); - - console.log(rows.length); - if (rows.length > 0) { - result = true; + } finally { + if (connection) { + connection.release(); } - } catch (error) { - console.error(error); } - - return result; -} +}); function isAccount(reqBody: unknown): reqBody is financialAccount { //Checks if it exists and is an object diff --git a/app/server/services/ts/user/src/routes/debt.ts b/app/server/services/ts/user/src/routes/debt.ts new file mode 100644 index 0000000..5fe47c1 --- /dev/null +++ b/app/server/services/ts/user/src/routes/debt.ts @@ -0,0 +1,46 @@ +import { Router } from "express"; +import type { Request, Response } from "express"; +// import type { ResultSetHeader } from "mysql2"; +// import { pool } from "../db.ts"; +// import type { financialAccount } from "@/types.js"; +import { authenticateJWT } from "../handleJWT.js"; +import { UnauthorizedAccessError } from "../types/UnauthorizedAccess.ts"; +import { createNewDebt, getDebts } from "../logic/debt.ts"; +// import { BadRequestError } from "../types/BadRequestError.ts"; +export const debtRouter = Router(); + +debtRouter.get("/", async (req: Request, res: Response) => { + try { + const userId = authenticateJWT(req); + getDebts(userId).then((debts) => { + res.status(200).json(debts); + }); + } catch (err: unknown) { + if (err instanceof UnauthorizedAccessError) { + console.error("User is not authorized to create debt", err); + return res + .status(401) + .json({ error: "User is not authorized to create debt" }); + } else { + console.error("Debt access failed", err); + res.status(500).json({ error: "Debt access failed" }); + } + } +}); +debtRouter.post("/", async (req: Request, res: Response) => { + try { + const userId = authenticateJWT(req); + const newDebt = await createNewDebt(req.body, userId); + res.status(201).json(newDebt); + } catch (err: unknown) { + if (err instanceof UnauthorizedAccessError) { + console.error("User is not authorized to create debt", err); + return res + .status(401) + .json({ error: "User is not authorized to create debt" }); + } else { + console.error("Debt creation failed", err); + res.status(500).json({ error: "Debt creation failed" }); + } + } +}); diff --git a/app/server/services/ts/user/src/routes/saving.ts b/app/server/services/ts/user/src/routes/saving.ts new file mode 100644 index 0000000..b267676 --- /dev/null +++ b/app/server/services/ts/user/src/routes/saving.ts @@ -0,0 +1,158 @@ +import { Router } from "express"; +import type { Request, Response } from "express"; +import { authenticateJWT } from "../handleJWT.js"; +import { UnauthorizedAccessError } from "../types/UnauthorizedAccess.ts"; +import { + createSavingAccount, + getSavingAccount, + getSavingAccountTransactionBy, +} from "../logic/saving.ts"; +import { BadRequestError } from "../types/BadRequestError.ts"; +export const savingRouter = Router(); + +savingRouter.get("/", async (req: Request, res: Response) => { + try { + const userId = authenticateJWT(req); + const savings = await getSavingAccount(userId); + res.status(200).json(savings); + } catch (err: unknown) { + if (err instanceof UnauthorizedAccessError) { + console.error("User is not authorized to access saving", err); + return res + .status(401) + .json({ error: "User is not authorized to access saving" }); + } else { + console.error("Saving access failed", err); + res.status(500).json({ error: "Saving access failed" }); + } + } +}); +savingRouter.get( + "/:financialAccountId/transactions", + async (req: Request, res: Response) => { + try { + // const userId = authenticateJWT(req); + const financialAccountId = req.params.financialAccountId?.toString(); + console.log("Financial account id: ", financialAccountId); + const transactions = + await getSavingAccountTransactionBy(financialAccountId); + res.status(200).json(transactions); + } catch (err: unknown) { + if (err instanceof BadRequestError) { + return res.status(400).json({ error: err.message }); + } else if (err instanceof UnauthorizedAccessError) { + console.error("User is not authorized to access saving", err); + return res + .status(401) + .json({ error: "User is not authorized to access saving" }); + } else { + console.error("Saving access failed", err); + res.status(500).json({ error: "Saving access failed" }); + } + } + }, +); +savingRouter.post("/", async (req: Request, res: Response) => { + try { + const userId = authenticateJWT(req); + const savingAccount = await createSavingAccount(req.body, userId); + res.status(201).json({ data: savingAccount }); + } catch (err: unknown) { + if (err instanceof UnauthorizedAccessError) { + console.error("User is not authorized to create saving", err); + return res + .status(401) + .json({ error: "User is not authorized to create saving" }); + } else { + console.error("Saving creation failed", err); + res.status(500).json({ error: "Saving creation failed" }); + } + } +}); +savingRouter.post("/projected", async (req: Request, res: Response) => { + try { + // const userId = authenticateJWT(req); + console.log(req.body); + res.status(200).json({ data: "Projected savings calculated successfully" }); + } catch (err: unknown) { + if (err instanceof UnauthorizedAccessError) { + console.error("User is not authorized to create saving", err); + return res + .status(401) + .json({ error: "User is not authorized to create saving" }); + } else { + console.error("Saving creation failed", err); + res.status(500).json({ error: "Saving creation failed" }); + } + } +}); + +// savingRouter.get("/", async (req: Request, res: Response) => { +// try { +// const userId = authenticateJWT(req); +// const savings = await getSavingAccount(userId) +// res.status(200).json(savings) +// } catch (err: any) { +// switch (err.constructor) { +// case UnauthorizedAccessError: +// console.error("User is not authorized to access saving", err); +// return res.status(401).json({ error: "User is not authorized to access saving" }); +// default: +// console.error("Saving access failed", err); +// res.status(500).json({ error: "Saving access failed" }); +// } +// } +// }); + +// savingRouter.get("/:financialAccountId/transactions", async (req: Request, res: Response) => { +// try { +// // const userId = authenticateJWT(req); +// const financialAccountId = req.params.financialAccountId?.toString() +// console.log("Financial account id: ", financialAccountId) +// const transactions = await getSavingAccountTransactionBy(financialAccountId) +// res.status(200).json(transactions) +// } catch (err: any) { +// switch (err.constructor) { +// case BadRequestError: +// return res.status(400).json({error: err.message}) +// case UnauthorizedAccessError: +// console.error("User is not authorized to access saving", err); +// return res.status(401).json({ error: "User is not authorized to access saving" }); +// default: +// console.error("Saving access failed", err); +// res.status(500).json({ error: "Saving access failed" }); +// } +// } +// }); +// savingRouter.post("/", async (req: Request, res: Response) => { +// try { +// const userId = authenticateJWT(req); +// const savingAccount = await createSavingAccount(req.body, userId); +// res.status(201).json({ data: savingAccount }); +// } catch (err: any) { +// switch (err.constructor) { +// case UnauthorizedAccessError: +// console.error("User is not authorized to create saving", err); +// return res.status(401).json({ error: "User is not authorized to create saving" }); +// default: +// console.error("Saving creation failed", err); +// res.status(500).json({ error: "Saving creation failed" }); +// } +// } +// }); +// savingRouter.post("/projected", async (req: Request, res: Response) => { +// try { +// // const userId = authenticateJWT(req); +// console.log(req.body) +// res.status(200).json({ data: "Projected savings calculated successfully" }); +// } catch (err: any) { +// switch (err.constructor) { +// case UnauthorizedAccessError: +// console.error("User is not authorized to create saving", err); +// return res.status(401).json({ error: "User is not authorized to create saving" }); +// default: +// console.error("Saving creation failed", err); +// res.status(500).json({ error: "Saving creation failed" }); +// } +// } +// }); diff --git a/app/server/services/ts/user/src/routes/transaction.ts b/app/server/services/ts/user/src/routes/transaction.ts index 05bc8a3..3318ee6 100644 --- a/app/server/services/ts/user/src/routes/transaction.ts +++ b/app/server/services/ts/user/src/routes/transaction.ts @@ -1,19 +1,22 @@ // transaction routes for creating and updating transactions import { Router } from "express"; import type { Request, Response } from "express"; -import type { ResultSetHeader } from "mysql2"; -import { getConnectionPool } from "@/sqlUtil"; +import type { PoolConnection, ResultSetHeader } from "mysql2/promise"; import { authenticateJWT } from "../handleJWT.js"; import type { Transaction } from "@/types.js"; +import { checkUserId } from "../CheckUser.ts"; +import { convertToDateTime } from "../DataConversion.ts"; +import { pool as connection, pool } from "../db.ts"; export const transactionsRouter = Router(); -const db = getConnectionPool(); // Get all transactions for a specific financial account transactionsRouter.get("/", async (req: Request, res: Response) => { let userId; + let connection: PoolConnection | undefined; try { + connection = await pool.getConnection(); const financialAccount_id = Number(req.query.financialAccount_id); if (!financialAccount_id) { @@ -29,7 +32,7 @@ transactionsRouter.get("/", async (req: Request, res: Response) => { .json({ error: "User not authorized to create a transaction" }); } - if (!(await checkUserId(userId, financialAccount_id))) { + if (!(await checkUserId(connection, userId, financialAccount_id))) { console.error( "User is not authorized to create a transaction on this account", ); @@ -38,7 +41,7 @@ transactionsRouter.get("/", async (req: Request, res: Response) => { }); } - const [rows] = await db.query( + const [rows] = await connection.query( `SELECT * FROM transaction WHERE financialAccount_id = ?`, [financialAccount_id], ); @@ -54,7 +57,9 @@ transactionsRouter.get("/", async (req: Request, res: Response) => { transactionsRouter.post("/", async (req: Request, res: Response) => { let userId; + let connection: PoolConnection | undefined; try { + connection = await pool.getConnection(); const { financialAccount_id, amount, @@ -76,7 +81,7 @@ transactionsRouter.post("/", async (req: Request, res: Response) => { .json({ error: "User not authorized to create a transaction" }); } - if (!(await checkUserId(userId, financialAccount_id))) { + if (!(await checkUserId(connection, userId, financialAccount_id))) { console.error( "User is not authorized to create a transaction on this account", ); @@ -85,7 +90,7 @@ transactionsRouter.post("/", async (req: Request, res: Response) => { }); } - const [result] = await db.query( + const [result] = await connection.query( `INSERT INTO transaction (financialAccount_id, amount, description, sender, recipient, date, category ) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ @@ -106,14 +111,20 @@ transactionsRouter.post("/", async (req: Request, res: Response) => { } catch (err) { console.error("Transaction creation failed", err); res.status(500).json({ error: "Transaction creation failed" }); + } finally { + if (connection) { + connection.release(); + } } }); // Update an existing transaction transactionsRouter.put("/", async (req: Request, res: Response) => { let userId; + let connection: PoolConnection | undefined; try { + connection = await pool.getConnection(); const { id, financialAccount_id, @@ -135,7 +146,7 @@ transactionsRouter.put("/", async (req: Request, res: Response) => { .json({ error: "User not authorized to create a transaction" }); } - if (!(await checkUserId(userId, financialAccount_id))) { + if (!(await checkUserId(connection, userId, financialAccount_id))) { console.error( "User is not authorized to create a transaction on this account", ); @@ -144,7 +155,7 @@ transactionsRouter.put("/", async (req: Request, res: Response) => { }); } - await db.query( + await connection.query( `UPDATE transaction SET amount=?, description=?, sender=?, recipient=?, date=? WHERE id=?`, @@ -161,6 +172,10 @@ transactionsRouter.put("/", async (req: Request, res: Response) => { res.json({ message: "Transaction successfully updated" }); } catch (err) { console.error("Transaction update failed", err); + } finally { + if (connection) { + connection.release(); + } } }); @@ -184,14 +199,14 @@ transactionsRouter.delete("/", async (req: Request, res: Response) => { .json({ error: "User not authorized to delete transaction" }); } - if (!(await checkUserId(userId, financialAccount_id))) { + if (!(await checkUserId(connection, userId, financialAccount_id))) { console.error("User is not authorized to delete this transaction"); return res .status(401) .json({ error: "User not authorized to delete this transaction" }); } - await db.query(`DELETE FROM transaction WHERE id=?`, [id]); + await connection.query(`DELETE FROM transaction WHERE id=?`, [id]); res.status(200).json({ message: "Transaction deleted" }); } catch (err) { @@ -205,8 +220,9 @@ transactionsRouter.post( "/csvTransaction", async (req: Request, res: Response) => { let userId; - + let connection: PoolConnection | undefined; try { + connection = await pool.getConnection(); const { financialAccount_id, transactions } = req.body; if (!financialAccount_id || !Array.isArray(transactions)) { @@ -224,7 +240,7 @@ transactionsRouter.post( } // Check account ownership - if (!(await checkUserId(userId, financialAccount_id))) { + if (!(await checkUserId(connection, userId, financialAccount_id))) { console.error("User is not authorized to import into this account"); return res.status(401).json({ error: "User not authorized to import into this account", @@ -269,8 +285,6 @@ transactionsRouter.post( .json({ error: "No valid transactions to import" }); } - const connection = await db.getConnection(); - try { await connection.beginTransaction(); @@ -324,35 +338,37 @@ transactionsRouter.post( } catch (err) { console.error("CSV transaction import failed", err); res.status(500).json({ error: "CSV transaction import failed" }); + } finally { + if (connection) { + connection.release(); + } } }, ); //Checks the user is the owner of the account -async function checkUserId( - userId: number, - accountId: number, -): Promise { - let result = false; - try { - //Checks if the profile has an account with that id - const [rows] = await db.query( - `SELECT 1 FROM profile_financialAccount - WHERE profile_id =? AND financialAccount_id =?`, - [userId, accountId], - ); - - console.log(rows.length); - if (rows.length > 0) { - result = true; - } - } catch (error) { - console.error(error); - } - - return result; -} - -function convertToDateTime(date: string) { - return date.slice(0, 19).replace("T", " "); -} +// async function checkUserId( +// userId: number, +// accountId: number, +// ): Promise { +// let result = false; +// try { +// //Checks if the profile has an account with that id +// const [rows] = await db.query( +// `SELECT * FROM profile_financialAccount +// WHERE profile_id =? AND financialAccount_id =?`, +// [userId, accountId], +// ); + +// console.log(rows.length); +// result = rows.length > 0; +// } catch (error) { +// console.error(error); +// } + +// return result; +// } + +// function convertToDateTime(date: string) { +// return date.slice(0, 19).replace("T", " "); +// } diff --git a/app/server/services/ts/user/src/types/BadRequestError.ts b/app/server/services/ts/user/src/types/BadRequestError.ts new file mode 100644 index 0000000..c0521d3 --- /dev/null +++ b/app/server/services/ts/user/src/types/BadRequestError.ts @@ -0,0 +1,7 @@ +export class BadRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "UnauthorizedAccessError"; + Object.setPrototypeOf(this, BadRequestError.prototype); + } +} diff --git a/app/server/services/ts/user/src/types/DebtInfoResponse.ts b/app/server/services/ts/user/src/types/DebtInfoResponse.ts new file mode 100644 index 0000000..9b4c3ae --- /dev/null +++ b/app/server/services/ts/user/src/types/DebtInfoResponse.ts @@ -0,0 +1,9 @@ +// import type { FinancialAccountType } from "./FinancialAccountType.ts"; +export interface DebtInfoResponse { + id: number; + balance: number; + name: string; + type: "Credit"; + subtype: string; + lastUpdated?: string; +} diff --git a/app/server/services/ts/user/src/types/ExpectedDebtDueDate.ts b/app/server/services/ts/user/src/types/ExpectedDebtDueDate.ts new file mode 100644 index 0000000..0f3c01e --- /dev/null +++ b/app/server/services/ts/user/src/types/ExpectedDebtDueDate.ts @@ -0,0 +1,8 @@ +export interface ExpectedDebtDueDate { + id: number; + category: string; + minimumPayment: number; + remainingAmount: number; + nextDueDate: string; + period: number; // in days +} diff --git a/app/server/services/ts/user/src/types/ExpectedDebtDueDateResponse.ts b/app/server/services/ts/user/src/types/ExpectedDebtDueDateResponse.ts new file mode 100644 index 0000000..594b190 --- /dev/null +++ b/app/server/services/ts/user/src/types/ExpectedDebtDueDateResponse.ts @@ -0,0 +1,7 @@ +export interface ExpectedDebtDueDateResponse { + id: number; + category: string; + minimumPayment: number; + amountPerInstallment: number[]; + installmentDates: string[]; // Array of expected due dates in ISO format +} diff --git a/app/server/services/ts/user/src/types/FinancialAccountRequest.ts b/app/server/services/ts/user/src/types/FinancialAccountRequest.ts new file mode 100644 index 0000000..f04763c --- /dev/null +++ b/app/server/services/ts/user/src/types/FinancialAccountRequest.ts @@ -0,0 +1,20 @@ +import type { FinancialAccountType } from "./FinancialAccountType.ts"; +import type { SavingAccountType } from "./SavingAccountType.ts"; + +export interface FinancialAccountRequest { + /*amount: number; + dueDate: string; + category: string; + interestRate?: number; + installment?: { + totalInstallments: number; + period: number; //in days + initialDate: string; + minimumPayment?: number; + };*/ + name: string; + type: FinancialAccountType; + balance: number; + value: number; + subtype: SavingAccountType | "Loan" | "n/a"; +} diff --git a/app/server/services/ts/user/src/types/FinancialAccountType.ts b/app/server/services/ts/user/src/types/FinancialAccountType.ts new file mode 100644 index 0000000..010242c --- /dev/null +++ b/app/server/services/ts/user/src/types/FinancialAccountType.ts @@ -0,0 +1,14 @@ +// export enum FinancialAccountType { +// SAVINGS = 'Savings', +// CREDIT = 'Credit Card', +// INVESTMENT = 'Investment', +// CHEQUING = 'Chequing', +// UNCONFIRMED = 'Unconfirmed', +// } + +export type FinancialAccountType = + | "Savings" + | "Credit" + | "Investment" + | "Chequing" + | "Unconfirmed"; diff --git a/app/server/services/ts/user/src/types/Goals.ts b/app/server/services/ts/user/src/types/Goals.ts new file mode 100644 index 0000000..96c7508 --- /dev/null +++ b/app/server/services/ts/user/src/types/Goals.ts @@ -0,0 +1,34 @@ +export type GoalType = "save" | "reduce_spending"; +export type GoalPeriod = "m" | "w"; // | "na"; + +export interface Goal { + id: number; + name: string; + type: GoalType; + category?: string; + target: number; + period: GoalPeriod; + profile_id: number; //maybe add the current amount here too, it's just not stored in the db + // current_amount: number; +} + +export interface GoalWithProgress extends Goal { + current_amount: number; + progress_percentage: number; +} + +export interface CreateGoalInput { + name: string; + type: GoalType; + category?: string; + target: number; + period: GoalPeriod; +} + +export interface UpdateGoalInput { + name?: string; + category?: string; + target?: number; + period?: GoalPeriod; + type?: GoalType; +} diff --git a/app/server/services/ts/user/src/types/InvestmentType.ts b/app/server/services/ts/user/src/types/InvestmentType.ts new file mode 100644 index 0000000..777dc6f --- /dev/null +++ b/app/server/services/ts/user/src/types/InvestmentType.ts @@ -0,0 +1 @@ +export type InvestmentType = "stock" | "fixedInterest"; diff --git a/app/server/services/ts/user/src/types/SavingAccountType.ts b/app/server/services/ts/user/src/types/SavingAccountType.ts new file mode 100644 index 0000000..0a5b4d2 --- /dev/null +++ b/app/server/services/ts/user/src/types/SavingAccountType.ts @@ -0,0 +1 @@ +export type SavingAccountType = "RRSP" | "TFSA" | "FHSA" | "RESP" | "RDSP"; diff --git a/app/server/services/ts/user/src/types/SavingInfoRequest.ts b/app/server/services/ts/user/src/types/SavingInfoRequest.ts new file mode 100644 index 0000000..977f5fb --- /dev/null +++ b/app/server/services/ts/user/src/types/SavingInfoRequest.ts @@ -0,0 +1,8 @@ +import type { SavingAccountType } from "./SavingAccountType.ts"; + +export interface SavingInfoRequest { + name: string; + balance: number; + accountType: SavingAccountType; + interestRate?: number; +} diff --git a/app/server/services/ts/user/src/types/SavingInfoResponse.ts b/app/server/services/ts/user/src/types/SavingInfoResponse.ts new file mode 100644 index 0000000..1398851 --- /dev/null +++ b/app/server/services/ts/user/src/types/SavingInfoResponse.ts @@ -0,0 +1,11 @@ +// import type { FinancialAccountType } from "./FinancialAccountType.ts"; +import type { SavingAccountType } from "./SavingAccountType.ts"; + +export interface SavingInfoResponse { + id: number; + balance: number; + name: string; + type: "Savings"; + subtype: SavingAccountType; + lastUpdated?: string; +} diff --git a/app/server/services/ts/user/src/types/TransactionDto.ts b/app/server/services/ts/user/src/types/TransactionDto.ts new file mode 100644 index 0000000..aa44e62 --- /dev/null +++ b/app/server/services/ts/user/src/types/TransactionDto.ts @@ -0,0 +1,4 @@ +import type { Transaction } from "./Transaction.ts"; + +// export interface TransactionDto extends Omit{}; +export type TransactionDto = Omit; diff --git a/app/server/services/ts/user/src/types/UnauthorizedAccess.ts b/app/server/services/ts/user/src/types/UnauthorizedAccess.ts new file mode 100644 index 0000000..03a8c4c --- /dev/null +++ b/app/server/services/ts/user/src/types/UnauthorizedAccess.ts @@ -0,0 +1,8 @@ +export class UnauthorizedAccessError extends Error { + constructor(message: string) { + super(message); // Call the constructor of the base class `Error` + this.name = "UnauthorizedAccessError"; // Set the error name to your custom error class name + // Set the prototype explicitly to maintain the correct prototype chain + Object.setPrototypeOf(this, UnauthorizedAccessError.prototype); + } +} diff --git a/app/server/services/ts/user/stryker.conf.json b/app/server/services/ts/user/stryker.conf.json new file mode 100644 index 0000000..725596c --- /dev/null +++ b/app/server/services/ts/user/stryker.conf.json @@ -0,0 +1,9 @@ +{ + "mutate": ["src/logic/goals.ts"], + "testRunner": "bun", + "plugins": ["stryker-mutator-bun-runner"], + "bun": { + "testFiles": ["./test/goals.test.ts"], + "timeout": 30000 + } +} diff --git a/app/server/services/ts/user/strykerNODE.conf.json b/app/server/services/ts/user/strykerNODE.conf.json new file mode 100644 index 0000000..fd0b1b6 --- /dev/null +++ b/app/server/services/ts/user/strykerNODE.conf.json @@ -0,0 +1,12 @@ +{ + "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "testRunner": "vitest", + "vitest": { + "configFile": "vitest.config.stryker.ts" + }, + "coverageAnalysis": "perTest", + "mutate": ["src/logic/goals.ts"], + "reporters": ["clear-text", "html"], + "concurrency": 4, + "timeoutMS": 10000 +} diff --git a/app/server/services/ts/user/test/dates.test.ts b/app/server/services/ts/user/test/dates.test.ts index 5acb76f..37c9593 100644 --- a/app/server/services/ts/user/test/dates.test.ts +++ b/app/server/services/ts/user/test/dates.test.ts @@ -1,4 +1,3 @@ -// tests/utils/dates.test.ts import { describe, expect, it } from "vitest"; import { generateDateRange } from "../src/utils/dates.ts"; import { diff --git a/app/server/services/ts/user/test/factories/goal.factory.ts b/app/server/services/ts/user/test/factories/goal.factory.ts new file mode 100644 index 0000000..f159404 --- /dev/null +++ b/app/server/services/ts/user/test/factories/goal.factory.ts @@ -0,0 +1,56 @@ +import type { Goal, CreateGoalInput } from "../../src/types/Goals.ts"; + +export function createMockGoal(overrides?: Partial): Goal { + return { + id: 1, + profile_id: 1, + name: "Save for Vacation", + type: "save", + category: "vacation", + target: 1000, + period: "na", + ...overrides, + }; +} + +export function createMockReduceSpendingGoal(overrides?: Partial): Goal { + return { + id: 2, + profile_id: 1, + name: "Reduce Grocery Spending", + type: "reduce_spending", + category: "groceries", + target: 400, + period: "m", + ...overrides, + }; +} + +export function createMockGoals(count: number = 3): Goal[] { + return Array.from({ length: count }, (_, i) => ({ + id: i + 1, + profile_id: 1, + name: `Goal ${i + 1}`, + type: i % 2 === 0 ? "save" : "reduce_spending", + category: i % 2 === 0 ? "vacation" : "groceries", + target: 500 + i * 100, + period: i % 2 === 0 ? "na" : "m", + })); +} + +export function createEmptyGoals(): Goal[] { + return []; +} + +export function createMockCreateGoalInput( + overrides?: Partial, +): CreateGoalInput { + return { + name: "Save for Vacation", + type: "save", + category: "travel", + target: 500, + period: "m", + ...overrides, + }; +} diff --git a/app/server/services/ts/user/test/goals.period.test.ts b/app/server/services/ts/user/test/goals.period.test.ts new file mode 100644 index 0000000..81e500f --- /dev/null +++ b/app/server/services/ts/user/test/goals.period.test.ts @@ -0,0 +1,64 @@ +import { vi, describe, expect, it, beforeEach, afterEach } from "vitest"; +import { enrichGoalWithProgress } from "../src/logic/goals.ts"; +import * as transactionsQueries from "../src/queries/transactions.ts"; +import type { Pool } from "mysql2/promise"; +import { createMockReduceSpendingGoal } from "./factories/goal.factory.ts"; + +vi.mock("../src/queries/transactions", () => ({ + getDateCategoryTransactionsQuery: vi.fn(), + getProfileCategoryTransactionsQuery: vi.fn(), +})); + +describe("Reduce Spending Goal Periods", () => { + const mockPool = {} as Pool; + const profileId = 456; + const mockGetDateCategoryTransactions = + transactionsQueries.getDateCategoryTransactionsQuery as vi.Mock; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should calculate weekly spending correctly", async () => { + const mockGoal = createMockReduceSpendingGoal({ period: "w" }); + mockGetDateCategoryTransactions.mockResolvedValue([ + { amount: -50, category: "groceries" }, + { amount: -30, category: "groceries" }, + ]); + + const result = await enrichGoalWithProgress(mockPool, mockGoal, profileId); + + expect(result.current_amount).toBe(80); + + //verify the date range is correct (last 7 days from March 15) + const callArgs = mockGetDateCategoryTransactions.mock.calls[0]; + expect(callArgs[3]).toBeInstanceOf(Date); // startDate + expect(callArgs[4]).toBeInstanceOf(Date); // endDate + }); + + it("should calculate monthly spending correctly", async () => { + const mockGoal = createMockReduceSpendingGoal({ period: "m" }); + mockGetDateCategoryTransactions.mockResolvedValue([ + { amount: -100, category: "groceries" }, + { amount: -200, category: "groceries" }, + ]); + + const result = await enrichGoalWithProgress(mockPool, mockGoal, profileId); + + expect(result.current_amount).toBe(300); + }); + + it("should handle empty transactions for weekly period", async () => { + const mockGoal = createMockReduceSpendingGoal({ period: "w" }); + mockGetDateCategoryTransactions.mockResolvedValue([]); + + const result = await enrichGoalWithProgress(mockPool, mockGoal, profileId); + + expect(result.current_amount).toBe(0); + expect(result.progress_percentage).toBe(0); + }); +}); diff --git a/app/server/services/ts/user/test/goals.test.ts b/app/server/services/ts/user/test/goals.test.ts new file mode 100644 index 0000000..3e16aa0 --- /dev/null +++ b/app/server/services/ts/user/test/goals.test.ts @@ -0,0 +1,496 @@ +// tests/logic/goals.test.ts +import { vi, describe, expect, it, beforeEach } from "vitest"; +import { + getGoalsByProfileId, + getUserGoals, + getUserGoalById, + createUserGoal, + updateUserGoal, + deleteUserGoal, + enrichGoalWithProgress, +} from "../src/logic/goals.ts"; +import * as goalsQueries from "../src/queries/goals.ts"; +import * as transactionsQueries from "../src/queries/transactions.ts"; +// import * as goalsLogic from "../src/logic/goals.ts"; + +import type { Pool } from "mysql2/promise"; +import { + createMockGoal, + createMockReduceSpendingGoal, + // createMockGoalWithProgress, + createMockGoals, + createEmptyGoals, + createMockCreateGoalInput, +} from "./factories/goal.factory.ts"; +import type { Goal, GoalType } from "../src/types/Goals.ts"; + +vi.mock("../src/queries/goals", () => ({ + getUserProfileId: vi.fn(), + getGoalsByProfileId: vi.fn(), + getGoalById: vi.fn(), + getGoalCountByProfileId: vi.fn(), + createGoal: vi.fn(), + updateGoal: vi.fn(), + deleteGoal: vi.fn(), +})); + +vi.mock("../src/queries/transactions", () => ({ + getDateCategoryTransactionsQuery: vi.fn(), + getProfileCategoryTransactionsQuery: vi.fn(), +})); + +describe("Goals Logic", () => { + const mockPool = {} as Pool; + const userId = 123; + const profileId = 1; + const goalId = 1; + + const mockGetUserProfileId = goalsQueries.getUserProfileId as vi.Mock; + const mockGetGoalsByProfileId = goalsQueries.getGoalsByProfileId as vi.Mock; + const mockGetGoalById = goalsQueries.getGoalById as vi.Mock; + const mockGetGoalCountByProfileId = + goalsQueries.getGoalCountByProfileId as vi.Mock; + const mockCreateGoal = goalsQueries.createGoal as vi.Mock; + const mockUpdateGoal = goalsQueries.updateGoal as vi.Mock; + const mockDeleteGoal = goalsQueries.deleteGoal as vi.Mock; + const mockGetDateCategoryTransactions = + transactionsQueries.getDateCategoryTransactionsQuery as vi.Mock; + const mockGetProfileCategoryTransactions = + transactionsQueries.getProfileCategoryTransactionsQuery as vi.Mock; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("getUserGoals", () => { + it("should return enriched goals for a user", async () => { + const mockGoals = createMockGoals(2); + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalsByProfileId.mockResolvedValue(mockGoals); + + //mock transactions for progress calculation + mockGetProfileCategoryTransactions.mockResolvedValue([ + { amount: 100, category: "vacation" }, + { amount: 150, category: "vacation" }, + ]); + mockGetDateCategoryTransactions.mockResolvedValue([]); + + const result = await getUserGoals(mockPool, userId); + + expect(result).toHaveLength(2); + expect(result[0].current_amount).toBeDefined(); + expect(result[0].progress_percentage).toBeDefined(); + expect(mockGetUserProfileId).toHaveBeenCalledWith(mockPool, userId); + expect(mockGetGoalsByProfileId).toHaveBeenCalledWith(mockPool, profileId); + }); + + it("should return empty array when no profile found", async () => { + mockGetUserProfileId.mockResolvedValue(null); + + const result = await getUserGoals(mockPool, userId); + + expect(result).toEqual([]); + expect(mockGetGoalsByProfileId).not.toHaveBeenCalled(); + }); + + it("should return empty array when no goals found", async () => { + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalsByProfileId.mockResolvedValue(createEmptyGoals()); + + const result = await getUserGoals(mockPool, userId); + + expect(result).toEqual([]); + }); + }); + + describe("getUserGoalById", () => { + it("should return enriched goal by id", async () => { + const mockGoal = createMockGoal(); + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalById.mockResolvedValue(mockGoal); + mockGetProfileCategoryTransactions.mockResolvedValue([ + { amount: 100, category: "vacation" }, + ]); + + const result = await getUserGoalById(mockPool, goalId, userId); + + expect(result).toBeDefined(); + expect(result?.id).toBe(goalId); + expect(result?.current_amount).toBeDefined(); + expect(mockGetUserProfileId).toHaveBeenCalledWith(mockPool, userId); + expect(mockGetGoalById).toHaveBeenCalledWith(mockPool, goalId, profileId); + }); + + it("should return null when no profile found", async () => { + mockGetUserProfileId.mockResolvedValue(null); + + const result = await getUserGoalById(mockPool, goalId, userId); + + expect(result).toBeNull(); + }); + + it("should return null when goal not found", async () => { + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalById.mockResolvedValue(null); + + const result = await getUserGoalById(mockPool, goalId, userId); + + expect(result).toBeNull(); + }); + }); + + describe("createUserGoal", () => { + const createInput = createMockCreateGoalInput(); + + const createdGoal = { + ...createInput, + id: goalId, + current_amount: 0, + progress_percentage: 0, + }; + + it("should create a new goal successfully", async () => { + // const mockNewGoal = createMockGoal(); + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalCountByProfileId.mockResolvedValue(2); + mockCreateGoal.mockResolvedValue(createdGoal); + mockGetProfileCategoryTransactions.mockResolvedValue([]); + + const result = await createUserGoal(mockPool, userId, createInput); + + expect(result).toBeDefined(); + expect(result.name).toBe(createInput.name); + expect(mockCreateGoal).toHaveBeenCalledWith( + mockPool, + profileId, + createInput, + ); + expect(mockGetGoalCountByProfileId).toHaveBeenCalledWith( + mockPool, + profileId, + ); + }); + + it("should throw error when profile not found", async () => { + mockGetUserProfileId.mockResolvedValue(null); + + await expect( + createUserGoal(mockPool, userId, createInput), + ).rejects.toThrow("User profile not found"); + }); + + it("should throw error when max goals reached", async () => { + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalCountByProfileId.mockResolvedValue(5); + + await expect( + createUserGoal(mockPool, userId, createInput), + ).rejects.toThrow("Maximum 5 goals allowed per profile"); + }); + + it("should throw error when goal name is empty", async () => { + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalCountByProfileId.mockResolvedValue(2); + const invalidInput = { ...createInput, name: " " }; + + await expect( + createUserGoal(mockPool, userId, invalidInput), + ).rejects.toThrow("Goal name is required"); + }); + + it("should throw error when target is zero or negative", async () => { + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalCountByProfileId.mockResolvedValue(2); + const invalidInput = { ...createInput, target: 0 }; + + await expect( + createUserGoal(mockPool, userId, invalidInput), + ).rejects.toThrow("Target amount must be greater than 0"); + }); + + it("should throw error when reduce_spending goal has no category", async () => { + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalCountByProfileId.mockResolvedValue(2); + const invalidInput = { + ...createInput, + type: "reduce_spending" as GoalType, + category: "", + }; + + await expect( + createUserGoal(mockPool, userId, invalidInput), + ).rejects.toThrow("Spending limit goals require a category"); + }); + }); + + describe("updateUserGoal", () => { + const updates = { name: "Updated Goal", target: 2000 }; + + it("should update a goal successfully", async () => { + const existingGoal = createMockGoal(); + const updatedGoal = { ...existingGoal, ...updates }; + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalById.mockResolvedValue(existingGoal); + mockUpdateGoal.mockResolvedValue(updatedGoal); + mockGetProfileCategoryTransactions.mockResolvedValue([]); + + const result = await updateUserGoal(mockPool, goalId, userId, updates); + + expect(result).toBeDefined(); + expect(result.name).toBe(updates.name); + expect(result.target).toBe(updates.target); + expect(mockUpdateGoal).toHaveBeenCalledWith( + mockPool, + goalId, + profileId, + updates, + ); + }); + + it("should throw error when profile not found", async () => { + mockGetUserProfileId.mockResolvedValue(null); + + await expect( + updateUserGoal(mockPool, goalId, userId, updates), + ).rejects.toThrow("User profile not found"); + }); + + it("should throw error when goal not found", async () => { + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalById.mockResolvedValue(null); + + await expect( + updateUserGoal(mockPool, goalId, userId, updates), + ).rejects.toThrow("Goal not found"); + }); + + it("should throw error when target is zero or negative", async () => { + const existingGoal = createMockGoal(); + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalById.mockResolvedValue(existingGoal); + const invalidUpdates = { target: 0 }; + + await expect( + updateUserGoal(mockPool, goalId, userId, invalidUpdates), + ).rejects.toThrow("Target amount must be greater than 0"); + }); + + it("should throw error when updateGoal returns null", async () => { + const existingGoal = createMockGoal(); + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalById.mockResolvedValue(existingGoal); + mockUpdateGoal.mockResolvedValue(null); // Simulate failed update + + await expect( + updateUserGoal(mockPool, goalId, userId, updates), + ).rejects.toThrow("Failed to update goal"); + }); + }); + + describe("deleteUserGoal", () => { + it("should delete a goal successfully", async () => { + mockGetUserProfileId.mockResolvedValue(profileId); + mockDeleteGoal.mockResolvedValue(true); + + const result = await deleteUserGoal(mockPool, goalId, userId); + + expect(result).toBe(true); + expect(mockDeleteGoal).toHaveBeenCalledWith(mockPool, goalId, profileId); + }); + + it("should throw error when profile not found", async () => { + mockGetUserProfileId.mockResolvedValue(null); + + await expect(deleteUserGoal(mockPool, goalId, userId)).rejects.toThrow( + "User profile not found", + ); + }); + + it("should throw error when goal not found", async () => { + mockGetUserProfileId.mockResolvedValue(profileId); + mockDeleteGoal.mockResolvedValue(false); + + await expect(deleteUserGoal(mockPool, goalId, userId)).rejects.toThrow( + "Goal not found or already deleted", + ); + }); + }); + + describe("enrichGoalWithProgress", () => { + it("should enrich a save goal with progress from savings transactions", async () => { + const mockGoal = createMockGoal(); + mockGetProfileCategoryTransactions.mockResolvedValue([ + { amount: 100, category: "vacation" }, + { amount: 150, category: "vacation" }, + ]); + + const result = await enrichGoalWithProgress( + mockPool, + mockGoal, + profileId, + ); + + expect(result.current_amount).toBe(250); + expect(result.progress_percentage).toBe(25); // 250/1000 * 100 + expect(mockGetProfileCategoryTransactions).toHaveBeenCalledWith( + mockPool, + profileId, + mockGoal.category, + ); + }); + + it("should enrich a reduce_spending goal with progress from expenses", async () => { + const mockGoal = createMockReduceSpendingGoal(); + mockGetDateCategoryTransactions.mockResolvedValue([ + { amount: -100, category: "groceries" }, + { amount: -150, category: "groceries" }, + ]); + + const result = await enrichGoalWithProgress( + mockPool, + mockGoal, + profileId, + ); + + expect(result.current_amount).toBe(250); + expect(result.progress_percentage).toBe(62.5); // 250/400 * 100 + expect(mockGetDateCategoryTransactions).toHaveBeenCalledWith( + mockPool, + profileId, + mockGoal.category, + expect.any(Date), + expect.any(Date), + ); + }); + + it("should cap progress at 100%", async () => { + const mockGoal = createMockGoal({ target: 200 }); + mockGetProfileCategoryTransactions.mockResolvedValue([ + { amount: 300, category: "vacation" }, + ]); + + const result = await enrichGoalWithProgress( + mockPool, + mockGoal, + profileId, + ); + + expect(result.current_amount).toBe(300); + expect(result.progress_percentage).toBe(100); // capped at 100 + }); + + it("should return 0 current amount when no transactions", async () => { + const mockGoal = createMockGoal(); + mockGetProfileCategoryTransactions.mockResolvedValue([]); + + const result = await enrichGoalWithProgress( + mockPool, + mockGoal, + profileId, + ); + + expect(result.current_amount).toBe(0); + expect(result.progress_percentage).toBe(0); + }); + + it("should handle negative totals for save goals (more expenses than savings)", async () => { + const mockGoal = createMockGoal(); + mockGetProfileCategoryTransactions.mockResolvedValue([ + { amount: 100, category: "vacation" }, + { amount: -300, category: "vacation" }, // negative transaction (expense) + ]); + + const result = await enrichGoalWithProgress( + mockPool, + mockGoal, + profileId, + ); + + expect(result.current_amount).toBe(0); // total is -200, should return 0 + }); + }); + + describe("getGoalsByProfileId", () => { + it("should return goals for a profile", async () => { + const mockGoals = createMockGoals(3); + mockGetGoalsByProfileId.mockResolvedValue(mockGoals); + + const result = await getGoalsByProfileId(mockPool, profileId); + + expect(result).toHaveLength(3); + expect(mockGetGoalsByProfileId).toHaveBeenCalledWith(mockPool, profileId); + }); + + it("should return empty array when no goals", async () => { + mockGetGoalsByProfileId.mockResolvedValue([]); + + const result = await getGoalsByProfileId(mockPool, profileId); + + expect(result).toEqual([]); + }); + }); + + describe("calculateCurrentAmount - unknown goal type via enrichGoalWithProgress", () => { + it("should return 0 for unknown goal type", async () => { + const invalidGoal = { + id: 999, + profile_id: 1, + name: "Invalid Goal", + type: "invalid_type" as GoalType, + category: "test", + target: 1000, + period: "m", + } as Goal; + + mockGetUserProfileId.mockResolvedValue(profileId); + + mockGetProfileCategoryTransactions.mockResolvedValue([]); + mockGetDateCategoryTransactions.mockResolvedValue([]); + + const result = await enrichGoalWithProgress( + mockPool, + invalidGoal, + profileId, + ); + expect(result.current_amount).toBe(0); + }); + + it("should handle profileId null gracefully", async () => { + const validGoal = createMockGoal(); + mockGetUserProfileId.mockResolvedValue(null); + const result = await enrichGoalWithProgress(mockPool, validGoal, null); + + expect(result.current_amount).toBe(0); + }); + }); + + // it("should throw error when enrichGoalWithProgress returns null", async () => { + // //this fails on docker for some reason, but runs fine locally, unknown if it works in GitHub Actions so it's untouched + // const mockNewGoal = createMockGoal(); + // mockGetUserProfileId.mockResolvedValue(profileId); + // mockGetGoalCountByProfileId.mockResolvedValue(2); + // mockCreateGoal.mockResolvedValue(mockNewGoal); + // const createInput = createMockCreateGoalInput(); + // //spy on enrichGoalWithProgress and make it return null + // const enrichSpy = vi + // .spyOn(goalsLogic, "enrichGoalWithProgress") + // .mockResolvedValue(null); + + // await expect(createUserGoal(mockPool, userId, createInput)).rejects.toThrow( + // "Failed to enrich goal with progress", + // ); + + // enrichSpy.mockRestore(); + // }); + + it("should throw error when createGoal returns undefined", async () => { + mockGetUserProfileId.mockResolvedValue(profileId); + mockGetGoalCountByProfileId.mockResolvedValue(2); + mockCreateGoal.mockResolvedValue(undefined); //simulate undefined result + const createInput = createMockCreateGoalInput(); + + await expect(createUserGoal(mockPool, userId, createInput)).rejects.toThrow( + "Failed to create goal", + ); + }); +}); diff --git a/app/server/services/ts/user/test/logic_debts.test.ts b/app/server/services/ts/user/test/logic_debts.test.ts new file mode 100644 index 0000000..d51c99a --- /dev/null +++ b/app/server/services/ts/user/test/logic_debts.test.ts @@ -0,0 +1,233 @@ +import { vi, describe, expect, it, beforeEach } from "vitest"; +import type { + Pool, + PoolConnection, + ResultSetHeader, + RowDataPacket, +} from "mysql2/promise"; +import { findDebtsBy, addDebt } from "../src/queries/debt.ts"; +import type { FinancialAccountType } from "../src/types/FinancialAccountType.ts"; +import type { FinancialAccountRequest } from "../src/types/FinancialAccountRequest.ts"; + +// Define the row structure from the database +interface DebtRow extends RowDataPacket { + id: string; + name: string; + balance: number; + subtype: string; + last_updated: string | null; +} + +interface ProfileRow extends RowDataPacket { + id: number; +} + +describe("findDebtsBy", () => { + const subtype = "Loan"; + + it("should return mapped debt accounts", async () => { + const mockRows: DebtRow[] = [ + { + id: "1", + name: "Debt A", + balance: 1000, + subtype: subtype, + last_updated: "2022-10-17T00:00:00.000Z", + } as DebtRow, + { + id: "2", + name: "Debt B", + balance: 9000, + subtype: subtype, + last_updated: "2026-02-08T19:00:00.000Z", + } as DebtRow, + ]; + + const mockDb = { + execute: vi.fn().mockResolvedValue([mockRows]), + } as unknown as Pool; + + const result = await findDebtsBy(mockDb, "30"); + + expect(mockDb.execute).toHaveBeenCalled(); + expect(result.length).toEqual(2); + expect(result[0]).toEqual({ + id: 1, + name: "Debt A", + balance: 1000, + type: "Credit" as FinancialAccountType, + subtype: subtype, + lastUpdated: new Date("2022-10-17T00:00:00.000Z").toISOString(), + }); + expect(result[1]).toEqual({ + id: 2, + name: "Debt B", + balance: 9000, + type: "Credit" as FinancialAccountType, + subtype: subtype, + lastUpdated: new Date("2026-02-08T19:00:00.000Z").toISOString(), + }); + }); + + it("should return empty array if no rows", async () => { + const mockDb = { + execute: vi.fn().mockResolvedValue([[]]), + } as unknown as Pool; + + const result = await findDebtsBy(mockDb, "20"); + expect(result).toEqual([]); + }); + + it("should handle null/invalid last_updated safely", async () => { + const mockRows: DebtRow[] = [ + { + id: "3", + name: "Debt C", + balance: 500, + subtype: subtype, + last_updated: null, + } as DebtRow, + ]; + + const mockDb = { + execute: vi.fn().mockResolvedValue([mockRows]), + } as unknown as Pool; + + const result = await findDebtsBy(mockDb, "50"); + expect(result[0]?.lastUpdated).toBe("N/A"); + }); + + it("should call DB with correct query and userId", async () => { + const mockDb = { + execute: vi.fn().mockResolvedValue([[]]), + } as unknown as Pool; + + await findDebtsBy(mockDb, "1"); + expect(mockDb.execute).toHaveBeenCalledWith( + expect.stringContaining("SELECT"), + ["1"], + ); + }); +}); + +describe("addDebt", () => { + let mockConnection: Partial; + let mockDb: Partial; + + // Type for the mock execute results + interface InsertResult extends ResultSetHeader { + insertId: number; + } + + beforeEach(() => { + mockConnection = { + beginTransaction: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), + release: vi.fn(), + execute: vi.fn(), + query: vi.fn(), + }; + + mockDb = { + getConnection: vi.fn().mockResolvedValue(mockConnection), + }; + }); + + const newDebts: FinancialAccountRequest = { + name: "Testing Debt Financial Account", + type: "Credit" as FinancialAccountType, + balance: 1000, + value: 1000, + subtype: "Loan", + }; + + const userId = "10"; + + it("should insert debt account and link profiles", async () => { + const mockExecute = mockConnection.execute as unknown as ReturnType< + typeof vi.fn + >; + const mockQuery = mockConnection.query as unknown as ReturnType< + typeof vi.fn + >; + + // Mock INSERT financialAccount + mockExecute.mockResolvedValueOnce([{ insertId: 10 } as InsertResult]); + // Mock SELECT profiles + mockExecute.mockResolvedValueOnce([[{ id: 1 }, { id: 2 }] as ProfileRow[]]); + mockQuery.mockResolvedValueOnce([]); + + const result = await addDebt(mockDb as Pool, newDebts, userId); + + expect(mockConnection.beginTransaction).toHaveBeenCalled(); + expect(mockExecute).toHaveBeenCalledTimes(2); + expect(mockQuery).toHaveBeenCalled(); + expect(mockConnection.commit).toHaveBeenCalled(); + expect(mockConnection.release).toHaveBeenCalled(); + + expect(result.id).toBe(10); + expect(result.name).toBe("Testing Debt Financial Account"); + expect(result.balance).toBe(1000); + }); + + it("should skip linking if no profiles found", async () => { + const mockExecute = mockConnection.execute as unknown as ReturnType< + typeof vi.fn + >; + const mockQuery = mockConnection.query as unknown as ReturnType< + typeof vi.fn + >; + + // INSERT financialAccount succeeds + mockExecute.mockResolvedValueOnce([{ insertId: 20 } as InsertResult]); + // SELECT profiles returns empty + mockExecute.mockResolvedValueOnce([[]]); + + const result = await addDebt(mockDb as Pool, newDebts, userId); + + expect(mockQuery).not.toHaveBeenCalled(); + expect(mockConnection.commit).toHaveBeenCalled(); + expect(result.id).toBe(20); + }); + + it("should rollback if any query fails", async () => { + const mockExecute = mockConnection.execute as unknown as ReturnType< + typeof vi.fn + >; + + mockExecute.mockResolvedValueOnce([{ insertId: 30 } as InsertResult]); + // Fail on the next query + mockExecute.mockRejectedValueOnce(new Error("DB error")); + + await expect(addDebt(mockDb as Pool, newDebts, userId)).rejects.toThrow( + "DB error", + ); + + expect(mockConnection.rollback).toHaveBeenCalled(); + expect(mockConnection.commit).not.toHaveBeenCalled(); + expect(mockConnection.release).toHaveBeenCalled(); + }); + + it("should call INSERT with correct values", async () => { + const mockExecute = mockConnection.execute as unknown as ReturnType< + typeof vi.fn + >; + + mockExecute.mockResolvedValueOnce([{ insertId: 40 } as InsertResult]); + mockExecute.mockResolvedValueOnce([[]]); + + await addDebt(mockDb as Pool, newDebts, "user-1"); + + expect(mockExecute).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO finus.financialAccount"), + [ + newDebts.name, + newDebts.type, + newDebts.balance, + newDebts.value, + newDebts.subtype, + ], + ); + }); +}); diff --git a/app/server/services/ts/user/test/logic_savings.test.ts b/app/server/services/ts/user/test/logic_savings.test.ts new file mode 100644 index 0000000..c2af381 --- /dev/null +++ b/app/server/services/ts/user/test/logic_savings.test.ts @@ -0,0 +1,118 @@ +import { vi, describe, expect, it, beforeEach } from "vitest"; +import * as transactionQueries from "../src/queries/transactions.ts"; +import { getSavingAccountTransactionBy } from "../src/logic/saving.ts"; + +vi.mock("../src/queries/transactions", () => ({ + findTransactionsBy: vi.fn(), +})); + +describe("getSavingAccountTransactionBy", () => { + const mockFindTransactionsBy = + transactionQueries.findTransactionsBy as vi.Mock; + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Valid transactions + it("should return mapped TransactionDto", async () => { + const mockTransactions = [ + { + id: 1, + financialAccountId: 1, + amount: 45.3, + category: "Income", + description: "Bi-weekly Tips", + sender: "Huy", + recipient: "Restaurant Owner Jeff", + date: "2026-03-20T18:30:00.456Z", + }, + { + id: 2, + financialAccountId: 50, + amount: 9000, + category: "Salary", + description: "Monthly salary", + sender: "Hospital Manager", + recipient: "Huy", + date: "2026-03-15T12:00:00.789Z", + }, + ]; //mocking data returned from querying + + mockFindTransactionsBy.mockResolvedValue(mockTransactions); + + const result = await getSavingAccountTransactionBy("1"); + + expect(transactionQueries.findTransactionsBy).toHaveBeenCalledWith( + expect.anything(), + "1", + ); + + expect(result).toEqual([ + { + id: 1, + amount: 45.3, + category: "Income", + description: "Bi-weekly Tips", + sender: "Huy", + recipient: "Restaurant Owner Jeff", + date: "2026-03-20 18:30:00", + }, + { + id: 2, + amount: 9000, + category: "Salary", + description: "Monthly salary", + sender: "Hospital Manager", + recipient: "Huy", + date: "2026-03-15 12:00:00", + }, + ]); + }); + + // Empty transactions + it("should return empty array if no transactions", async () => { + mockFindTransactionsBy.mockResolvedValue([]); + + const result = await getSavingAccountTransactionBy("1"); + + expect(result).toEqual([]); + }); + + // Empty date + it("should assign N/A to missing date", async () => { + const mockTransactions = [ + { + id: 2, + financialAccountId: 10, + amount: 5000, + category: "Student fee savings", + description: "savings to pay annual student fee", + sender: "Parents", + recipient: "Huy", + date: null, + }, + ]; + + mockFindTransactionsBy.mockResolvedValue(mockTransactions); + + const result = await getSavingAccountTransactionBy("1"); + + expect(result[0].date).toBe("N/A"); + }); + + // Throw error for missing financialAccountId + it("should throw error if financialAccountId is missing", async () => { + await expect(getSavingAccountTransactionBy(undefined)).rejects.toThrow( + "Missing financial account id", + ); + }); + + // Triggering db error + it("should throw if findTransactionsBy fails", async () => { + mockFindTransactionsBy.mockRejectedValue(new Error("DB error")); + + await expect(getSavingAccountTransactionBy("1")).rejects.toThrow( + "DB error", + ); + }); +}); diff --git a/app/server/services/ts/user/test/logic_transactions.test.ts b/app/server/services/ts/user/test/logic_transactions.test.ts index 0eb7c26..4ca4989 100644 --- a/app/server/services/ts/user/test/logic_transactions.test.ts +++ b/app/server/services/ts/user/test/logic_transactions.test.ts @@ -8,6 +8,7 @@ import { createEmptyTransactions, createTransactionsWithMissingFields, } from "./factories/transaction.factory.ts"; +// import type { Transaction } from "../src/types/Transaction.ts"; vi.mock("../src/queries/transactions", () => ({ getAllTransactionsQuery: vi.fn(), diff --git a/app/server/services/ts/user/test/queries_savings.test.ts b/app/server/services/ts/user/test/queries_savings.test.ts new file mode 100644 index 0000000..94b3c9b --- /dev/null +++ b/app/server/services/ts/user/test/queries_savings.test.ts @@ -0,0 +1,201 @@ +import { vi, describe, expect, it, beforeEach } from "vitest"; +import { findSavingsBy, addSavingAccount } from "../src/queries/saving.ts"; +import type { Pool, PoolConnection } from "mysql2/promise"; +import type { SavingAccountType } from "../src/types/SavingAccountType.ts"; +import type { FinancialAccountType } from "../src/types/FinancialAccountType.ts"; + +describe("findSavingsBy", () => { + it("should return mapped saving accounts", async () => { + // Setup fake data + const mockRows = [ + { + id: "1", + name: "Savings A", + balance: 1000, + subtype: "TFSA" as SavingAccountType, + last_updated: "2022-10-17T00:00:00.000Z", + }, + { + id: "2", + name: "Savings B", + balance: 9000, + subtype: "RRSP" as SavingAccountType, + last_updated: "2026-02-08T19:00:00.000Z", + }, + ]; + + const mockDb = { + execute: vi.fn().mockResolvedValue([mockRows]), + } as unknown as Pool; + + const result = await findSavingsBy(mockDb, "30"); + + // Assert + expect(mockDb.execute).toHaveBeenCalled(); + + expect(result.length).toEqual(2); + expect(result[0]).toEqual({ + id: 1, + name: "Savings A", + balance: 1000, + type: "Savings" as FinancialAccountType, + subtype: "TFSA" as SavingAccountType, + lastUpdated: new Date("2022-10-17T00:00:00.000Z").toISOString(), + }); + expect(result[1]).toEqual({ + id: 2, + name: "Savings B", + balance: 9000, + type: "Savings" as FinancialAccountType, + subtype: "RRSP" as SavingAccountType, + lastUpdated: new Date("2026-02-08T19:00:00.000Z").toISOString(), + }); + }); + + it("should return empty array if no rows", async () => { + const mockDb = { + execute: vi.fn().mockResolvedValue([[]]), + } as unknown as Pool; + + const result = await findSavingsBy(mockDb, "20"); + + expect(result).toEqual([]); + }); + + it("should handle null/invalid last_updated safely", async () => { + const mockRows = [ + { + id: "3", + name: "Savings C", + balance: 500, + type: "Savings" as FinancialAccountType, + subtype: "RESP" as SavingAccountType, + last_updated: null, + }, + ]; + + const mockDb = { + execute: vi.fn().mockResolvedValue([mockRows]), + } as unknown as Pool; + + const result = await findSavingsBy(mockDb, "50"); + + expect(result[0]?.lastUpdated).toBe("N/A"); + }); + + it("should call DB with correct query and userId", async () => { + const mockDb = { + execute: vi.fn().mockResolvedValue([[]]), + } as unknown as Pool; + + await findSavingsBy(mockDb, "1"); + + expect(mockDb.execute).toHaveBeenCalledWith( + expect.stringContaining("SELECT"), + ["1"], + ); + }); +}); + +describe("addSavingAccount", () => { + let mockConnection: Partial; + let mockDb: Partial; + + beforeEach(() => { + mockConnection = { + beginTransaction: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), + release: vi.fn(), + execute: vi.fn(), + query: vi.fn(), + }; + + mockDb = { + getConnection: vi.fn().mockResolvedValue(mockConnection), + }; + }); + + const newSavings = { + name: "Test Saving", + type: "Savings" as FinancialAccountType, + balance: 1000, + value: 1000, + subtype: "RRSP" as SavingAccountType, + }; + + const userId = "10"; + + it("should insert saving account and link profiles", async () => { + (mockConnection.execute as unknown as ReturnType) + // INSERT financialAccount + .mockResolvedValueOnce([{ insertId: 10 }]) + // SELECT profiles + .mockResolvedValueOnce([[{ id: 1 }, { id: 2 }]]); + + ( + mockConnection.query as unknown as ReturnType + ).mockResolvedValueOnce([]); + + const result = await addSavingAccount(mockDb as Pool, newSavings, userId); + + // Assert + expect(mockConnection.beginTransaction).toHaveBeenCalled(); + expect(mockConnection.execute).toHaveBeenCalledTimes(2); + expect(mockConnection.query).toHaveBeenCalled(); // linking table + expect(mockConnection.commit).toHaveBeenCalled(); + expect(mockConnection.release).toHaveBeenCalled(); + + expect(result.id).toBe(10); + expect(result.name).toBe("Test Saving"); + expect(result.balance).toBe(1000); + }); + + // Should not insert into link table due to no profile found + it("should skip linking if no profiles found", async () => { + (mockConnection.execute as unknown as ReturnType) + .mockResolvedValueOnce([{ insertId: 20 }]) + .mockResolvedValueOnce([[]]); // no profiles + + const result = await addSavingAccount(mockDb as Pool, newSavings, userId); + + expect(mockConnection.query).not.toHaveBeenCalled(); + expect(mockConnection.commit).toHaveBeenCalled(); + expect(result.id).toBe(20); + }); + + // Should rollback on error + it("should rollback if any query fails", async () => { + (mockConnection.execute as unknown as ReturnType) + .mockResolvedValueOnce([{ insertId: 30 }]) + .mockRejectedValueOnce(new Error("DB error")); // fail on profile query + + await expect( + addSavingAccount(mockDb as Pool, newSavings, userId), + ).rejects.toThrow("DB error"); + + expect(mockConnection.rollback).toHaveBeenCalled(); + expect(mockConnection.commit).not.toHaveBeenCalled(); + expect(mockConnection.release).toHaveBeenCalled(); + }); + + // Should call queries with correct params + it("should call INSERT with correct values", async () => { + (mockConnection.execute as unknown as ReturnType) + .mockResolvedValueOnce([{ insertId: 40 }]) + .mockResolvedValueOnce([[]]); + + await addSavingAccount(mockDb as Pool, newSavings, "user-1"); + + expect(mockConnection.execute).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO finus.financialAccount"), + [ + newSavings.name, + newSavings.type, + newSavings.balance, + newSavings.value, + newSavings.subtype, + ], + ); + }); +}); diff --git a/app/server/services/ts/user/vitest.config.stryker.ts b/app/server/services/ts/user/vitest.config.stryker.ts new file mode 100644 index 0000000..8ac7718 --- /dev/null +++ b/app/server/services/ts/user/vitest.config.stryker.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["test/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["json", "text"], + }, + }, +}); diff --git a/documentation/Acceptance/MarketAcceptance/MarketAcceptance.md b/documentation/Acceptance/MarketAcceptance/MarketAcceptance.md new file mode 100644 index 0000000..15727a6 --- /dev/null +++ b/documentation/Acceptance/MarketAcceptance/MarketAcceptance.md @@ -0,0 +1,61 @@ +## Acceptance Criteria +1: Search +Searching “AAPL” returns Apple stock. +Searching “USD/CAD” returns the forex pair. +Empty search returns no results. +Invalid symbols return an empty list. + +2: Market Data +GET /market/AAPL returns: + +- price +- daily change +- timestamp + +3: Historical Data +GET /market/=AAPL&range=1M returns: +an array of price points +no gaps in chart data +missing days filled with the last known value + +4: Pinning +POST /user/pins stores a pin. +GET /user/pins returns all pinned instruments. +Duplicate pins are rejected. + +5: Unpinning +DELETE /user/pins/:symbol removes the pin. + +6: Sync on Login +After login, pinned instruments appear on the dashboard automatically. + +## Acceptance Test Scenarios +1 — Search for an Instrument +Given the user is authenticated +When they search for “AAPL” +Then the system returns a list containing Apple stock + +2 — View Market Quote +Given the user selects “AAPL” +When they open the instrument page +Then the system shows current price and daily change + +3 — View Historical Chart +Given the user selects “AAPL” +When they choose the 1‑month range +Then the system displays a chart with normalized data + +4 — Pin an Instrument +Given the user is authenticated +When they pin “AAPL” +Then the pin appears in their profile + +5 — Sync Pins on Login +Given the user has pinned “AAPL” +When they log in +Then the dashboard shows “AAPL” + +6 — Unpin an Instrument +Given the user has pinned “AAPL” +When they unpin it +Then it is removed from their profile \ No newline at end of file diff --git a/documentation/Acceptance/UserInp/UserInpAcceptance.md b/documentation/Acceptance/UserInp/UserInpAcceptance.md new file mode 100644 index 0000000..852f4d4 --- /dev/null +++ b/documentation/Acceptance/UserInp/UserInpAcceptance.md @@ -0,0 +1,59 @@ +# Acceptance Criteria + +## 1: Manual Financial Entry +- Users can manually enter financial data such as incomes, expenses, investments, and individual transactions. +- Users can input transaction details including name, amount, date, and category. +- Manually entered data appears immediately in the user’s financial profile. + +## 2: CSV Import +- Users can upload a CSV bank statement for parsing. +- The system extracts transaction names, dates, amounts, and descriptions. +- Empty or malformed rows are ignored without breaking the import. +- Parsed transactions are displayed for user confirmation before saving. +- Invalid CSV formats return a clear error message. + +## 3: Financial Profiles +- Users can create financial profiles that aggregate all accounts, transactions, and categories. +- Profiles persist across sessions. +- Profiles update automatically when new transactions are added manually or via CSV. + +## 4: Edit Transactions +- Users can edit the category of any historical transaction. +- Users can edit the name of any historical transaction. +- Users can edit the financial value of any historical transaction. +- Edited transactions update all related summaries and totals. + +--- + +# Acceptance Test Scenarios + +## 1 — Manual Entry of Financial Data +Given the user is authenticated +When they manually enter a new income, expense, or investment +Then the system stores the entry +And it appears in their financial profile + +## 2 — CSV Import and Parsing +Given the user uploads a valid CSV bank statement +When the system parses the file +Then transactions are extracted and displayed for review +And invalid rows are ignored without stopping the import + + +## 3 — Editing Transaction Category +Given a transaction is incorrectly categorized +When the user selects a new category +Then the transaction is updated +And category totals reflect the change + +## 4 — Editing Transaction Name +Given a transaction name is unclear or incorrect +When the user edits the name +Then the updated name appears across all views + +## 5 — Editing Transaction Value +Given a transaction amount is wrong +When the user edits the value +Then the corrected value updates account balances and summaries + + diff --git a/documentation/Finus_Test_Plan.md b/documentation/Finus_Test_Plan.md index bff544c..cc74133 100644 --- a/documentation/Finus_Test_Plan.md +++ b/documentation/Finus_Test_Plan.md @@ -1,32 +1,32 @@ - ***Note that you can refine your testing plan as the project development goes. Keep the change log as follow:*** *Change-log* -| Version | Change Date | By | Description | -| :---: | :---: | :---: | :---: | -| version number | Date of Change | Name of person who made changes | Description of the changes made | -| 1.0 | March 2 | Roman Tebel | Initial write up | -| 1.1 | March 5 | Deep Patel | Added more details to the scope, roles, and testing | +| Version | Change Date | By | Description | +| :------------: | :------------: | :-----------------------------: | :-------------------------------------------------: | +| version number | Date of Change | Name of person who made changes | Description of the changes made | +| 1.0 | March 2 | Roman Tebel | Initial write up | +| 1.1 | March 5 | Deep Patel | Added more details to the scope, roles, and testing | +| 1.2 | March 27 | Roman | Updating testing procedures | 1. # **Introduction** - 1. ## **Scope** + 1. ## **Scope** These are the features we have implemented for Sprint 2 and have created tests for: 1\. User Authentication -1. Sign up -2. Login +1. Sign up +2. Login 3. Logout 2\. Visualization Dashboard -1. Savings chart logic -2. Income flow chart logic -3. Budget calculation and performance against expenses -4. Lookup of transactions +1. Savings chart logic +2. Income flow chart logic +3. Budget calculation and performance against expenses +4. Lookup of transactions 5. Aggregation of snapshot data 3\. User Data Input @@ -35,88 +35,84 @@ These are the features we have implemented for Sprint 2 and have created tests f 2. Data input 3. Validating forms and files - The following are the features to be implemented in future sprints: Financial Goals, Financial Projections, Stock and FOREX Tracking, Collaborative Budgets (stretch), and ML Integration (stretch). The scope includes functional verification through unit, integration, acceptance, and regression testing for the features implemented. The unit and integration tests will confirm code quality and can potentially evaluate some of the project requirements. A major portion of project requirements in our case must be validated through a manual walkthrough within the client. -Load testing will be conducted in Sprint 3 in order to ensure that the server can support the expected usage load, specified in the course outline for the project. +Load testing will be conducted in Sprint 4 in order to ensure that the server can support the expected usage load, specified in the course outline for the project. Mutation testing will be conducted in Sprint 3 in order to expand code coverage and make the project more robust. - - -2. ## **Roles and Responsibilities** +2. ## **Roles and Responsibilities** Each team member is assigned one or more of the following roles: -1. Frontend Developer -2. Backend Developer -3. DevOps -4. Tester +1. Frontend Developer +2. Backend Developer +3. DevOps +4. Tester 5. Quality Assurance -| Name | Net ID | GitHub username | Role | -| :---- | :---- | :---- | :---- | -| Roman Tebel | 7929015 | TebelR | Frontend Developer \+ Backend Developer \+ Tester | -| Logan Decock | 7966258 | MeasureOneCodeTwice | Frontend Developer \+ Backend Developer \+ DevOps | -| Jackie Mei | 7882240 | JackieMei3 | Frontend Developer \+ Backend Developer \+ QA | -| Dylan Prabagaran | 7898220 | dyll87 | Frontend Developer \+ Backend Developer \+ Tester | -| Deep Patel | 7957389 | deep-n-patel | Frontend Developer \+ Backend Developer \+ Tester | -| Hoang Huy Truong | 7960938 | HuyTruong24 | Frontend Developer \+ Backend Developer \+ Tester | +| Name | Net ID | GitHub username | Role | +| :--------------- | :------ | :------------------ | :----------------------------------------------- | +| Roman Tebel | 7929015 | TebelR | Frontend Developer\+ Backend Developer \+ Tester | +| Logan Decock | 7966258 | MeasureOneCodeTwice | Frontend Developer\+ Backend Developer \+ DevOps | +| Jackie Mei | 7882240 | JackieMei3 | Frontend Developer\+ Backend Developer \+ QA | +| Dylan Prabagaran | 7898220 | dyll87 | Frontend Developer\+ Backend Developer \+ Tester | +| Deep Patel | 7957389 | deep-n-patel | Frontend Developer\+ Backend Developer \+ Tester | +| Hoang Huy Truong | 7960938 | HuyTruong24 | Frontend Developer\+ Backend Developer \+ Tester | **Role Details** -* Frontend Developer - * Build and maintain the user interface of the application. - * Connect the frontend to backend services through APIs. - * Ensure the application is responsive and user-friendly. - * Test and debug frontend code to maintain performance and quality. - -* Backend Developer - * Develop and maintain server-side logic and APIs. - * Manage databases and data processing. - * Implement security, authentication, and business logic. - * Ensure the system is reliable, scalable, and well-tested. - -* DevOps - * Manager docker compose files, environments and secrets - * Create and maintain CI/CD pipelines - * Environment set up such linting and formatting as a pre-commit hooks - -* Tester - * Creates testing plans and strategies - * Responsible for performing both manual and automated testing - * Makes sure that the software meets the performance and quality standards - -* Quality Assurance - * Searches/Finds bugs in development & build process as well as in production. - +* Frontend Developer + + * Build and maintain the user interface of the application. + * Connect the frontend to backend services through APIs. + * Ensure the application is responsive and user-friendly. + * Test and debug frontend code to maintain performance and quality. +* Backend Developer + + * Develop and maintain server-side logic and APIs. + * Manage databases and data processing. + * Implement security, authentication, and business logic. + * Ensure the system is reliable, scalable, and well-tested. +* DevOps + + * Manager docker compose files, environments and secrets + * Create and maintain CI/CD pipelines + * Environment set up such linting and formatting as a pre-commit hooks +* Tester + + * Creates testing plans and strategies + * Responsible for performing both manual and automated testing + * Makes sure that the software meets the performance and quality standards +* Quality Assurance + + * Searches/Finds bugs in development & build process as well as in production. 2. # **Test Methodology** - 1. ## **Automated regression testing** + 1. ## **Automated regression testing** **2.1.1 Test Levels** -Test levels define the Types of Testing to be executed on the Application Under Test (AUT). In this course, unit testing, integration testing, acceptance testing, regression testing, and load testing are mandatory. +Test levels define the Types of Testing to be executed on the Application Under Test (AUT). In this course, unit testing, integration testing, acceptance testing, regression testing, and load testing are mandatory. **Requirements**: -- For unit testing, at least 10 unit tests for EACH core feature to cover the code related to each core feature. -- For integration testing, at least 10 in total to cover core features. -- Acceptance testing for each core feature. Let’s use end-user test for this. You can ask real end-user or your team members to go through each user story and see if the requirements are meet. -- For regression testing, need to execute all above unit tests \+ integration tests you have for each commit pushed to main branch. - +- For unit testing, at least 10 unit tests for EACH core feature to cover the code related to each core feature. +- For integration testing, at least 10 in total to cover core features. +- Acceptance testing for each core feature. Let’s use end-user test for this. You can ask real end-user or your team members to go through each user story and see if the requirements are meet. +- For regression testing, need to execute all above unit tests \+ integration tests you have for each commit pushed to main branch. -| Test Level | Scope & Requirement | Methodology (How will you do this?) | -| :---- | :---- | :---- | -| **Unit Testing** | **User Authentication:** 20 tests
**User Input:** 30 tests
**Visualization Dashboard:** 50 in Python, 46 in TypeScript
**Total:** 146 tests | *We use Vitest for TS-based microservices and the client. We use PyTest for python-based microservices.* | -| **Integration Testing** | **10 tests total** covering interactions between features. | *Running tests in their environment with microservices fully operational and communicating with each other.* | -| **Acceptance Testing** | **End-user testing** for every user story. | *Team members/external users will perform Manual Walkthroughs based on User Story criteria. Deficiencies and potential improvements will be documented.* | -| **Regression Testing** | Unit \+ Integration tests executed on **every push to main branch**. | *We have configured a GitHub Actions CI pipeline to run all tests automatically.* | +| Test Level | Scope & Requirement | Methodology (How will you do this?) | +| :---------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Unit Testing** | **User Authentication:** 20 tests `
` **User Input:** 30 tests `
` **Visualization Dashboard:** 50 in Python, 46 in TypeScript `
` **Total:** 146 tests | *We use Vitest for TS-based microservices and the client. We use PyTest for python-based microservices.* | +| **Integration Testing** | **10 tests total** covering interactions between features. | *Running tests in their environment with microservices fully operational and communicating with each other. These tests have to be executed locally as CI/CD does not have enough flexibility for this.* | +| **Acceptance Testing** | **End-user testing** for every user story. | *Team members/external users will perform Manual Walkthroughs based on User Story criteria. Deficiencies and potential improvements will be documented.* | +| **Regression Testing** | Unit\+ Integration tests executed on **every push to main branch**. | *We have configured a GitHub Actions CI pipeline to run all tests automatically.* | **2.1.2 CI/CD Regression Workflows** @@ -126,19 +122,22 @@ When a commit is pushed to a feature branch, github actions will also build and 2. ## **Mutation Testing (Test effectiveness)** -*Skip for Sprint 2\.* +*We used mutmut for mutation testing in our python modules: analytics and market. Separate docker files exist for mutation testing if you want to try running things locally.* + +Stryker is the main supported mutation testing tool for JS and TS, but it is not supported in bun. There is a community-made plugin to enable stryker for bun, but it is in its earliest stages of development. Stryker executes, but can't gather coverage from tests in bun, so it appears as if no mutants are getting killed no matter what is done in unit tests. + +We attempted to run stryker with vitest in node, but this results in a lot of library conflicts and version mismatches as bun and node use the same exact configuration and organization files for packages. 3. ## **Load Testing** -*Skip for Sprint 2\.* +*Skip for Sprint 3\.* -3. # **Terms/Acronyms** +3. # **Terms/Acronyms** Make a mention of any terms or acronyms used in the project -| TERM/ACRONYM | DEFINITION | -| :---- | :---- | -| API | Application Program Interface | -| AUT | Application Under Test | -| JWT | JSON Web Token | - +| TERM/ACRONYM | DEFINITION | +| :----------- | :---------------------------- | +| API | Application Program Interface | +| AUT | Application Under Test | +| JWT | JSON Web Token | diff --git a/documentation/acceptance tests/goals.md b/documentation/acceptance tests/goals.md new file mode 100644 index 0000000..e5a1a66 --- /dev/null +++ b/documentation/acceptance tests/goals.md @@ -0,0 +1,21 @@ +Acceptance criteria from user story: + +1. User budget is aligned with the selected financial goal + +2. User budget recommends changes to the user’s financial habits, should the user stray away from their financial goal + +3. Users can set goals with an amount, data, and goal type ("reduce spending") + + +The above criteria can be tested through the client by following the steps below. + +1. Log in with an email j@j.com and password pwd +2. Observe that there is a generated budget. Pick a random category that has a budget generated for it at the weekly period. +3. Locate a checkmark in the top left corner of the page and hover your mouse over it +4. Observe the goals panel appearing to the right of the checkmark +5. Assuming that this is a pre-populated account, there may be strangely-named goals. Click the "+ Add Goal" button +6. Observe a new goal appearing in the panel - this meets the 3rd acceptance criteria +7. Click on the goal to expand it in more detail and make it editable +8. Can change any field in the goal, but for the scenario, set the goal type to 'Save Money' and category to the random category that you picked in step 2. Apply the edit +9. Refresh the page or change the period of the budget and go back to monthly, and observe that the budget is readjusted. If your category was considered a necessity, it's budget is likely increased, if it is a want, it is likely lowered. More of your average income is allocated to savings at a flat 5% rate. This meets the 1st acceptance criteria +10. Change the created goal's type to "Reduce Spending", and refresh the budget after applying edits. Depending on how close your expenses are to the target for the selected period, observe that the budget is adjusted with varying levels of punishment for wants. The category "entertainment", is classified as a want internally, as an example. Overall, the budget for needs is also slightly reduced. This meets the 2nd acceptance criteria as needed. diff --git a/documentation/sequence-diagrams/goals/Goals Sequence Diagram.png b/documentation/sequence-diagrams/goals/Goals Sequence Diagram.png new file mode 100644 index 0000000..d653ca9 Binary files /dev/null and b/documentation/sequence-diagrams/goals/Goals Sequence Diagram.png differ diff --git a/documentation/sequence-diagrams/stockTracking/SeqDiagramStock.png b/documentation/sequence-diagrams/stockTracking/SeqDiagramStock.png new file mode 100644 index 0000000..bebfcef Binary files /dev/null and b/documentation/sequence-diagrams/stockTracking/SeqDiagramStock.png differ diff --git a/documentation/sequence-diagrams/user-projection/user-flow-diagram.png b/documentation/sequence-diagrams/user-projection/user-flow-diagram.png new file mode 100644 index 0000000..e40ca92 Binary files /dev/null and b/documentation/sequence-diagrams/user-projection/user-flow-diagram.png differ diff --git a/meeting-minutes/Meeting_10_Mar-23.md b/meeting-minutes/Meeting_10_Mar-23.md new file mode 100644 index 0000000..b23cd66 --- /dev/null +++ b/meeting-minutes/Meeting_10_Mar-23.md @@ -0,0 +1,85 @@ +Sprint 3 rubric + +* All 6 features completed - no bugs +* Code deployed to production environment +* Fully working CI/CD pipeline +* Sequence diagrams for every feature +* 100% backend test coverage & 10 integration tests per feature +* Acceptance test for each feature + +Announcements: + +Dev is now protected. + +Are pre-commit hooks working for everybody? + +Completed + +Feature 3 - Financial projections for debt and savings** ** ** ** (Huy, Jackie) + +* Can create and view new debts +* Endpoints for debt calculation set up, no calculation +* API calls being made +* Graph exists + +Feature 4 - Financial goals ** ** ** ** ** ** ** ** (Roman, Logan) + +* Almost done! +* Hitch with python - budget generation +* Need tests + +Feature 6 - Market and FOREX portfolio lookup. ** ** ** ** ** **(Dylan, Deep) + +* Server side logic complete +* Unit tests ½ done + +CI/CD** ** ** ** ** ** ** ** ** ** ** ** (Logan) + +* AWS environment is set up 🎉 + +Features TODO: + +Feature 3 - Financial projections for debt and savings ** ** ** ** (Huy, Jackie) + +* 10 Mutation tests +* 10 integration tests + +Feature 4 - Financial goals ** ** ** ** ** ** ** ** (Roman, Logan) + +* 10 Mutation tests +* 10 integration tests +* Debug budget + +Feature 6 - Market and FOREX portfolio lookup. ** ** ** ** ** **(Dylan, Deep) + +* 10 Mutation tests +* 10 integration tests +* Visualisation wrap up + +Other TODO + +10 Integration tests for each feature. + +Multi-service tests? + +Assign one person to each feature’s tests + +Set up CD + +Set up a multi container integration test environment. + +Logan - User auth integration tests + +Deep - Forex + +Roman - Financial Goals + +Dylan - User input + +Jackie - Financial projections + +Huy - Visualisation + +Also need to update the testing plan to include what we did for integration and mutation testing + +** diff --git a/meeting-minutes/Meeting_8_Mar-04.md b/meeting-minutes/Meeting_8_Mar-04.md new file mode 100644 index 0000000..a1e1410 --- /dev/null +++ b/meeting-minutes/Meeting_8_Mar-04.md @@ -0,0 +1,40 @@ +Agenda + +* Updates +* Cover new & catch-up work +* Assign work and set deadlines + +Features: + +* Visualization dashboard: Roman, Huy + + * 10 unit tests / 100% backend coverage + * Sequence diagram + * Calculations +* User Authentication: Logan & Deep +* Transition to shadcn +* 100% backend coverage +* Sequence diagram +* User Data input: Jackie & Dylan +* Form communicates w/ server +* 10 unit tests / 100% coverage +* Sequence diagram +* Bug squashing +* Test plan +* Complete it + +TODO: + +* Sprint 1 feedback + * Add a high-level architecture diagram directly to your GitHub README to give a quick technical overview. + * Aim for more detail in your documentation and task planning. + * Group your tasks clearly into their respective sprints. +* Wrap up +* Close complete dev tasks and features on github +* All meeting minutes on github wiki - Logan + +Features to take next sprint + +Technical seminar + +* Microservices, Docker? diff --git a/meeting-minutes/Meeting_9_Mar-08.md b/meeting-minutes/Meeting_9_Mar-08.md new file mode 100644 index 0000000..86dc4aa --- /dev/null +++ b/meeting-minutes/Meeting_9_Mar-08.md @@ -0,0 +1,37 @@ +Agenda + +Features: + +* Visualization dashboard: Roman, Huy + * 10 unit tests / 100% backend coverage - WIll do + * Sequence diagram +* User Data input: Jackie & Dylan +* Form communicates w/ server +* 10 unit tests / 100% coverage - make sure coverage is 100% +* Bug squashing - Do this as new features get developed + +Wrap up: + +* Close complete dev tasks and features on github + +Assign Features for next sprint + +Feature 3 - Financial projections for debt and savings - Huy, Jackie + +Feature 4 - Financial goals - Roman, Logan + +Feature 6 - Market and FOREX portfolio lookup. - Dylan, Deep + +Technical seminar + +* Cinemate: Basic +* Course hub: not bad +* GetEmployedCS: Basic +* MarketSafe - don’t know about environment files (duplicated envs), don’t know how to init db +* Umanitoba guesser: Basic, + +No one using repo caching + +Next meeting - Sunday 6 pm/ Monday + +**