diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 875c8284..c6096022 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -105,6 +105,153 @@ jobs: run: cargo build --all --verbose # ───────────────────────────────────────────── + coverage: + name: Contract Coverage (>= 80%) + runs-on: ubuntu-latest + needs: build + defaults: + run: + working-directory: contracts + env: + CARGO_TERM_COLOR: always + RUSTUP_TOOLCHAIN: stable + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain (with LLVM tools for tarpaulin) + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - name: Install cargo-binstall + run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash + - name: Install cargo-tarpaulin (pre-built binary) + run: cargo binstall cargo-tarpaulin -y + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contracts/target + key: ${{ runner.os }}-cargo-coverage-${{ hashFiles('''contracts/Cargo.lock''') }} + restore-keys: | + ${{ runner.os }}-cargo-coverage- + ${{ runner.os }}-cargo- + - name: Run per-crate coverage (fail under 80%) + run: | + set -euo pipefail + mkdir -p ../coverage/contracts + echo "# Contract coverage report" > ../coverage/contracts/SUMMARY.md + OVERALL_STATUS=0 + # Soroban contracts compile to wasm32/cdylib but their tests run on + # the host (x86_64). `--engine llvm` instruments the host test + # binaries; requires the llvm-tools-preview component installed above. + for crate in access_control common_types manage_hub membership_token payment_escrow resource_credits workspace_booking; do + if [ ! -f "$crate/Cargo.toml" ]; then + echo "::warning::Skipping missing crate $crate" + continue + fi + echo "" + echo "" + echo "========================================================" + echo " Coverage for crate: $crate" + echo "========================================================" + if ! cargo tarpaulin \ + --manifest-path "$crate/Cargo.toml" \ + --engine llvm \ + --timeout 300 \ + --fail-under 80 \ + --out Xml Stdout \ + --output-dir "../coverage/contracts/$crate"; then + echo "::error file=$crate::Coverage for $crate is below 80% (or tarpaulin failed)." + OVERALL_STATUS=1 + fi + done + if [ "$OVERALL_STATUS" -ne 0 ]; then + echo "::error::One or more contract crates fell below the 80% coverage threshold." + exit 1 + fi + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: contract-coverage + path: coverage/contracts + if-no-files-found: ignore + + - name: Upload coverage to Codecov (contracts) + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ../coverage/contracts + flags: contracts + fail_ci_if_error: true + verbose: true + + # ───────────────────────────────────────────── + # NO PLACEHOLDER TESTS + # Fails if any test file contains placeholder-style content or filenames. + # Placeholder tests (e.g. expect(1+1).toBe(2)) are often created as + # scaffolding and accidentally committed. + # ───────────────────────────────────────────── + no-placeholders: + name: No Placeholder Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check for placeholder test files + run: | + set -euo pipefail + HAS_PLACEHOLDER=false + + # Check 1: Filename-based — flag files with "placeholder" or "template" + # in the name that look like test files. + # Exclude node_modules/ to avoid false positives from dependency files. + MATCHES_BY_NAME=$(find . -type f -not -path '*/node_modules/*' \ + \( -name '*placeholder*' -o -name '*template*' \) \ + \( -name '*.test.*' -o -name '*.spec.*' \) 2>/dev/null || true) + + if [ -n "$MATCHES_BY_NAME" ]; then + echo "::error::Test files with placeholder/template in filename:" + echo "$MATCHES_BY_NAME" | sed 's/^/ /' + HAS_PLACEHOLDER=true + fi + + # Check 2: Content-based — flag test files whose describe() or it() + # description suggests a placeholder or template. + if command -v rg &>/dev/null; then + MATCHES_BY_CONTENT=$( + rg -l \ + --glob '*.test.*' \ + --glob '*.spec.*' \ + -e 'describe\(("placeholder"|'"'"'placeholder'"'"'|"template"|'"'"'template'"'"')' \ + -e 'it\(("should pass"|'"'"'should pass'"'"'|"is a placeholder"|'"'"'is a placeholder'"'"')' \ + 2>/dev/null || true + ) + else + # fallback to grep + MATCHES_BY_CONTENT=$( + grep -rl \ + --include='*.test.*' \ + --include='*.spec.*' \ + -E "describe\(\"placeholder\"|describe\('placeholder'|describe\(\"template\"|describe\('template'|it\(\"should pass\"|it\('should pass'|it\(\"is a placeholder\"|it\('is a placeholder'" \ + 2>/dev/null || true + ) + fi + + if [ -n "$MATCHES_BY_CONTENT" ]; then + echo "::error::Test files with placeholder-style descriptions:" + echo "$MATCHES_BY_CONTENT" | sed 's/^/ /' + HAS_PLACEHOLDER=true + fi + + if [ "$HAS_PLACEHOLDER" = true ]; then + echo "" + echo "::error::Placeholder test files detected — remove or replace them with real tests." + exit 1 + fi + + echo " No placeholder test files found." + # SEMGREP — Reentrancy guard enforcement # (TEMPORARILY DISABLED — uncomment to re-enable) # ───────────────────────────────────────────── @@ -165,8 +312,17 @@ jobs: - name: Build (TypeScript check) run: npm run build - - name: Unit tests - run: npm run test -- --passWithNoTests + - name: Unit tests with coverage + run: npm run test:cov -- --passWithNoTests + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/lcov.info + flags: backend + fail_ci_if_error: true + verbose: true # ───────────────────────────────────────────── # FRONTEND — Next.js @@ -196,7 +352,155 @@ jobs: - name: Lint run: npm run lint + - name: Tests with coverage + run: npm run test:cov + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/lcov.info + flags: frontend + fail_ci_if_error: true + verbose: true + - name: Build run: npm run build env: NEXT_PUBLIC_API_URL: http://localhost:3001 + + # ───────────────────────────────────────────── + # DATA-MODEL SYNC CHECK + # Fails the build if any TypeORM entity file or migration is modified + # without updating backend/docs/data-model.md. + # See https://github.com/NovaCoreLabs1/NovaLabs/issues/140 + # ───────────────────────────────────────────── + verify-data-model: + name: Verify Data Model Sync + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Detect entity/migration changes without doc update + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + run: | + set -euo pipefail + # Determine which commits to diff against depending on event type. + # - For pull_request: compare PR head against the PR's base SHA. + # - For push (incl. merges): compare HEAD against the previous SHA. + # - Fallback (e.g. workflow_dispatch): skip the check. + case "$EVENT_NAME" in + pull_request) + BASE_SHA="$PR_BASE_SHA" + ;; + push) + BASE_SHA="$PUSH_BASE_SHA" + ;; + *) + echo "Skipping data-model sync check for event: $EVENT_NAME" + exit 0 + ;; + esac + if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + echo "::warning::No base SHA available; skipping data-model sync check." + exit 0 + fi + git fetch --no-tags --depth=1 origin "$BASE_SHA" + ENTITY_CHANGED="$(git diff --name-only "$BASE_SHA"...HEAD | grep -E '(^|/)entities/.*\.entity\.ts$' || true)" + MIGRATION_CHANGED="$(git diff --name-only "$BASE_SHA"...HEAD | grep -E '(^|/)migrations/.*\.ts$' || true)" + DOC_CHANGED="$(git diff --name-only "$BASE_SHA"...HEAD | grep -E '(^|/)backend/docs/data-model\.md$' || true)" + SCHEMA_CHANGED="$ENTITY_CHANGED$MIGRATION_CHANGED" + if [ -n "$SCHEMA_CHANGED" ] && [ -z "$DOC_CHANGED" ]; then + { + echo "::error::TypeORM entities or migrations were modified but backend/docs/data-model.md was not updated." + echo "::error::Files changed:" + [ -n "$ENTITY_CHANGED" ] && printf '::error file=%s::Entity file changed without docs update\n' $ENTITY_CHANGED + [ -n "$MIGRATION_CHANGED" ] && printf '::error file=%s::Migration file changed without docs update\n' $MIGRATION_CHANGED + echo "::error::Please update backend/docs/data-model.md to keep the ER diagram and field reference in sync." + } + exit 1 + fi + echo "No entity/migration/doc sync issues detected." + if [ -n "$SCHEMA_CHANGED" ]; then + echo "Schema files changed (and docs updated):" + printf ' %s\n' $SCHEMA_CHANGED + fi + + # ───────────────────────────────────────────── + # AUTO-GENERATE ER DIAGRAM (erdia) + # Regenerates the Mermaid ER diagram from TypeORM entities and fails + # if the generated output differs from what is committed. Developers + # run `npm run db:diagram` locally after entity changes. + # See https://github.com/NovaCoreLabs1/NovaLabs/issues/140 + # ───────────────────────────────────────────── + generate-er-diagram: + name: Generate ER Diagram + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + defaults: + run: + working-directory: backend + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: novalabs + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d novalabs" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: backend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Generate ER diagram with erdia + run: npm run db:diagram + env: + DATABASE_HOST: localhost + DATABASE_PORT: 5432 + DATABASE_USERNAME: postgres + DATABASE_PASSWORD: password + DATABASE_NAME: novalabs + + - name: Check generated docs match committed version + run: | + OUTPUT_DIR="docs/erd" + if [ -d "$OUTPUT_DIR" ]; then + CHANGES="$(git status --porcelain -- "$OUTPUT_DIR/" 2>/dev/null || true)" + if [ -z "$CHANGES" ]; then + echo "Generated ER diagram matches committed version." + else + echo "Generated ER diagram differs from committed version." + echo " Run 'npm run db:diagram' locally and commit the changes." + echo "" + git status -- "$OUTPUT_DIR/" + exit 1 + fi + else + echo "erdia output directory not found - run erdia locally first." + echo " npm run db:diagram" + echo " Then commit the docs/erd/ directory." + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..6aa9915b --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# dependencies +node_modules/ +.pnp +.pnp.js + +# testing +coverage/ +.nyc_output/ + +# next.js +.next/ +out/ + +# production +build/ +dist/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.idea/ +*.swp +*.swo +*~ + +# TypeScript +*.tsbuildinfo + +# Stryker temp files +.stryker-tmp + +# Logs +logs/ +*.log + +# Stryker mutation testing output +reports/ + +# OS files +Thumbs.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..dfa3370e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,165 @@ +# Contributing to NovaLabs + +Thanks for your interest in contributing to NovaLabs! 🎉 +This guide covers how to set up your environment, our coding conventions, and the +process for opening pull requests. + +--- + +## 1. Workflow + +1. Fork the repository (or work directly on a feature branch if you have access). +2. Create a feature branch: + ```bash + git checkout -b feat/your-feature-name + ``` +3. Make your changes and commit using **Conventional Commits**: + ``` + feat(scope): short summary + fix(scope): short summary + docs(scope): short summary + test(scope): short summary + refactor(scope): short summary + chore(scope): short summary + ``` +4. Push your branch: + ```bash + git push origin feat/your-feature-name + ``` +5. Open a Pull Request against `main`. Use `Closes #` in the + description for any issue your PR resolves. + +--- + +## 2. Coding conventions + +### Backend (NestJS / TypeORM) +- Stick to the existing module structure under `backend/src//`. +- All entities live in `/entities/*.entity.ts` and **must** be registered + in the relevant module's `TypeOrmModule.forFeature([...])`. +- Use DTOs (with `class-validator`) for every request body — never accept raw + `any`. +- Money is always stored as `bigint` in **kobo** (smallest currency unit). +- Sensitive columns (passwords, OTP secrets, tokens) **must** be decorated with + `@Exclude()` so they are stripped from JSON responses. + +### Frontend (Next.js / React) +- Use the components in `frontend/components/ui/` (shadcn-style) for primitives. +- Tailwind CSS for styling — no inline styles or external CSS modules. +- All API calls go through `frontend/lib/apiClient.ts`; do not call `fetch` + directly from components. +- React Query handles server state; local UI state uses `useState`/`useReducer`. + +### Contracts (Soroban / Rust) +- Every contract crate lives at `contracts//` with its own `Cargo.toml`. +- Public entry points are `pub fn` inside an `#[contractimpl] impl ContractName`. +- Errors live in `errors.rs` using `#[contracterror]` with explicit `#[repr(u32)]` + discriminants. +- Storage keys live in `lib.rs` (or a dedicated `storage.rs`) as a + `#[contracttype] pub enum DataKey`. +- **Every contract must have at least 80% line coverage** — enforced by the + `coverage` CI job. See `.github/workflows/CI.yaml`. + +--- + +## 3. Data-model sync rule + +Whenever you **add, rename, or remove** a TypeORM entity, column, index, or +relationship — or any database migration — you **must** also update +[`backend/docs/data-model.md`](backend/docs/data-model.md) in the same PR. + +The **`verify-data-model`** CI job will fail any PR that: + +1. Touches a path matching `entities/*.entity.ts` or `migrations/*.ts`, **and** +2. Does not also touch `backend/docs/data-model.md`. + +The file contains: + +- A Mermaid `erDiagram` block with all entities, columns, and relationships. +- A relationship cheat-sheet table. +- An index summary table. +- A conventions section. + +When updating it, render the Mermaid locally (GitHub, mermaid.live, or any IDE +preview) to confirm the diagram parses before committing. + +--- + +## 4. Pull-request checklist + +Before opening a PR, confirm: + +- [ ] `cd backend && npm run lint && npm run build` passes +- [ ] `cd frontend && npm run lint && npm run build` passes +- [ ] `cd contracts && cargo fmt --all -- --check` passes +- [ ] `cd contracts && cargo clippy --all-targets --all-features -- -D warnings` + passes +- [ ] `cd contracts && cargo test --all` passes +- [ ] `backend/docs/data-model.md` updated (if any entity/migration changed) +- [ ] Commit messages follow Conventional Commits +- [ ] PR description includes `Closes #` for each issue resolved + +--- + +## 5. Code coverage + +Every PR and push to `main` automatically uploads coverage reports to +[Codecov](https://codecov.io/gh/NovaCoreLabs1/NovaLabs). The dashboard shows +historical trends and per-file breakdowns for each area. + +### Running coverage locally + +**Backend** (Jest) +```bash +cd backend +npm run test:cov +``` +Output: `backend/coverage/lcov.info` and an HTML report. + +**Frontend** (Vitest) +```bash +cd frontend +npm run test:cov +``` +Output: `frontend/coverage/lcov.info` and a terminal summary. + +**Contracts** (cargo-tarpaulin) +```bash +cd contracts +cargo tarpaulin --all --engine llvm --out Xml --fail-under 80 +``` +`cargo-tarpaulin` must be installed first (`cargo install cargo-tarpaulin` or +`cargo binstall cargo-tarpaulin`). + +### Current targets + +| Area | Target | Threshold | Tool | +|------|-------:|----------:|------| +| Contracts | 80% | 5% | cargo-tarpaulin | +| Backend | 80% | 5% | Jest | +| Frontend | 20% | 5% | Vitest | + +Targets are intentionally set near current levels and will be raised as more +tests are written. The Codecov status check will not block your PR — it appears +as a comment so you can track progress over time. + +See [`codecov.yml`](codecov.yml) for the full configuration. + +--- + +## 6. Reporting bugs + +Open an issue with: + +- A clear, descriptive title. +- Steps to reproduce (or a minimal snippet). +- Expected vs. actual behaviour. +- Environment details (Node version, OS, browser if applicable). + +Security issues should **not** be filed as public issues — follow the +[security policy](SECURITY.md) if one exists, otherwise contact a maintainer +directly. + +--- + +Built with ❤️ by the NovaLabs community. \ No newline at end of file diff --git a/README.md b/README.md index f5412af3..64f33d2c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ NovaLabs is a full-stack platform built to power the next generation of coworkin [![Live Demo](https://img.shields.io/badge/Live%20Demo-novalabs.vercel.app-black?style=for-the-badge&logo=vercel)](https://novalabs.vercel.app/) [![License](https://img.shields.io/badge/License-MIT-blue?style=for-the-badge)](LICENSE) [![Contributors](https://img.shields.io/badge/Contributors-96%2B-brightgreen?style=for-the-badge)](https://github.com/NovaCoreLabs1/NovaLabs/graphs/contributors) +[![Contracts](https://img.shields.io/codecov/c/github/NovaCoreLabs1/NovaLabs?flag=contracts&label=Contracts&logo=codecov&style=for-the-badge)](https://codecov.io/gh/NovaCoreLabs1/NovaLabs) +[![Backend](https://img.shields.io/codecov/c/github/NovaCoreLabs1/NovaLabs?flag=backend&label=Backend&logo=codecov&style=for-the-badge)](https://codecov.io/gh/NovaCoreLabs1/NovaLabs) +[![Frontend](https://img.shields.io/codecov/c/github/NovaCoreLabs1/NovaLabs?flag=frontend&label=Frontend&logo=codecov&style=for-the-badge)](https://codecov.io/gh/NovaCoreLabs1/NovaLabs) @@ -19,16 +22,17 @@ NovaLabs is a full-stack platform built to power the next generation of coworkin 1. [About](#about) 2. [Key Features](#key-features) 3. [Tech Stack](#tech-stack) -4. [Getting Started](#getting-started) +4. [Code Coverage](#code-coverage) +5. [Getting Started](#getting-started) - [Prerequisites](#prerequisites) - [Installation](#installation) - [Environment Variables](#environment-variables) -5. [Usage](#usage) -6. [Project Structure](#project-structure) -7. [Contributing](#contributing) -8. [Roadmap](#roadmap) -9. [License](#license) -10. [Acknowledgements](#acknowledgements) +6. [Usage](#usage) +7. [Project Structure](#project-structure) +8. [Contributing](#contributing) +9. [Roadmap](#roadmap) +10. [License](#license) +11. [Acknowledgements](#acknowledgements) --- @@ -78,6 +82,20 @@ NovaLabs uses a modern, production-grade full-stack technology stack: --- +## Code Coverage + +Code coverage is automatically measured and uploaded to [Codecov](https://codecov.io/gh/NovaCoreLabs1/NovaLabs) on every PR and push to `main`. The badges above show the current line coverage for each area of the codebase: + +| Badge | Area | Tool | Target | +|-------|------|------|--------| +| **Contracts** | Rust smart contracts (`contracts/`) | `cargo-tarpaulin` | ≥ 80% | +| **Backend** | NestJS TypeScript API (`backend/src/`) | Jest (`test:cov`) | ≥ 80% | +| **Frontend** | Next.js app (`frontend/`) | Vitest (`test:cov`) | ≥ 20% | + +Targets are configured in [`codecov.yml`](codecov.yml) and enforced via GitHub Actions status checks. You can drill into per-file coverage and historical trends on the [Codecov dashboard](https://codecov.io/gh/NovaCoreLabs1/NovaLabs). + +--- + ## Getting Started Follow these steps to run NovaLabs locally for development or testing. @@ -200,7 +218,7 @@ Contributions are welcome! Whether it's a bug fix, new feature, documentation im 4. Push to your fork: `git push origin feature/your-feature-name` 5. Open a Pull Request against `main` -Please follow the existing code style, naming conventions, and module architecture when contributing. See [CONTRIBUTING.md](CONTRIBUTING.md) if available. +Please follow the existing code style, naming conventions, and module architecture when contributing. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full checklist, and the [PostgreSQL data model](backend/docs/data-model.md) if your change touches any entity, migration, or schema. --- @@ -211,7 +229,7 @@ Please follow the existing code style, naming conventions, and module architectu - [ ] Multi-hub / multi-location support - [ ] Public API for third-party integrations - [ ] Webhook support for payment providers -- [ ] Dark mode UI +- [x] Dark mode UI (next-themes + ThemeToggle component) - [ ] Offline biometric fallback support --- diff --git a/backend/README.md b/backend/README.md index 496db2d5..0190c2f6 100644 --- a/backend/README.md +++ b/backend/README.md @@ -146,6 +146,13 @@ npm run typeorm:revert-migration --- +## Data Model + +See [docs/data-model.md](./docs/data-model.md) for the full PostgreSQL entity-relationship diagram, +table definitions, indexes, and relationship documentation. + +--- + ## License [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE) diff --git a/backend/docs/data-model.md b/backend/docs/data-model.md new file mode 100644 index 00000000..35cf495f --- /dev/null +++ b/backend/docs/data-model.md @@ -0,0 +1,397 @@ +# NovaLabs — PostgreSQL Data Model + +> **Source of truth:** this document is hand-maintained from the TypeORM entities under +> `backend/src/**/entities/*.entity.ts`. The **CI "Verify Data Model Sync" job** in +> `.github/workflows/CI.yaml` fails any pull request that modifies a `.entity.ts` file +> without also updating this file. +> +> **Maintainers' rule:** every PR that adds, removes, or renames a column, table, or +> relationship must update the corresponding section below. Reviewers should block +> merges that change entities but leave this document untouched. + +--- + +## 1. Entity-Relationship Overview + +```mermaid +erDiagram + USER ||--o{ REFRESH_TOKEN : "has" + USER ||--o{ BOOKING : "creates" + USER ||--o{ PAYMENT : "initiates" + USER ||--o{ INVOICE : "receives" + USER ||--o{ WORKSPACE_LOG : "generates" + USER ||--o{ NOTIFICATION : "receives" + + WORKSPACE ||--o{ BOOKING : "is booked as" + WORKSPACE ||--o{ WORKSPACE_LOG : "is logged in" + + BOOKING ||--o{ PAYMENT : "is paid by" + BOOKING ||--o{ INVOICE : "produces" + BOOKING ||--o{ WORKSPACE_LOG : "may be linked to" + + PAYMENT ||--o| INVOICE : "satisfies (nullable)" + + USER { + uuid id PK + string firstname + string lastname + string username "nullable" + string email "unique" + string password "excluded" + enum role "USER | ADMIN | ..." + string passwordResetToken "excluded, nullable" + timestamptz passwordResetExpiresIn "excluded, nullable" + timestamptz lastPasswordResetSentAt "excluded, nullable" + string verificationToken "excluded, nullable" + timestamptz verificationTokenExpiry "excluded, nullable" + timestamptz lastVerificationEmailSent "excluded, nullable" + string verificationCode "excluded, nullable" + timestamptz verificationCodeExpiresAt "excluded, nullable" + string passwordResetCode "excluded, nullable" + timestamptz passwordResetCodeExpiresAt "excluded, nullable" + bool isVerified + bool isActive + bool isDeleted + bool isSuspended + string profilePicture "nullable, varchar(500)" + string phone "nullable, varchar(15)" + bool twoFactorEnabled + string totpSecret "excluded, nullable, varchar(255)" + jsonb totpBackupCodes "excluded, nullable" + enum membershipStatus "INACTIVE | ACTIVE | ..." + timestamptz memberSince "nullable" + int profileCompleteness "default 0" + timestamptz createdAt + timestamptz updatedAt + timestamptz deletedAt "soft delete" + } + + REFRESH_TOKEN { + uuid id PK + uuid userId FK "indexed, ON DELETE CASCADE" + text token "unique index" + string familyId "varchar(255), token rotation family" + int version "rotation version counter" + timestamptz expiresAt "nullable" + bool revoked + timestamptz consumedAt "nullable" + timestamptz createdAt + timestamptz updatedAt + } + + WORKSPACE { + uuid id PK + string name + enum type "HOT_DESK | DEDICATED_DESK | PRIVATE_OFFICE | MEETING_ROOM | VIRTUAL | HYBRID" + int totalSeats "default 1" + int availableSeats "default 1" + bigint hourlyRate "amount in kobo" + text description "nullable" + text[] amenities "nullable, TypeORM simple-array" + text[] images "nullable, TypeORM simple-array" + bool isActive + timestamptz createdAt + timestamptz updatedAt + } + + BOOKING { + uuid id PK + uuid userId FK "nullable, ON DELETE RESTRICT, indexed" + uuid workspaceId FK "ON DELETE RESTRICT, indexed" + enum planType "DAILY | WEEKLY | MONTHLY | QUARTERLY | YEARLY" + date startDate + date endDate + bigint totalAmount "amount in kobo" + enum status "PENDING | CONFIRMED | CANCELLED | ..." + int seatCount "default 1" + text notes "nullable" + string sorobanEscrowId "nullable" + bool reminderSent + bool isGuestBooking + jsonb guestInfo "nullable {name,email,phone}" + timestamptz createdAt + timestamptz updatedAt + } + + PAYMENT { + uuid id PK + uuid bookingId FK "indexed, ON DELETE RESTRICT" + uuid userId FK "nullable, indexed, ON DELETE RESTRICT" + bigint amount "amount in kobo" + varchar currency "ISO-4217, default 'NGN'" + enum provider "PAYSTACK | SOROBAN" + string providerReference "indexed, nullable" + enum status "PENDING | SUCCESSFUL | FAILED | ..." + timestamptz paidAt "nullable" + jsonb metadata "nullable" + timestamptz createdAt + timestamptz updatedAt + } + + INVOICE { + uuid id PK + varchar invoiceNumber "unique, INV-00001" + uuid userId FK "indexed, ON DELETE RESTRICT" + uuid bookingId FK "indexed, ON DELETE RESTRICT" + uuid paymentId FK "nullable, ON DELETE SET NULL" + bigint amountKobo "amount in kobo" + varchar currency "default 'NGN'" + enum status "PENDING | PAID | VOID" + timestamptz paidAt "nullable" + jsonb lineItems "immutable snapshot" + timestamptz createdAt + timestamptz updatedAt + } + + WORKSPACE_LOG { + uuid id PK + uuid userId FK "ON DELETE RESTRICT, composite index (userId,checkedInAt)" + uuid workspaceId FK "ON DELETE RESTRICT, composite index (workspaceId,checkedInAt)" + uuid bookingId FK "nullable, ON DELETE SET NULL" + timestamptz checkedInAt + timestamptz checkedOutAt "nullable" + int durationMinutes "nullable, computed on checkout" + text notes "nullable" + string biometricTemplateHash "nullable, varchar(128)" + string biometricStorageReference "nullable, varchar(255)" + string biometricProcessingLocation "nullable, varchar(32)" + string biometricVendor "nullable, varchar(64)" + } + + NOTIFICATION { + uuid id PK + uuid userId FK "ON DELETE CASCADE, composite index (userId,isRead)" + enum type "BOOKING_CONFIRMED | PAYMENT_SUCCESS | ..." + varchar title + text message + bool isRead + jsonb metadata "nullable, refs related entity" + timestamptz createdAt + } + + NEWSLETTER_SUBSCRIBER { + uuid id PK + varchar email "unique, max 254, indexed" + bool isVerified + timestamptz verifiedAt "nullable" + varchar verificationToken "nullable" + timestamptz verificationTokenExpiresAt "nullable" + timestamptz subscribedAt + timestamptz unsubscribedAt "nullable" + bool isActive "indexed" + varchar unsubscribeToken + timestamptz consentedAt "nullable" + varchar ipAddress "nullable, max 64" + timestamptz createdAt + timestamptz updatedAt + timestamptz deletedAt "soft delete" + } + + CONTACT_MESSAGE { + uuid id PK + varchar fullName "max 100" + varchar email "max 254" + varchar phone "nullable, max 20" + varchar company "nullable, max 150" + varchar subject "max 200" + text message + varchar ipAddress "nullable, max 64" + bool isRead + timestamptz createdAt + timestamptz updatedAt + } + + AUDIT_LOG { + uuid id PK + uuid actorId "nullable" + varchar actorEmail "nullable, max 255" + varchar actorRole "nullable, max 50" + varchar action "max 100" + varchar targetType "nullable, max 100" + uuid targetId "nullable" + varchar ipAddress "nullable, max 45" + text userAgent "nullable" + jsonb metadata "nullable" + timestamptz createdAt + } +``` + +--- + +## 2. Enumerations + +### `UserRole` +| Value | Description | +|---|---| +| `super_admin` | Full system access | +| `admin` | Administrative access | +| `staff` | Staff-level access | +| `user` | Regular platform user (default) | + +### `MembershipStatus` +| Value | Description | +|---|---| +| `active` | Active member | +| `inactive` | Not a member (default) | +| `suspended` | Membership suspended | + +### `WorkspaceType` +| Value | Description | +|---|---| +| `HotDesk` | Unassigned hot desk | +| `DedicatedDesk` | Assigned dedicated desk | +| `PrivateOffice` | Private office space | +| `MeetingRoom` | Meeting/conference room | +| `Virtual` | Virtual office | +| `Hybrid` | Hybrid space | + +### `PlanType` +| Value | Description | +|---|---| +| `daily` | Daily booking | +| `weekly` | Weekly booking | +| `monthly` | Monthly booking | +| `quarterly` | Quarterly subscription | +| `yearly` | Annual subscription | + +### `BookingStatus` +| Value | Description | +|---|---| +| `pending` | Awaiting confirmation | +| `confirmed` | Booking confirmed | +| `cancelled` | Booking cancelled | +| `completed` | Booking completed | + +### `PaymentProvider` +| Value | Description | +|---|---| +| `paystack` | Paystack (NGN fiat) | +| `soroban` | Soroban smart contract (Stellar) | + +### `PaymentStatus` +| Value | Description | +|---|---| +| `pending` | Awaiting processing | +| `success` | Payment completed | +| `failed` | Payment failed | +| `refunded` | Payment refunded | + +### `InvoiceStatus` +| Value | Description | +|---|---| +| `pending` | Unpaid | +| `paid` | Settled | +| `void` | Cancelled/voided | + +### `NotificationType` +| Value | Description | +|---|---| +| `booking_confirmed` | Booking confirmed | +| `booking_cancelled` | Booking cancelled | +| `booking_completed` | Booking completed | +| `payment_success` | Payment successful | +| `payment_failed` | Payment failed | +| `payment_refunded` | Payment refunded | +| `invoice_generated` | New invoice | +| `general` | General notification | + +### `AuditAction` +| Value | Description | +|---|---| +| `create` | Resource created | +| `update` | Resource updated | +| `delete` | Resource deleted | +| `refund` | Payment refunded | +| `login` | User logged in | +| `logout` | User logged out | +| `impersonate` | Admin impersonated a user | +| `cancel` | Booking cancelled | +| `auth.refresh.family.revoked` | Refresh token family revoked | + +--- + +## 3. Relationship Cheat-Sheet + +| From | Cardinality | To | Join column | On delete | Nullable | +|---|---|---|---|---|---| +| `User` | 1 → 0..* | `RefreshToken` | `refresh_tokens.userId` | `CASCADE` | no | +| `User` | 1 → 0..* | `Booking` | `bookings.userId` | `RESTRICT` | yes | +| `User` | 1 → 0..* | `Payment` | `payments.userId` | `RESTRICT` | yes | +| `User` | 1 → 0..* | `Invoice` | `invoices.userId` | `RESTRICT` | no | +| `User` | 1 → 0..* | `WorkspaceLog` | `workspace_logs.userId` | `RESTRICT` | no | +| `User` | 1 → 0..* | `Notification` | `notifications.userId` | `CASCADE` | no | +| `Workspace` | 1 → 0..* | `Booking` | `bookings.workspaceId` | `RESTRICT` | no | +| `Workspace` | 1 → 0..* | `WorkspaceLog` | `workspace_logs.workspaceId` | `RESTRICT` | no | +| `Booking` | 1 → 0..* | `Payment` | `payments.bookingId` | `RESTRICT` | no | +| `Booking` | 1 → 0..* | `Invoice` | `invoices.bookingId` | `RESTRICT` | no | +| `Booking` | 1 → 0..* | `WorkspaceLog` | `workspace_logs.bookingId` | `SET NULL` | yes | +| `Payment` | 1 → 0..1 | `Invoice` | `invoices.paymentId` | `SET NULL` | yes | + +`NewsletterSubscriber`, `ContactMessage`, and `AuditLog` are intentionally standalone — they do +not reference any other table via TypeORM relationships and are isolated from the core user domain. + +--- + +## 3. Index Summary + +| Table | Index | Columns | Notes | +|---|---|---|---| +| `users` | PK | `id` | uuid | +| `refresh_tokens` | unique | `token` | hashed | +| `refresh_tokens` | unique | `familyId`, `version` | token rotation | +| `refresh_tokens` | btree | `userId` | | +| `refresh_tokens` | btree | `familyId` | | +| `bookings` | btree | `userId` | | +| `bookings` | btree | `workspaceId` | | +| `bookings` | btree | `status` | | +| `payments` | btree | `bookingId` | | +| `payments` | btree | `userId` | | +| `payments` | btree | `providerReference` | | +| `invoices` | unique | `invoiceNumber` | human-readable | +| `invoices` | btree | `userId` | | +| `invoices` | btree | `bookingId` | | +| `workspace_logs` | composite | `workspaceId`, `checkedInAt` | | +| `workspace_logs` | composite | `userId`, `checkedInAt` | | +| `notifications` | composite | `userId`, `isRead` | | +| `newsletter_subscribers` | unique | `email` | | +| `newsletter_subscribers` | btree | `email` | | +| `newsletter_subscribers` | btree | `isActive` | | +| `newsletter_subscribers` | btree | `isVerified` | | + +--- + +## 4. Conventions + +- **Money:** all monetary amounts are stored in **kobo** (smallest currency unit) as + `bigint` to avoid floating-point errors. +- **Timestamps:** `timestamptz` for absolute instants, `date` for calendar days + (e.g. `Booking.startDate`). +- **Soft deletes:** `User` and `NewsletterSubscriber` use a `deletedAt` column + driven by TypeORM's `@DeleteDateColumn`. All other tables hard-delete. +- **Excluded fields:** sensitive columns on `User` (password, OTP secrets, tokens) + are decorated with `@Exclude()` so they are stripped from JSON responses. + These columns exist in the database (with `varchar`/`text`/`jsonb`/`timestamptz` + types as listed in the ER diagram) but never reach the API consumer. +- **Audit columns:** most entities expose `createdAt` / `updatedAt` via + `@CreateDateColumn` / `@UpdateDateColumn`. +- **Cascade vs Restrict:** cascading deletes are reserved for strictly-owned rows + (refresh tokens, notifications). Domain entities (`Booking`, `Payment`, + `Invoice`, `WorkspaceLog`) refuse to delete their parent user/workspace to + preserve audit trails — `RESTRICT` is enforced at the database level. +- **Biometric data:** `WorkspaceLog` stores only privacy-safe markers (hash, + vendor reference, processing location) — never raw biometric templates. + See [`docs/THREAT-MODEL.md`](../../docs/THREAT-MODEL.md) for the full threat model. + +--- + +## 5. Keeping this document in sync + +The CI workflow includes a **`verify-data-model`** job that: + +1. Computes `git diff --name-only origin/main...HEAD`. +2. If any path matches `\.entity\.ts$` and **no** path matches + `backend/docs/data-model\.md$`, the job fails with an `::error::` annotation. + +To regenerate after a deliberate schema change, edit the Mermaid block above +and (if helpful) regenerate a visual preview with any Mermaid live editor before +committing. diff --git a/backend/docs/erd/backend.md b/backend/docs/erd/backend.md new file mode 100644 index 00000000..cb863b9f --- /dev/null +++ b/backend/docs/erd/backend.md @@ -0,0 +1,239 @@ +# backend + +## ER Diagram + +```mermaid +%%{init: {'theme':'dark'}}%% + +erDiagram + + +"workspaces(Workspace)" { + *uuid id PK + *string name + *enum type + *int totalSeats + *int availableSeats + *bigint hourlyRate + text description + simple-array amenities + simple-array images + *boolean isActive + *timestamp createdAt + *timestamp updatedAt +} + + + + +"refresh_tokens(RefreshToken)" { + *uuid id PK + *uuid userId FK + *text token UK + *varchar(255) familyId UK + *int version UK + timestamptz expiresAt + *boolean revoked + timestamptz consumedAt + *timestamp createdAt + *timestamp updatedAt +} + + +"refresh_tokens(RefreshToken)" }o -- || "users(User)": "userId" + + +"users(User)" { + *uuid id PK + *string firstname + *string lastname + string username + *string email UK + *string password + *enum role + string passwordResetToken + timestamptz passwordResetExpiresIn + timestamptz lastPasswordResetSentAt + string verificationToken + timestamptz verificationTokenExpiry + timestamptz lastVerificationEmailSent + string verificationCode + timestamptz verificationCodeExpiresAt + string passwordResetCode + timestamptz passwordResetCodeExpiresAt + *boolean isVerified + *boolean isActive + *boolean isDeleted + *boolean isSuspended + varchar(500) profilePicture + varchar(15) phone + *timestamp createdAt + *timestamp updatedAt + *boolean twoFactorEnabled + varchar(255) totpSecret + jsonb totpBackupCodes + *enum membershipStatus + timestamptz memberSince + *int profileCompleteness + timestamp deletedAt +} + + + + +"bookings(Booking)" { + *uuid id PK + uuid userId FK + *uuid workspaceId FK + *enum planType + *date startDate + *date endDate + *bigint totalAmount + *enum status + *int seatCount + text notes + string sorobanEscrowId + *boolean reminderSent + *boolean isGuestBooking + jsonb guestInfo + *timestamp createdAt + *timestamp updatedAt +} + + +"bookings(Booking)" }o -- o| "users(User)": "userId" +"bookings(Booking)" }o -- || "workspaces(Workspace)": "workspaceId" + + +"workspace_logs(WorkspaceLog)" { + *uuid id PK + *uuid userId FK + *uuid workspaceId FK + uuid bookingId FK + *timestamptz checkedInAt + timestamptz checkedOutAt + int durationMinutes + text notes + varchar(128) biometricTemplateHash + varchar(255) biometricStorageReference + varchar(32) biometricProcessingLocation + varchar(64) biometricVendor +} + + +"workspace_logs(WorkspaceLog)" }o -- || "users(User)": "userId" +"workspace_logs(WorkspaceLog)" }o -- || "workspaces(Workspace)": "workspaceId" +"workspace_logs(WorkspaceLog)" }o -- o| "bookings(Booking)": "bookingId" + + +"payments(Payment)" { + *uuid id PK + *uuid bookingId FK + uuid userId FK + *bigint amount + *varchar(3) currency + *enum provider + string providerReference + *enum status + timestamptz paidAt + jsonb metadata + *timestamp createdAt + *timestamp updatedAt +} + + +"payments(Payment)" }o -- || "bookings(Booking)": "bookingId" +"payments(Payment)" }o -- o| "users(User)": "userId" + + +"notifications(Notification)" { + *uuid id PK + *uuid userId FK + *enum type + *varchar(255) title + *text message + *boolean isRead + jsonb metadata + *timestamp createdAt +} + + +"notifications(Notification)" }o -- || "users(User)": "userId" + + +"newsletter_subscriber(NewsletterSubscriber)" { + *uuid id PK + *varchar(254) email + *boolean isVerified + timestamptz verifiedAt + varchar(128) verificationToken + timestamptz verificationTokenExpiresAt + *timestamptz subscribedAt + timestamptz unsubscribedAt + *boolean isActive + *varchar(128) unsubscribeToken + timestamptz consentedAt + varchar(64) ipAddress + *timestamp createdAt + *timestamp updatedAt + timestamp deletedAt +} + + + + +"invoices(Invoice)" { + *uuid id PK + *varchar(20) invoiceNumber UK + *uuid userId FK + *uuid bookingId FK + uuid paymentId FK + *bigint amountKobo + *varchar(3) currency + *enum status + timestamptz paidAt + jsonb lineItems + *timestamp createdAt + *timestamp updatedAt +} + + +"invoices(Invoice)" }o -- || "users(User)": "userId" +"invoices(Invoice)" }o -- || "bookings(Booking)": "bookingId" +"invoices(Invoice)" }o -- o| "payments(Payment)": "paymentId" + + +"contact_messages(ContactMessage)" { + *uuid id PK + *varchar(100) fullName + *varchar(254) email + varchar(20) phone + varchar(150) company + *varchar(200) subject + *text message + varchar(64) ipAddress + *boolean isRead + *timestamp createdAt + *timestamp updatedAt +} + + + + +"audit_log(AuditLog)" { + *uuid id PK + uuid actorId + varchar(255) actorEmail + varchar(50) actorRole + *varchar(100) action + varchar(100) targetType + uuid targetId + varchar(45) ipAddress + text userAgent + jsonb metadata + *timestamptz createdAt +} + + + +``` diff --git a/backend/erdiadb.json b/backend/erdiadb.json new file mode 100644 index 00000000..65e75bbb --- /dev/null +++ b/backend/erdiadb.json @@ -0,0 +1,3414 @@ +[ + { + "$kind": "entity", + "name": "Workspace", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "workspaces", + "hasRelation": false + }, + { + "$kind": "entity", + "name": "RefreshToken", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "refresh_tokens", + "hasRelation": true + }, + { + "$kind": "entity", + "name": "User", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "users", + "hasRelation": true + }, + { + "$kind": "entity", + "name": "Booking", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "bookings", + "hasRelation": true + }, + { + "$kind": "entity", + "name": "WorkspaceLog", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "workspace_logs", + "hasRelation": true + }, + { + "$kind": "entity", + "name": "Payment", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "payments", + "hasRelation": true + }, + { + "$kind": "entity", + "name": "Notification", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "notifications", + "dbName": "notifications", + "hasRelation": true + }, + { + "$kind": "entity", + "name": "NewsletterSubscriber", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "newsletter_subscriber", + "hasRelation": false + }, + { + "$kind": "entity", + "name": "Invoice", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "invoices", + "hasRelation": true + }, + { + "$kind": "entity", + "name": "ContactMessage", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "contact_messages", + "hasRelation": false + }, + { + "$kind": "entity", + "name": "AuditLog", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "audit_log", + "hasRelation": false + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "name", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "name", + "attributeKey": [], + "isNullable": "", + "columnType": "*string", + "charset": "", + "columnTypeWithLength": "*string", + "comment": "", + "weight": 705012.25 + }, + { + "$kind": "column", + "name": "type", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "type", + "attributeKey": [], + "isNullable": "", + "columnType": "*enum", + "charset": "", + "columnTypeWithLength": "*enum", + "comment": "", + "weight": 479006.01 + }, + { + "$kind": "column", + "name": "totalSeats", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "totalSeats", + "attributeKey": [], + "isNullable": "", + "columnType": "*int", + "charset": "", + "columnTypeWithLength": "*int", + "comment": "", + "weight": 373006.11 + }, + { + "$kind": "column", + "name": "availableSeats", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "availableSeats", + "attributeKey": [], + "isNullable": "", + "columnType": "*int", + "charset": "", + "columnTypeWithLength": "*int", + "comment": "", + "weight": 373025.04 + }, + { + "$kind": "column", + "name": "hourlyRate", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "hourlyRate", + "attributeKey": [], + "isNullable": "", + "columnType": "*bigint", + "charset": "", + "columnTypeWithLength": "*bigint", + "comment": "", + "weight": 679018.11 + }, + { + "$kind": "column", + "name": "description", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "description", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "text", + "charset": "", + "columnTypeWithLength": "text", + "comment": "", + "weight": 453022.21 + }, + { + "$kind": "column", + "name": "amenities", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "amenities", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "simple-array", + "charset": "", + "columnTypeWithLength": "simple-array", + "comment": "", + "weight": 1238025.13 + }, + { + "$kind": "column", + "name": "images", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "images", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "simple-array", + "charset": "", + "columnTypeWithLength": "simple-array", + "comment": "", + "weight": 1238017.13 + }, + { + "$kind": "column", + "name": "isActive", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "isActive", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778017.07 + }, + { + "$kind": "column", + "name": "createdAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "createdAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022023.08 + }, + { + "$kind": "column", + "name": "updatedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspaces", + "dbName": "updatedAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022005.1 + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "userId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "userId", + "attributeKey": [ + "FK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 10481005.07 + }, + { + "$kind": "column", + "name": "token", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "token", + "attributeKey": [ + "UK" + ], + "isNullable": "", + "columnType": "*text", + "charset": "", + "columnTypeWithLength": "*text", + "comment": "", + "weight": 495006.11 + }, + { + "$kind": "column", + "name": "familyId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "familyId", + "attributeKey": [ + "UK" + ], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(255)", + "comment": "", + "weight": 785020.25 + }, + { + "$kind": "column", + "name": "version", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "version", + "attributeKey": [ + "UK" + ], + "isNullable": "", + "columnType": "*int", + "charset": "", + "columnTypeWithLength": "*int", + "comment": "", + "weight": 373004.21 + }, + { + "$kind": "column", + "name": "expiresAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "expiresAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218021.02 + }, + { + "$kind": "column", + "name": "revoked", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "revoked", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778008.21 + }, + { + "$kind": "column", + "name": "consumedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "consumedAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218023.11 + }, + { + "$kind": "column", + "name": "createdAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "createdAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022023.08 + }, + { + "$kind": "column", + "name": "updatedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "updatedAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022005.1 + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "firstname", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "firstname", + "attributeKey": [], + "isNullable": "", + "columnType": "*string", + "charset": "", + "columnTypeWithLength": "*string", + "comment": "", + "weight": 705020.17 + }, + { + "$kind": "column", + "name": "lastname", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "lastname", + "attributeKey": [], + "isNullable": "", + "columnType": "*string", + "charset": "", + "columnTypeWithLength": "*string", + "comment": "", + "weight": 705014.25 + }, + { + "$kind": "column", + "name": "username", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "username", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "string", + "charset": "", + "columnTypeWithLength": "string", + "comment": "", + "weight": 663005.07 + }, + { + "$kind": "column", + "name": "email", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "email", + "attributeKey": [ + "UK" + ], + "isNullable": "", + "columnType": "*string", + "charset": "", + "columnTypeWithLength": "*string", + "comment": "", + "weight": 705021.13 + }, + { + "$kind": "column", + "name": "password", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "password", + "attributeKey": [], + "isNullable": "", + "columnType": "*string", + "charset": "", + "columnTypeWithLength": "*string", + "comment": "", + "weight": 705010.25 + }, + { + "$kind": "column", + "name": "role", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "role", + "attributeKey": [], + "isNullable": "", + "columnType": "*enum", + "charset": "", + "columnTypeWithLength": "*enum", + "comment": "", + "weight": 479008.11 + }, + { + "$kind": "column", + "name": "passwordResetToken", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "passwordResetToken", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "string", + "charset": "", + "columnTypeWithLength": "string", + "comment": "", + "weight": 663010.25 + }, + { + "$kind": "column", + "name": "passwordResetExpiresIn", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "passwordResetExpiresIn", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218010.25 + }, + { + "$kind": "column", + "name": "lastPasswordResetSentAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "lastPasswordResetSentAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218014.25 + }, + { + "$kind": "column", + "name": "verificationToken", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "verificationToken", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "string", + "charset": "", + "columnTypeWithLength": "string", + "comment": "", + "weight": 663004.21 + }, + { + "$kind": "column", + "name": "verificationTokenExpiry", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "verificationTokenExpiry", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218004.21 + }, + { + "$kind": "column", + "name": "lastVerificationEmailSent", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "lastVerificationEmailSent", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218014.25 + }, + { + "$kind": "column", + "name": "verificationCode", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "verificationCode", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "string", + "charset": "", + "columnTypeWithLength": "string", + "comment": "", + "weight": 663004.21 + }, + { + "$kind": "column", + "name": "verificationCodeExpiresAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "verificationCodeExpiresAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218004.21 + }, + { + "$kind": "column", + "name": "passwordResetCode", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "passwordResetCode", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "string", + "charset": "", + "columnTypeWithLength": "string", + "comment": "", + "weight": 663010.25 + }, + { + "$kind": "column", + "name": "passwordResetCodeExpiresAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "passwordResetCodeExpiresAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218010.25 + }, + { + "$kind": "column", + "name": "isVerified", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "isVerified", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778017.07 + }, + { + "$kind": "column", + "name": "isActive", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "isActive", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778017.07 + }, + { + "$kind": "column", + "name": "isDeleted", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "isDeleted", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778017.07 + }, + { + "$kind": "column", + "name": "isSuspended", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "isSuspended", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778017.07 + }, + { + "$kind": "column", + "name": "profilePicture", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "profilePicture", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(500)", + "comment": "", + "weight": 743010.08 + }, + { + "$kind": "column", + "name": "phone", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "phone", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(15)", + "comment": "", + "weight": 743010.18 + }, + { + "$kind": "column", + "name": "createdAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "createdAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022023.08 + }, + { + "$kind": "column", + "name": "updatedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "updatedAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022005.1 + }, + { + "$kind": "column", + "name": "twoFactorEnabled", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "twoFactorEnabled", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778006.03 + }, + { + "$kind": "column", + "name": "totpSecret", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "totpSecret", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(255)", + "comment": "", + "weight": 743006.11 + }, + { + "$kind": "column", + "name": "totpBackupCodes", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "totpBackupCodes", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "jsonb", + "charset": "", + "columnTypeWithLength": "jsonb", + "comment": "", + "weight": 540006.11 + }, + { + "$kind": "column", + "name": "membershipStatus", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "membershipStatus", + "attributeKey": [], + "isNullable": "", + "columnType": "*enum", + "charset": "", + "columnTypeWithLength": "*enum", + "comment": "", + "weight": 479013.21 + }, + { + "$kind": "column", + "name": "memberSince", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "memberSince", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218013.21 + }, + { + "$kind": "column", + "name": "profileCompleteness", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "profileCompleteness", + "attributeKey": [], + "isNullable": "", + "columnType": "*int", + "charset": "", + "columnTypeWithLength": "*int", + "comment": "", + "weight": 373010.08 + }, + { + "$kind": "column", + "name": "deletedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "deletedAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamp", + "charset": "", + "columnTypeWithLength": "timestamp", + "comment": "", + "weight": 980022.21 + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "userId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "userId", + "attributeKey": [ + "FK" + ], + "isNullable": "nullable", + "columnType": "uuid", + "charset": "", + "columnTypeWithLength": "uuid", + "comment": "", + "weight": 10439005.07 + }, + { + "$kind": "column", + "name": "workspaceId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "workspaceId", + "attributeKey": [ + "FK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 10481003.11 + }, + { + "$kind": "column", + "name": "planType", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "planType", + "attributeKey": [], + "isNullable": "", + "columnType": "*enum", + "charset": "", + "columnTypeWithLength": "*enum", + "comment": "", + "weight": 479010.14 + }, + { + "$kind": "column", + "name": "startDate", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "startDate", + "attributeKey": [], + "isNullable": "", + "columnType": "*date", + "charset": "", + "columnTypeWithLength": "*date", + "comment": "", + "weight": 456007.06 + }, + { + "$kind": "column", + "name": "endDate", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "endDate", + "attributeKey": [], + "isNullable": "", + "columnType": "*date", + "charset": "", + "columnTypeWithLength": "*date", + "comment": "", + "weight": 456021.12 + }, + { + "$kind": "column", + "name": "totalAmount", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "totalAmount", + "attributeKey": [], + "isNullable": "", + "columnType": "*bigint", + "charset": "", + "columnTypeWithLength": "*bigint", + "comment": "", + "weight": 679006.11 + }, + { + "$kind": "column", + "name": "status", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "status", + "attributeKey": [], + "isNullable": "", + "columnType": "*enum", + "charset": "", + "columnTypeWithLength": "*enum", + "comment": "", + "weight": 479007.06 + }, + { + "$kind": "column", + "name": "seatCount", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "seatCount", + "attributeKey": [], + "isNullable": "", + "columnType": "*int", + "charset": "", + "columnTypeWithLength": "*int", + "comment": "", + "weight": 373007.21 + }, + { + "$kind": "column", + "name": "notes", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "notes", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "text", + "charset": "", + "columnTypeWithLength": "text", + "comment": "", + "weight": 453012.11 + }, + { + "$kind": "column", + "name": "sorobanEscrowId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "sorobanEscrowId", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "string", + "charset": "", + "columnTypeWithLength": "string", + "comment": "", + "weight": 663007.11 + }, + { + "$kind": "column", + "name": "reminderSent", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "reminderSent", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778008.21 + }, + { + "$kind": "column", + "name": "isGuestBooking", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "isGuestBooking", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778017.07 + }, + { + "$kind": "column", + "name": "guestInfo", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "guestInfo", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "jsonb", + "charset": "", + "columnTypeWithLength": "jsonb", + "comment": "", + "weight": 540019.05 + }, + { + "$kind": "column", + "name": "createdAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "createdAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022023.08 + }, + { + "$kind": "column", + "name": "updatedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "updatedAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022005.1 + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "userId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "userId", + "attributeKey": [ + "FK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 10481005.07 + }, + { + "$kind": "column", + "name": "workspaceId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "workspaceId", + "attributeKey": [ + "FK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 10481003.11 + }, + { + "$kind": "column", + "name": "bookingId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "bookingId", + "attributeKey": [ + "FK" + ], + "isNullable": "nullable", + "columnType": "uuid", + "charset": "", + "columnTypeWithLength": "uuid", + "comment": "", + "weight": 10439024.11 + }, + { + "$kind": "column", + "name": "checkedInAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "checkedInAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamptz", + "charset": "", + "columnTypeWithLength": "*timestamptz", + "comment": "", + "weight": 1260023.18 + }, + { + "$kind": "column", + "name": "checkedOutAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "checkedOutAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218023.18 + }, + { + "$kind": "column", + "name": "durationMinutes", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "durationMinutes", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "int", + "charset": "", + "columnTypeWithLength": "int", + "comment": "", + "weight": 331022.05 + }, + { + "$kind": "column", + "name": "notes", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "notes", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "text", + "charset": "", + "columnTypeWithLength": "text", + "comment": "", + "weight": 453012.11 + }, + { + "$kind": "column", + "name": "biometricTemplateHash", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "biometricTemplateHash", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(128)", + "comment": "", + "weight": 743024.17 + }, + { + "$kind": "column", + "name": "biometricStorageReference", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "biometricStorageReference", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(255)", + "comment": "", + "weight": 743024.17 + }, + { + "$kind": "column", + "name": "biometricProcessingLocation", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "biometricProcessingLocation", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(32)", + "comment": "", + "weight": 743024.17 + }, + { + "$kind": "column", + "name": "biometricVendor", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "biometricVendor", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(64)", + "comment": "", + "weight": 743024.17 + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "bookingId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "bookingId", + "attributeKey": [ + "FK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 10481024.11 + }, + { + "$kind": "column", + "name": "userId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "userId", + "attributeKey": [ + "FK" + ], + "isNullable": "nullable", + "columnType": "uuid", + "charset": "", + "columnTypeWithLength": "uuid", + "comment": "", + "weight": 10439005.07 + }, + { + "$kind": "column", + "name": "amount", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "amount", + "attributeKey": [], + "isNullable": "", + "columnType": "*bigint", + "charset": "", + "columnTypeWithLength": "*bigint", + "comment": "", + "weight": 679025.13 + }, + { + "$kind": "column", + "name": "currency", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "currency", + "attributeKey": [], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(3)", + "comment": "", + "weight": 785023.05 + }, + { + "$kind": "column", + "name": "provider", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "provider", + "attributeKey": [], + "isNullable": "", + "columnType": "*enum", + "charset": "", + "columnTypeWithLength": "*enum", + "comment": "", + "weight": 479010.08 + }, + { + "$kind": "column", + "name": "providerReference", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "providerReference", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "string", + "charset": "", + "columnTypeWithLength": "string", + "comment": "", + "weight": 663010.08 + }, + { + "$kind": "column", + "name": "status", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "status", + "attributeKey": [], + "isNullable": "", + "columnType": "*enum", + "charset": "", + "columnTypeWithLength": "*enum", + "comment": "", + "weight": 479007.06 + }, + { + "$kind": "column", + "name": "paidAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "paidAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218010.25 + }, + { + "$kind": "column", + "name": "metadata", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "metadata", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "jsonb", + "charset": "", + "columnTypeWithLength": "jsonb", + "comment": "", + "weight": 540013.21 + }, + { + "$kind": "column", + "name": "createdAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "createdAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022023.08 + }, + { + "$kind": "column", + "name": "updatedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "updatedAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022005.1 + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "notifications", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "userId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "notifications", + "dbName": "userId", + "attributeKey": [ + "FK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 10481005.07 + }, + { + "$kind": "column", + "name": "type", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "notifications", + "dbName": "type", + "attributeKey": [], + "isNullable": "", + "columnType": "*enum", + "charset": "", + "columnTypeWithLength": "*enum", + "comment": "", + "weight": 479006.01 + }, + { + "$kind": "column", + "name": "title", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "notifications", + "dbName": "title", + "attributeKey": [], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(255)", + "comment": "", + "weight": 785006.17 + }, + { + "$kind": "column", + "name": "message", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "notifications", + "dbName": "message", + "attributeKey": [], + "isNullable": "", + "columnType": "*text", + "charset": "", + "columnTypeWithLength": "*text", + "comment": "", + "weight": 495013.21 + }, + { + "$kind": "column", + "name": "isRead", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "notifications", + "dbName": "isRead", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778017.07 + }, + { + "$kind": "column", + "name": "metadata", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "notifications", + "dbName": "metadata", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "jsonb", + "charset": "", + "columnTypeWithLength": "jsonb", + "comment": "", + "weight": 540013.21 + }, + { + "$kind": "column", + "name": "createdAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "notifications", + "dbName": "createdAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022023.08 + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "email", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "email", + "attributeKey": [], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(254)", + "comment": "", + "weight": 785021.13 + }, + { + "$kind": "column", + "name": "isVerified", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "isVerified", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778017.07 + }, + { + "$kind": "column", + "name": "verifiedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "verifiedAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218004.21 + }, + { + "$kind": "column", + "name": "verificationToken", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "verificationToken", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(128)", + "comment": "", + "weight": 743004.21 + }, + { + "$kind": "column", + "name": "verificationTokenExpiresAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "verificationTokenExpiresAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218004.21 + }, + { + "$kind": "column", + "name": "subscribedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "subscribedAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamptz", + "charset": "", + "columnTypeWithLength": "*timestamptz", + "comment": "", + "weight": 1260007.05 + }, + { + "$kind": "column", + "name": "unsubscribedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "unsubscribedAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218005.12 + }, + { + "$kind": "column", + "name": "isActive", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "isActive", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778017.07 + }, + { + "$kind": "column", + "name": "unsubscribeToken", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "unsubscribeToken", + "attributeKey": [], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(128)", + "comment": "", + "weight": 785005.12 + }, + { + "$kind": "column", + "name": "consentedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "consentedAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218023.11 + }, + { + "$kind": "column", + "name": "ipAddress", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "ipAddress", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(64)", + "comment": "", + "weight": 743017.1 + }, + { + "$kind": "column", + "name": "createdAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "createdAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022023.08 + }, + { + "$kind": "column", + "name": "updatedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "updatedAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022005.1 + }, + { + "$kind": "column", + "name": "deletedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "deletedAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamp", + "charset": "", + "columnTypeWithLength": "timestamp", + "comment": "", + "weight": 980022.21 + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "invoiceNumber", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "invoiceNumber", + "attributeKey": [ + "UK" + ], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(20)", + "comment": "", + "weight": 785017.12 + }, + { + "$kind": "column", + "name": "userId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "userId", + "attributeKey": [ + "FK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 10481005.07 + }, + { + "$kind": "column", + "name": "bookingId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "bookingId", + "attributeKey": [ + "FK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 10481024.11 + }, + { + "$kind": "column", + "name": "paymentId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "paymentId", + "attributeKey": [ + "FK" + ], + "isNullable": "nullable", + "columnType": "uuid", + "charset": "", + "columnTypeWithLength": "uuid", + "comment": "", + "weight": 10439010.25 + }, + { + "$kind": "column", + "name": "amountKobo", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "amountKobo", + "attributeKey": [], + "isNullable": "", + "columnType": "*bigint", + "charset": "", + "columnTypeWithLength": "*bigint", + "comment": "", + "weight": 679025.13 + }, + { + "$kind": "column", + "name": "currency", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "currency", + "attributeKey": [], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(3)", + "comment": "", + "weight": 785023.05 + }, + { + "$kind": "column", + "name": "status", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "status", + "attributeKey": [], + "isNullable": "", + "columnType": "*enum", + "charset": "", + "columnTypeWithLength": "*enum", + "comment": "", + "weight": 479007.06 + }, + { + "$kind": "column", + "name": "paidAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "paidAt", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "timestamptz", + "charset": "", + "columnTypeWithLength": "timestamptz", + "comment": "", + "weight": 1218010.25 + }, + { + "$kind": "column", + "name": "lineItems", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "lineItems", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "jsonb", + "charset": "", + "columnTypeWithLength": "jsonb", + "comment": "", + "weight": 540014.17 + }, + { + "$kind": "column", + "name": "createdAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "createdAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022023.08 + }, + { + "$kind": "column", + "name": "updatedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "updatedAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022005.1 + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "fullName", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "fullName", + "attributeKey": [], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(100)", + "comment": "", + "weight": 785020.05 + }, + { + "$kind": "column", + "name": "email", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "email", + "attributeKey": [], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(254)", + "comment": "", + "weight": 785021.13 + }, + { + "$kind": "column", + "name": "phone", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "phone", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(20)", + "comment": "", + "weight": 743010.18 + }, + { + "$kind": "column", + "name": "company", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "company", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(150)", + "comment": "", + "weight": 743023.11 + }, + { + "$kind": "column", + "name": "subject", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "subject", + "attributeKey": [], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(200)", + "comment": "", + "weight": 785007.05 + }, + { + "$kind": "column", + "name": "message", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "message", + "attributeKey": [], + "isNullable": "", + "columnType": "*text", + "charset": "", + "columnTypeWithLength": "*text", + "comment": "", + "weight": 495013.21 + }, + { + "$kind": "column", + "name": "ipAddress", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "ipAddress", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(64)", + "comment": "", + "weight": 743017.1 + }, + { + "$kind": "column", + "name": "isRead", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "isRead", + "attributeKey": [], + "isNullable": "", + "columnType": "*boolean", + "charset": "", + "columnTypeWithLength": "*boolean", + "comment": "", + "weight": 778017.07 + }, + { + "$kind": "column", + "name": "createdAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "createdAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022023.08 + }, + { + "$kind": "column", + "name": "updatedAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "contact_messages", + "dbName": "updatedAt", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamp", + "charset": "", + "columnTypeWithLength": "*timestamp", + "comment": "", + "weight": 1022005.1 + }, + { + "$kind": "column", + "name": "id", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "id", + "attributeKey": [ + "PK" + ], + "isNullable": "", + "columnType": "*uuid", + "charset": "", + "columnTypeWithLength": "*uuid", + "comment": "", + "weight": 20481017.22 + }, + { + "$kind": "column", + "name": "actorId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "actor_id", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "uuid", + "charset": "", + "columnTypeWithLength": "uuid", + "comment": "", + "weight": 439025.23 + }, + { + "$kind": "column", + "name": "actorEmail", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "actor_email", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(255)", + "comment": "", + "weight": 743025.23 + }, + { + "$kind": "column", + "name": "actorRole", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "actor_role", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(50)", + "comment": "", + "weight": 743025.23 + }, + { + "$kind": "column", + "name": "action", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "action", + "attributeKey": [], + "isNullable": "", + "columnType": "*varchar", + "charset": "", + "columnTypeWithLength": "*varchar(100)", + "comment": "", + "weight": 785025.23 + }, + { + "$kind": "column", + "name": "targetType", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "target_type", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(100)", + "comment": "", + "weight": 743006.25 + }, + { + "$kind": "column", + "name": "targetId", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "target_id", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "uuid", + "charset": "", + "columnTypeWithLength": "uuid", + "comment": "", + "weight": 439006.25 + }, + { + "$kind": "column", + "name": "ipAddress", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "ip_address", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "varchar", + "charset": "", + "columnTypeWithLength": "varchar(45)", + "comment": "", + "weight": 743017.1 + }, + { + "$kind": "column", + "name": "userAgent", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "user_agent", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "text", + "charset": "", + "columnTypeWithLength": "text", + "comment": "", + "weight": 453005.07 + }, + { + "$kind": "column", + "name": "metadata", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "metadata", + "attributeKey": [], + "isNullable": "nullable", + "columnType": "jsonb", + "charset": "", + "columnTypeWithLength": "jsonb", + "comment": "", + "weight": 540013.21 + }, + { + "$kind": "column", + "name": "createdAt", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "audit_log", + "dbName": "created_at", + "attributeKey": [], + "isNullable": "", + "columnType": "*timestamptz", + "charset": "", + "columnTypeWithLength": "*timestamptz", + "comment": "", + "weight": 1260023.08 + }, + { + "name": "RefreshToken", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "refresh_tokens", + "dbName": "refresh_tokens", + "change": "none", + "inverseEntityName": "User", + "inverseEntityDBName": "users", + "joinColumnName": "userId", + "joinPropertyName": "userId", + "relationHash": "cmVmcmVzaF90b2tlbnM6dXNlcnM6b25lLXRvLW1hbnk=", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": false, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 1 + }, + { + "name": "User", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "users", + "dbName": "users", + "change": "none", + "inverseEntityName": "RefreshToken", + "inverseEntityDBName": "refresh_tokens", + "joinColumnName": "userId", + "joinPropertyName": "userId", + "relationHash": "cmVmcmVzaF90b2tlbnM6dXNlcnM6b25lLXRvLW1hbnk=", + "joinColumnOne": true, + "joinColumnNullable": true, + "inverseJoinColumnNullable": false, + "inverseJoinColumnOne": false, + "relationType": "one-to-many", + "isDuplicate": true, + "order": 2 + }, + { + "name": "Booking", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "bookings", + "dbName": "bookings", + "change": "none", + "inverseEntityName": "User", + "inverseEntityDBName": "users", + "joinColumnName": "userId", + "joinPropertyName": "userId", + "relationHash": "Ym9va2luZ3M6dXNlcnM6b25lLXRvLW1hbnk=", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": true, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 1 + }, + { + "name": "Booking", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "bookings", + "dbName": "bookings", + "change": "none", + "inverseEntityName": "Workspace", + "inverseEntityDBName": "workspaces", + "joinColumnName": "workspaceId", + "joinPropertyName": "workspaceId", + "relationHash": "Ym9va2luZ3M6d29ya3NwYWNlczpvbmUtdG8tbWFueQ==", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": false, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 1 + }, + { + "name": "WorkspaceLog", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "workspace_logs", + "dbName": "workspace_logs", + "change": "none", + "inverseEntityName": "User", + "inverseEntityDBName": "users", + "joinColumnName": "userId", + "joinPropertyName": "userId", + "relationHash": "dXNlcnM6d29ya3NwYWNlX2xvZ3M6b25lLXRvLW1hbnk=", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": false, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 2 + }, + { + "name": "WorkspaceLog", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "workspace_logs", + "dbName": "workspace_logs", + "change": "none", + "inverseEntityName": "Workspace", + "inverseEntityDBName": "workspaces", + "joinColumnName": "workspaceId", + "joinPropertyName": "workspaceId", + "relationHash": "d29ya3NwYWNlX2xvZ3M6d29ya3NwYWNlczpvbmUtdG8tbWFueQ==", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": false, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 1 + }, + { + "name": "WorkspaceLog", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "workspace_logs", + "dbName": "workspace_logs", + "change": "none", + "inverseEntityName": "Booking", + "inverseEntityDBName": "bookings", + "joinColumnName": "bookingId", + "joinPropertyName": "bookingId", + "relationHash": "Ym9va2luZ3M6d29ya3NwYWNlX2xvZ3M6b25lLXRvLW1hbnk=", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": true, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 2 + }, + { + "name": "Payment", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "payments", + "dbName": "payments", + "change": "none", + "inverseEntityName": "Booking", + "inverseEntityDBName": "bookings", + "joinColumnName": "bookingId", + "joinPropertyName": "bookingId", + "relationHash": "Ym9va2luZ3M6cGF5bWVudHM6b25lLXRvLW1hbnk=", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": false, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 2 + }, + { + "name": "Payment", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "payments", + "dbName": "payments", + "change": "none", + "inverseEntityName": "User", + "inverseEntityDBName": "users", + "joinColumnName": "userId", + "joinPropertyName": "userId", + "relationHash": "cGF5bWVudHM6dXNlcnM6b25lLXRvLW1hbnk=", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": true, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 1 + }, + { + "name": "Notification", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "notifications", + "dbName": "notifications", + "change": "none", + "inverseEntityName": "User", + "inverseEntityDBName": "users", + "joinColumnName": "userId", + "joinPropertyName": "userId", + "relationHash": "bm90aWZpY2F0aW9uczp1c2VyczpvbmUtdG8tbWFueQ==", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": false, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 1 + }, + { + "name": "Invoice", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "invoices", + "dbName": "invoices", + "change": "none", + "inverseEntityName": "User", + "inverseEntityDBName": "users", + "joinColumnName": "userId", + "joinPropertyName": "userId", + "relationHash": "aW52b2ljZXM6dXNlcnM6b25lLXRvLW1hbnk=", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": false, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 1 + }, + { + "name": "Invoice", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "invoices", + "dbName": "invoices", + "change": "none", + "inverseEntityName": "Booking", + "inverseEntityDBName": "bookings", + "joinColumnName": "bookingId", + "joinPropertyName": "bookingId", + "relationHash": "Ym9va2luZ3M6aW52b2ljZXM6b25lLXRvLW1hbnk=", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": false, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 2 + }, + { + "name": "Invoice", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "$kind": "relation", + "entity": "invoices", + "dbName": "invoices", + "change": "none", + "inverseEntityName": "Payment", + "inverseEntityDBName": "payments", + "joinColumnName": "paymentId", + "joinPropertyName": "paymentId", + "relationHash": "aW52b2ljZXM6cGF5bWVudHM6b25lLXRvLW1hbnk=", + "joinColumnOne": false, + "joinColumnNullable": true, + "inverseJoinColumnNullable": true, + "inverseJoinColumnOne": true, + "relationType": "many-to-one", + "isDuplicate": false, + "order": 1 + }, + { + "$kind": "index", + "name": "IDX_50db1b2ab77f2bb6b737b39341", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "IDX_50db1b2ab77f2bb6b737b39341", + "tableName": "RefreshToken", + "tableDBName": "refresh_tokens", + "isUnique": true, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "familyId", + "version" + ] + }, + { + "$kind": "index", + "name": "IDX_40e9a8b923a1b3fb4429a5c624", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "IDX_40e9a8b923a1b3fb4429a5c624", + "tableName": "RefreshToken", + "tableDBName": "refresh_tokens", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "familyId" + ] + }, + { + "$kind": "index", + "name": "IDX_610102b60fea1455310ccd299d", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "IDX_610102b60fea1455310ccd299d", + "tableName": "RefreshToken", + "tableDBName": "refresh_tokens", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "userId" + ] + }, + { + "$kind": "index", + "name": "IDX_4542dd2f38a61354a040ba9fd5", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "refresh_tokens", + "dbName": "IDX_4542dd2f38a61354a040ba9fd5", + "tableName": "RefreshToken", + "tableDBName": "refresh_tokens", + "isUnique": true, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "token" + ] + }, + { + "$kind": "index", + "name": "UQ_97672ac88f789774dd47f7c8be3", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "users", + "dbName": "UQ_97672ac88f789774dd47f7c8be3", + "tableName": "User", + "tableDBName": "users", + "isUnique": true, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "email" + ] + }, + { + "$kind": "index", + "name": "IDX_48b267d894e32a25ebde4b207a", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "IDX_48b267d894e32a25ebde4b207a", + "tableName": "Booking", + "tableDBName": "bookings", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "status" + ] + }, + { + "$kind": "index", + "name": "IDX_96d2c1b1f22ea3d78ae391fd19", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "IDX_96d2c1b1f22ea3d78ae391fd19", + "tableName": "Booking", + "tableDBName": "bookings", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "workspaceId" + ] + }, + { + "$kind": "index", + "name": "IDX_38a69a58a323647f2e75eb994d", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "bookings", + "dbName": "IDX_38a69a58a323647f2e75eb994d", + "tableName": "Booking", + "tableDBName": "bookings", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "userId" + ] + }, + { + "$kind": "index", + "name": "IDX_3f0e626284faa95cdde4799a3d", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "IDX_3f0e626284faa95cdde4799a3d", + "tableName": "WorkspaceLog", + "tableDBName": "workspace_logs", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "userId", + "checkedInAt" + ] + }, + { + "$kind": "index", + "name": "IDX_f0409cb0365b7d0cc097d6861d", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "workspace_logs", + "dbName": "IDX_f0409cb0365b7d0cc097d6861d", + "tableName": "WorkspaceLog", + "tableDBName": "workspace_logs", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "workspaceId", + "checkedInAt" + ] + }, + { + "$kind": "index", + "name": "IDX_6bd9aa51f09e7dd2727adb8a6e", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "IDX_6bd9aa51f09e7dd2727adb8a6e", + "tableName": "Payment", + "tableDBName": "payments", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "providerReference" + ] + }, + { + "$kind": "index", + "name": "IDX_d35cb3c13a18e1ea1705b2817b", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "IDX_d35cb3c13a18e1ea1705b2817b", + "tableName": "Payment", + "tableDBName": "payments", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "userId" + ] + }, + { + "$kind": "index", + "name": "IDX_1ead3dc5d71db0ea822706e389", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "payments", + "dbName": "IDX_1ead3dc5d71db0ea822706e389", + "tableName": "Payment", + "tableDBName": "payments", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "bookingId" + ] + }, + { + "$kind": "index", + "name": "IDX_5340fc241f57310d243e5ab20b", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "notifications", + "dbName": "IDX_5340fc241f57310d243e5ab20b", + "tableName": "Notification", + "tableDBName": "notifications", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "userId", + "isRead" + ] + }, + { + "$kind": "index", + "name": "IDX_607cf367dae44af2260c472799", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "IDX_607cf367dae44af2260c472799", + "tableName": "NewsletterSubscriber", + "tableDBName": "newsletter_subscriber", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "isVerified" + ] + }, + { + "$kind": "index", + "name": "IDX_19aa53c6630913c18dd641a739", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "IDX_19aa53c6630913c18dd641a739", + "tableName": "NewsletterSubscriber", + "tableDBName": "newsletter_subscriber", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "isActive" + ] + }, + { + "$kind": "index", + "name": "IDX_c7c77fa243eefb2415b13f1b4e", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "IDX_c7c77fa243eefb2415b13f1b4e", + "tableName": "NewsletterSubscriber", + "tableDBName": "newsletter_subscriber", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "email" + ] + }, + { + "$kind": "index", + "name": "UQ_c7c77fa243eefb2415b13f1b4e4", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "newsletter_subscriber", + "dbName": "UQ_c7c77fa243eefb2415b13f1b4e4", + "tableName": "NewsletterSubscriber", + "tableDBName": "newsletter_subscriber", + "isUnique": true, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "email" + ] + }, + { + "$kind": "index", + "name": "IDX_eca01fda44679cc1c342822e01", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "IDX_eca01fda44679cc1c342822e01", + "tableName": "Invoice", + "tableDBName": "invoices", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "bookingId" + ] + }, + { + "$kind": "index", + "name": "IDX_fcbe490dc37a1abf68f19c5ccb", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "IDX_fcbe490dc37a1abf68f19c5ccb", + "tableName": "Invoice", + "tableDBName": "invoices", + "isUnique": false, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "userId" + ] + }, + { + "$kind": "index", + "name": "IDX_bf8e0f9dd4558ef209ec111782", + "version": "0.0.1", + "createdAt": "2026-07-19T22:39:59+00:00", + "updatedAt": "2026-07-19T22:39:59+00:00", + "change": "none", + "entity": "invoices", + "dbName": "IDX_bf8e0f9dd4558ef209ec111782", + "tableName": "Invoice", + "tableDBName": "invoices", + "isUnique": true, + "isFulltext": false, + "isSpatial": false, + "columnNames": [ + "invoiceNumber" + ] + } +] \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 9d38097f..6500e4a4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -89,6 +89,7 @@ "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", + "erdia": "^3.7.1", "eslint": "^8.0.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", @@ -1828,6 +1829,13 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@braintree/sanitize-url": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", + "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==", + "dev": true, + "license": "MIT" + }, "node_modules/@cacheable/utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz", @@ -1879,9 +1887,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "license": "MIT", "optional": true, "dependencies": { @@ -3560,6 +3568,20 @@ "node": ">=8" } }, + "node_modules/@maeum/cli-logo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@maeum/cli-logo/-/cli-logo-1.0.0.tgz", + "integrity": "sha512-GxGPlAF6yS7IBqu28GL0gV7e4oGD92Ocbv7uv0tMq/akYOZVW4GB0a3XOHUDBQ7QfhMeFjNJFyNlLGrknflzPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "figlet": "^1.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@messageformat/core": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.0.tgz", @@ -4859,6 +4881,29 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", + "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.3.5", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.4.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@scure/base": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", @@ -6224,6 +6269,22 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/@stryker-mutator/core/node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@stryker-mutator/core/node_modules/get-stream": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", @@ -6264,6 +6325,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stryker-mutator/core/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@stryker-mutator/core/node_modules/mute-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", @@ -6426,6 +6500,13 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -6555,6 +6636,40 @@ "@types/node": "*" } }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -6698,6 +6813,16 @@ "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -6740,6 +6865,13 @@ "@types/node": "*" } }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -6881,6 +7013,21 @@ "@types/serve-static": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -6913,6 +7060,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -7398,6 +7556,20 @@ "node": ">= 6.0.0" } }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -7443,6 +7615,85 @@ "ajv": "^8.8.2" } }, + "node_modules/alasql": { + "version": "4.17.3", + "resolved": "https://registry.npmjs.org/alasql/-/alasql-4.17.3.tgz", + "integrity": "sha512-EOBD6zfZJ1u6Y76672MapJOyahzjDIQGE7nspt4yKhBWZB2k0cIHHakx6iJaolKkjB4vMPIo09ysw52aQfZ9rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "4.1.0", + "yargs": "16" + }, + "bin": { + "alasql": "bin/alasql-cli.js" + }, + "engines": { + "node": ">=15" + }, + "optionalDependencies": { + "react-native-fs": "^2.20.0" + } + }, + "node_modules/alasql/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/alasql/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/alasql/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/alasql/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/angular-html-parser": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.4.0.tgz", @@ -7725,6 +7976,16 @@ "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -7732,6 +7993,19 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -7759,6 +8033,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/awilix": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/awilix/-/awilix-10.0.2.tgz", + "integrity": "sha512-hFatb7eZFdtiWjjmGRSm/K/uxZpmcBlM+YoeMB3VpOPXk3xa6+7zctg3LRbUzoimom5bwGrePF0jXReO6b4zNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "fast-glob": "^3.3.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/awilix/node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, "node_modules/axios": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", @@ -7771,6 +8070,21 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -7925,6 +8239,46 @@ } } }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, "node_modules/bare-module-resolve": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.2.tgz", @@ -7943,6 +8297,13 @@ } } }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/bare-semver": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz", @@ -7950,6 +8311,51 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==", + "dev": true, + "optional": true + }, "node_modules/base32.js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", @@ -8000,11 +8406,21 @@ "node": ">=6.0.0" } }, - "node_modules/bcrypt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", - "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", - "hasInstallScript": true, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.3.0", @@ -8516,6 +8932,17 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -8584,6 +9011,21 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", + "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "10.0.0", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -8654,6 +9096,16 @@ "node": ">=0.10.0" } }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -8837,6 +9289,27 @@ "node": ">= 6" } }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "dev": true, + "license": "MIT" + }, + "node_modules/complex.js": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz", + "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -8982,6 +9455,16 @@ "node": ">= 0.10" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "dev": true, + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -9091,6 +9574,16 @@ "node": ">=12.0.0" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9139,688 +9632,709 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "cose-base": "^1.0.0" }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "peerDependencies": { + "cytoscape": "^3.2.0" } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dev": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } + "engines": { + "node": ">=12" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": ">=12" + } }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "license": "MIT", + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dev": true, + "license": "ISC", "dependencies": { - "clone": "^1.0.2" + "d3-path": "1 - 3" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=12" } }, - "node_modules/defaults/node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "license": "MIT", + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=0.8" + "node": ">=12" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dev": true, + "license": "ISC", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" + "d3-array": "^3.2.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, "engines": { - "node": ">=0.4.0" + "node": ">=12" } }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=0.10" + "node": ">=12" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, "engines": { - "node": ">= 0.8" + "node": ">=12" } }, - "node_modules/des.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">= 10" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", "dev": true, "license": "ISC", "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" } }, - "node_modules/dfa": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", - "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", - "license": "MIT" - }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", "dev": true, - "license": "BSD-3-Clause", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, "engines": { - "node": ">=0.3.1" + "node": ">=12" } }, - "node_modules/diff-match-patch": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", - "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12" } }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", "dev": true, - "license": "Apache-2.0", + "license": "ISC", "dependencies": { - "esutils": "^2.0.2" + "d3-array": "2.5.0 - 3" }, "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "node": ">=12" } }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "dev": true, + "license": "ISC", "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "node": ">=12" } }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "license": "BSD-2-Clause", + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dev": true, + "license": "ISC", "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "d3-color": "1 - 3" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "engines": { + "node": ">=12" } }, - "node_modules/dotenv": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", - "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", - "license": "BSD-2-Clause", + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "dev": true, + "license": "ISC", "engines": { "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" } }, - "node_modules/dotenv-expand": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", - "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "dev": true, + "license": "ISC", "engines": { "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" } }, - "node_modules/dotenv-expand/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "dev": true, + "license": "ISC", "engines": { "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "dev": true, + "license": "ISC", "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/duplexer2": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { - "readable-stream": "^2.0.2" + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" } }, - "node_modules/duplexer2/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/duplexer2/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "internmap": "^1.0.0" } }, - "node_modules/duplexer2/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "dev": true, + "license": "BSD-3-Clause" }, - "node_modules/duplexer2/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "safe-buffer": "~5.1.0" + "d3-path": "1" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "dev": true, + "license": "ISC" }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dev": true, + "license": "ISC", "dependencies": { - "safe-buffer": "^5.0.1" + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" } }, - "node_modules/editorconfig": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", - "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", - "license": "MIT", + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dev": true, + "license": "ISC", "dependencies": { - "@one-ini/wasm": "0.1.1", - "commander": "^10.0.0", - "minimatch": "^9.0.1", - "semver": "^7.5.3" - }, - "bin": { - "editorconfig": "bin/editorconfig" + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" }, "engines": { - "node": ">=14" + "node": ">=12" } }, - "node_modules/editorconfig/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } }, - "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "license": "MIT", + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dev": true, + "license": "ISC", "dependencies": { - "balanced-match": "^1.0.0" + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" } }, - "node_modules/editorconfig/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "license": "MIT", + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, "engines": { - "node": ">=14" + "node": ">=12" } }, - "node_modules/editorconfig/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.2" + "d3-time": "1 - 3" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=12" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.375", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", - "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", - "license": "ISC" + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, "engines": { "node": ">=12" }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" + "peerDependencies": { + "d3-selection": "2 - 3" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, "engines": { - "node": ">= 0.8" + "node": ">=12" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "node_modules/dagre-d3-es": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", + "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "dev": true, "license": "MIT", "dependencies": { - "once": "^1.4.0" + "d3": "^7.9.0", + "lodash-es": "^4.17.21" } }, - "node_modules/engine.io": { - "version": "6.6.9", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", - "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "@types/ws": "^8.5.12", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.21.0" + "ms": "^2.1.3" }, "engines": { - "node": ">=10.2.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">=0.10.0" } }, - "node_modules/engine.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "character-entities": "^2.0.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/engine.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-object-diff": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=0.10.0" } }, - "node_modules/engine.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "clone": "^1.0.2" }, - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/engine.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "node_modules/defaults/node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=0.8" } }, - "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, "license": "MIT", "dependencies": { - "is-arrayish": "^0.2.1" + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/del": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", + "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "globby": "^11.0.1", + "graceful-fs": "^4.2.4", + "is-glob": "^4.0.1", + "is-path-cwd": "^2.2.0", + "is-path-inside": "^3.0.2", + "p-map": "^4.0.0", + "rimraf": "^3.0.2", + "slash": "^3.0.0" }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-goat": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", - "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", - "license": "MIT", "engines": { "node": ">=10" }, @@ -9828,18 +10342,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { + "node_modules/del/node_modules/p-map": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", "dev": true, "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, "engines": { "node": ">=10" }, @@ -9847,1114 +10358,1061 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=0.4.0" } }, - "node_modules/eslint-config-prettier": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", - "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", - "dev": true, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" + "engines": { + "node": ">= 0.8" } }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" - }, "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } + "node": ">=6" } }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=8" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true, "license": "MIT", - "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" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=8" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "license": "MIT" }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "node_modules/devtools-protocol": { + "version": "0.0.1312386", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", + "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause" + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "asap": "^2.0.0", + "wrappy": "1" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "engines": { - "node": ">= 4" + "node": ">=0.3.1" } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "path-type": "^4.0.0" }, "engines": { - "node": "*" + "node": ">=8" } }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, - "license": "BSD-2-Clause", + "license": "Apache-2.0", "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "esutils": "^2.0.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=6.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" + "dependencies": { + "domelementtype": "^2.3.0" }, "engines": { - "node": ">=4" + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "license": "BSD-2-Clause", "dependencies": { - "estraverse": "^5.2.0" + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" }, - "engines": { - "node": ">=4.0" + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", "license": "BSD-2-Clause", "engines": { - "node": ">=4.0" + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.6" + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, "engines": { - "node": ">=0.8.x" + "node": ">= 0.4" } }, - "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" } }, - "node_modules/exceljs": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", - "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "dependencies": { - "archiver": "^5.0.0", - "dayjs": "^1.8.34", - "fast-csv": "^4.3.1", - "jszip": "^3.10.1", - "readable-stream": "^3.6.0", - "saxes": "^5.0.1", - "tmp": "^0.2.0", - "unzipper": "^0.10.11", - "uuid": "^8.3.0" - }, - "engines": { - "node": ">=8.3.0" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "safe-buffer": "~5.1.0" } }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" } }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", "license": "MIT", "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=14" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "license": "MIT", "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" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "balanced-match": "^1.0.0" } }, - "node_modules/express/node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", "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" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=18" + "node": ">=16 || 14 >=14.17" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/express/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "dev": true, + "license": "EPL-2.0" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=6.6.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, - "node_modules/express/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", "engines": { "node": ">= 0.8" } }, - "node_modules/express/node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" + "once": "^1.4.0" } }, - "node_modules/express/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" }, "engines": { - "node": ">= 0.6" + "node": ">=10.2.0" } }, - "node_modules/fast-csv": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", - "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", - "license": "MIT", - "dependencies": { - "@fast-csv/format": "4.3.5", - "@fast-csv/parse": "4.3.6" - }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", "engines": { "node": ">=10.0.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-printf": { - "version": "1.6.10", - "resolved": "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.10.tgz", - "integrity": "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==", - "license": "BSD-3-Clause", + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, "engines": { - "node": ">=10.0" + "node": ">= 0.6" } }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "license": "MIT" - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" + "engines": { + "node": ">= 0.6" } }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "dev": true, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { - "fast-string-width": "^3.0.2" + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" } }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" + "engines": { + "node": ">= 0.6" } }, - "node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", "license": "MIT", "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" }, - "bin": { - "fxparser": "src/cli/cli.js" + "engines": { + "node": ">=10.13.0" } }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" + "license": "MIT", + "engines": { + "node": ">=6" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/erdia": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/erdia/-/erdia-3.7.1.tgz", + "integrity": "sha512-GZaAPOfPQMt9j6gSwLEX+QKJOTkjlyLhVOE9K0ppMWA+EyTOYIKQOVBSLBQBB60OhAGvuJdlwlxz7IlmDj7Lpg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12.0.0" + "dependencies": { + "@maeum/cli-logo": "^1.0.0", + "alasql": "^4.1.3", + "awilix": "^10.0.2", + "chalk": "^4.1.2", + "compare-versions": "^6.1.0", + "consola": "^3.2.3", + "dayjs": "^1.11.9", + "deep-object-diff": "^1.1.9", + "del": "^6.1.1", + "eta": "^3.1.0", + "fast-safe-stringify": "^2.1.1", + "filenamify": "^4.3.0", + "find-up": "^5.0.0", + "fuse.js": "^6.6.2", + "glob": "^10.3.12", + "globby": "^11.1.0", + "inquirer": "^8.2.4", + "inquirer-autocomplete-prompt": "^2.0.0", + "jsonc-parser": "^3.2.0", + "mathjs": "^11.8.2", + "mermaid": "^10.3.1", + "minimist": "^1.2.8", + "my-easy-fp": "^0.22.0", + "my-node-fp": "^0.10.3", + "my-only-either": "^1.1.2", + "pathe": "^1.1.2", + "prettier": "^3.0.3", + "puppeteer": "^22.6.1", + "read-pkg": "^5.2.0", + "semver": "^7.5.4", + "source-map-support": "^0.5.21", + "tslib": "^2.4.1", + "type-fest": "^4.2.0", + "typeorm": "^0.3.17", + "yargs": "^17.7.2" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "bin": { + "erdia": "dist/cjs/cli.cjs" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "engines": { + "node": ">=18" } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "node_modules/erdia/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "node_modules/erdia/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "balanced-match": "^1.0.0" } }, - "node_modules/figures/node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "node_modules/erdia/node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "node_modules/erdia/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "flat-cache": "^3.0.4" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "^10.12.0 || >=12.0.0" + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/file-type": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", - "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", - "license": "MIT", + "node_modules/erdia/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/erdia/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", "dependencies": { - "@tokenizer/inflate": "^0.2.6", - "strtok3": "^10.2.0", - "token-types": "^6.0.0", - "uint8array-extras": "^1.4.0" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=18" + "node": ">=16 || 14 >=14.17" }, "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", + "node_modules/erdia/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "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" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">= 18.0.0" + "node": ">=16 || 14 >=14.18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/erdia/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" + "is-arrayish": "^0.2.1" } }, - "node_modules/flat-cache/node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "engines": { + "node": ">= 0.4" } }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "node": ">= 0.4" } }, - "node_modules/fontkit": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", - "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { - "@swc/helpers": "^0.5.12", - "brotli": "^1.3.2", - "clone": "^2.1.2", - "dfa": "^1.2.0", - "fast-deep-equal": "^3.1.3", - "restructure": "^3.0.0", - "tiny-inflate": "^1.0.3", - "unicode-properties": "^1.4.0", - "unicode-trie": "^2.0.0" + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "is-callable": "^1.2.7" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=6" } }, - "node_modules/fork-ts-checker-webpack-plugin": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", - "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "node_modules/escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", + "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.16.7", - "chalk": "^4.1.2", - "chokidar": "^4.0.1", - "cosmiconfig": "^8.2.0", - "deepmerge": "^4.2.2", - "fs-extra": "^10.0.0", - "memfs": "^3.4.1", - "minimatch": "^3.0.4", - "node-abort-controller": "^3.0.1", - "schema-utils": "^3.1.1", - "semver": "^7.3.5", - "tapable": "^2.2.1" - }, "engines": { - "node": ">=14.21.3" + "node": ">=10" }, - "peerDependencies": { - "typescript": ">3.6.0", - "webpack": "^5.11.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "brace-expansion": "^1.1.7" + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" }, - "engines": { - "node": "*" - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" } }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, "engines": { - "node": ">= 0.6" + "node": ">=0.10.0" } }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">=14.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" + "url": "https://opencollective.com/eslint" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" } }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, "engines": { - "node": ">= 0.8" + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "license": "MIT", + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">=12" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/fs-monkey": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", - "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", - "license": "Unlicense" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/fstream": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", - "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", "dependencies": { - "graceful-fs": "^4.1.2", - "inherits": "~2.0.0", - "mkdirp": ">=0.5 0", - "rimraf": "2" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, - "engines": { - "node": ">=0.6" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/fstream/node_modules/balanced-match": { + "node_modules/eslint/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, - "node_modules/fstream/node_modules/brace-expansion": { + "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/fstream/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 4" } }, - "node_modules/fstream/node_modules/minimatch": { + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -10963,759 +11421,738 @@ "node": "*" } }, - "node_modules/fstream/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "glob": "^7.1.3" + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, "engines": { - "node": ">=6.9.0" + "node": ">=4" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">=0.10" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", "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" + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">=8.0.0" + "node": ">=0.10.0" } }, - "node_modules/get-port": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "node_modules/eta": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-3.5.0.tgz", + "integrity": "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/eta-dev/eta?sponsor=1" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" + "node": ">= 0.6" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.8.x" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, + "bare-events": "^2.7.0" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=12.0.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" }, "engines": { - "node": ">=10.13.0" + "node": ">=8.3.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.20.2" + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", "dev": true, - "license": "MIT" + "engines": { + "node": ">= 0.8.0" + } }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" }, - "bin": { - "handlebars": "bin/handlebars" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "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" }, "engines": { - "node": ">=0.4.7" + "node": ">= 18" }, - "optionalDependencies": { - "uglify-js": "^3.1.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/handlebars/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", + "node_modules/express/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "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" + }, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/express/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6.6.0" } }, - "node_modules/has-own-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", - "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", - "dev": true, + "node_modules/express/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/express/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 0.10" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/express/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 0.6" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "has-symbols": "^1.0.3" + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" }, "engines": { - "node": ">= 0.4" + "node": ">= 10.17.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "@types/yauzl": "^2.9.1" } }, - "node_modules/hashery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz", - "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==", + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.14.0" + "pump": "^3.0.0" }, "engines": { - "node": ">=20" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "license": "MIT", - "bin": { - "he": "bin/he" + "node": ">=10.0.0" } }, - "node_modules/hookified": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", - "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "dev": true, "license": "MIT" }, - "node_modules/html-minifier": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-4.0.0.tgz", - "integrity": "sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==", + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { - "camel-case": "^3.0.0", - "clean-css": "^4.2.1", - "commander": "^2.19.0", - "he": "^1.2.0", - "param-case": "^2.1.1", - "relateurl": "^0.2.7", - "uglify-js": "^3.5.1" - }, - "bin": { - "html-minifier": "cli.js" + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" }, "engines": { - "node": ">=6" + "node": ">=8.6.0" } }, - "node_modules/html-minifier/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", "dependencies": { - "agent-base": "6", - "debug": "4" + "is-glob": "^4.0.1" }, "engines": { "node": ">= 6" } }, - "node_modules/human-signals": { + "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } + "license": "MIT" }, - "node_modules/i18n": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/i18n/-/i18n-0.15.3.tgz", - "integrity": "sha512-tW/AA5R4lJZLnd60Agcd0PfXB1C2G7UqTrdNewuv/SIYdxcHkCE8w4Zx1SgCjJ+2BLuAAGIG/KXb/xNYF1lO5Q==", - "license": "MIT", - "dependencies": { - "@messageformat/core": "^3.4.0", - "debug": "^4.4.3", - "fast-printf": "^1.6.10", - "make-plural": "^7.4.0", - "math-interval-parser": "^2.0.1", - "mustache": "^4.2.0" - }, + "node_modules/fast-printf": { + "version": "1.6.10", + "resolved": "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.10.tgz", + "integrity": "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==", + "license": "BSD-3-Clause", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/mashpie" + "node": ">=10.0" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "fast-string-truncated-width": "^3.0.2" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "url": "https://github.com/sponsors/fastify" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "opencollective", + "url": "https://opencollective.com/fastify" } ], "license": "BSD-3-Clause" }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 4" + "dependencies": { + "fast-string-width": "^3.0.2" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT", "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "fxparser": "src/cli/cli.js" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "license": "ISC", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "reusify": "^1.0.4" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/ioredis": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", - "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, "license": "MIT", "dependencies": { - "@ioredis/commands": "1.5.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" + "pend": "~1.2.0" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/figlet": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.11.2.tgz", + "integrity": "sha512-VXzWZSFTauu1f4CJbj/EnlpDVaTjO33cp/xgH0CLtPS/aP59i3ElwyR2bquCEsAv2Apv9E29PX5Zy39ipeUqSw==", + "dev": true, "license": "MIT", "dependencies": { - "binary-extensions": "^2.0.0" + "commander": "^14.0.0" + }, + "bin": { + "figlet": "bin/index.js" }, "engines": { - "node": ">=8" + "node": ">= 17.0.0" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/figlet/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "escape-string-regexp": "^1.0.5" }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.8.0" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "flat-cache": "^3.0.4" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "license": "MIT", - "engines": { - "node": ">=8" + "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/file-type": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", + "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, "engines": { - "node": ">=0.12.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "node_modules/filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "node_modules/filenamify": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", + "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", "dev": true, "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.1", + "trim-repeated": "^1.0.0" + }, "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.16" + "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" }, "engines": { - "node": ">= 0.4" + "node": ">= 18.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, "engines": { "node": ">=10" }, @@ -11723,301 +12160,331 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, "engines": { - "node": ">=8" + "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "node_modules/flat-cache/node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" + "json-buffer": "3.0.1" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "is-callable": "^1.2.7" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/iterare": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", - "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", - "engines": { - "node": ">=6" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=14.21.3" }, "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "typescript": ">3.6.0", + "webpack": "^5.11.0" } }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "*" } }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 6" } }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" + "mime-db": "1.52.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "engines": { + "node": ">=14.0.0" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" } }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.4.tgz", + "integrity": "sha512-pwiTgt0Q7t+GHZA4yaLjObx4vXmmdcS0iSJ19o8d/goUGgItX9UZWKWNnLHehxviD8wU2IWRsnR8cD5+yOJP2Q==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" + "engines": { + "node": "*" }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } + "engines": { + "node": ">=0.6" } }, - "node_modules/jest-config/node_modules/balanced-match": { + "node_modules/fstream/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, - "node_modules/jest-config/node_modules/brace-expansion": { + "node_modules/fstream/node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/jest-config/node_modules/glob": { + "node_modules/fstream/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -12034,11 +12501,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-config/node_modules/minimatch": { + "node_modules/fstream/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -12047,1051 +12513,2380 @@ "node": "*" } }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", + "node_modules/fstream/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "glob": "^7.1.3" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "bin": { + "rimraf": "bin.js" } }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "node_modules/fuse.js": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", + "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==", "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, + "license": "Apache-2.0", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" } }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=6.9.0" } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "license": "MIT", + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" }, - "optionalDependencies": { - "fsevents": "^2.3.2" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true, "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8.0.0" } }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" } }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } + "engines": { + "node": ">= 14" } }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" + "is-glob": "^4.0.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10.13.0" } }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" + "type-fest": "^0.20.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-runner/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/globby/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 4" } }, - "node_modules/jest-runner/node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" }, - "node_modules/jest-runtime/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, "license": "MIT" }, - "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/jest-runtime/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" }, "engines": { - "node": "*" + "node": ">=0.4.7" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "optionalDependencies": { + "uglify-js": "^3.1.4" } }, - "node_modules/jest-runtime/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", "engines": { - "node": "*" + "node": ">=0.10.0" } }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "node_modules/has-own-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", + "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", - "engines": { - "node": ">=8.6" + "dependencies": { + "es-define-property": "^1.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, + "node_modules/hashery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz", + "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==", "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" + "hookified": "^1.14.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=20" } }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "function-bind": "^1.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "bin": { + "he": "bin/he" } }, - "node_modules/jpeg-exif": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", - "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "license": "MIT" }, - "node_modules/js-beautify": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-minifier": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-4.0.0.tgz", + "integrity": "sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==", "license": "MIT", "dependencies": { - "config-chain": "^1.1.13", - "editorconfig": "^1.0.4", - "glob": "^10.4.2", - "js-cookie": "^3.0.5", - "nopt": "^7.2.1" + "camel-case": "^3.0.0", + "clean-css": "^4.2.1", + "commander": "^2.19.0", + "he": "^1.2.0", + "param-case": "^2.1.1", + "relateurl": "^0.2.7", + "uglify-js": "^3.5.1" }, "bin": { - "css-beautify": "js/bin/css-beautify.js", - "html-beautify": "js/bin/html-beautify.js", - "js-beautify": "js/bin/js-beautify.js" + "html-minifier": "cli.js" }, "engines": { - "node": ">=14" + "node": ">=6" } }, - "node_modules/js-beautify/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/html-minifier/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/js-beautify/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" } }, - "node_modules/js-beautify/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": ">= 0.8" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/js-beautify/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } }, - "node_modules/js-beautify/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.2" + "agent-base": "6", + "debug": "4" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/i18n": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/i18n/-/i18n-0.15.3.tgz", + "integrity": "sha512-tW/AA5R4lJZLnd60Agcd0PfXB1C2G7UqTrdNewuv/SIYdxcHkCE8w4Zx1SgCjJ+2BLuAAGIG/KXb/xNYF1lO5Q==", + "license": "MIT", + "dependencies": { + "@messageformat/core": "^3.4.0", + "debug": "^4.4.3", + "fast-printf": "^1.6.10", + "make-plural": "^7.4.0", + "math-interval-parser": "^2.0.1", + "mustache": "^4.2.0" + }, + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/mashpie" } }, - "node_modules/js-beautify/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=0.10.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/js-cookie": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", - "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", - "license": "MIT" + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" }, - "node_modules/js-md4": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", - "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 4" + } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" }, "engines": { - "node": ">=6" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } }, - "node_modules/json-rpc-2.0": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.7.1.tgz", - "integrity": "sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==", + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, - "node_modules/json2csv": { - "version": "6.0.0-alpha.2", - "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-6.0.0-alpha.2.tgz", - "integrity": "sha512-nJ3oP6QxN8z69IT1HmrJdfVxhU1kLTBVgMfRnNZc37YEY+jZ4nU27rBGxT4vaqM/KUCavLRhntmTuBFqZLBUcA==", + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "dev": true, "license": "MIT", "dependencies": { - "@streamparser/json": "^0.0.6", - "commander": "^6.2.0", - "lodash.get": "^4.4.2" - }, - "bin": { - "json2csv": "bin/json2csv.js" + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" }, "engines": { - "node": ">= 12", - "npm": ">= 6.13.0" + "node": ">=12.0.0" } }, - "node_modules/json2csv/node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "license": "MIT", + "node_modules/inquirer-autocomplete-prompt": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inquirer-autocomplete-prompt/-/inquirer-autocomplete-prompt-2.0.1.tgz", + "integrity": "sha512-jUHrH0btO7j5r8DTQgANf2CBkTZChoVySD8zF/wp5fZCOLIuUbleXhf4ZY5jNBOc1owA3gdfWtfZuppfYBhcUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "ansi-escapes": "^4.3.2", + "figures": "^3.2.0", + "picocolors": "^1.0.0", + "run-async": "^2.4.1", + "rxjs": "^7.5.4" + }, "engines": { - "node": ">= 6" + "node": ">=12" + }, + "peerDependencies": { + "inquirer": "^8.0.0" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, + "node_modules/inquirer/node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=6" + "node": ">= 10" } }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "license": "MIT" + "node_modules/inquirer/node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" } }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "node_modules/ioredis": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", + "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", "license": "MIT", "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" + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" }, "engines": { - "node": ">=12", - "npm": ">=6" + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" } }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" } }, - "node_modules/jszip/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/jszip/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "engines": { + "node": ">= 0.10" } }, - "node_modules/jszip/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, - "node_modules/jszip/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/juice": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/juice/-/juice-10.0.1.tgz", - "integrity": "sha512-ZhJT1soxJCkOiO55/mz8yeBKTAJhRzX9WBO+16ZTqNTONnnVlUPyVBIzQ7lDRjaBdTbid+bAnyIon/GM3yp4cA==", + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "license": "MIT", "dependencies": { - "cheerio": "1.0.0-rc.12", - "commander": "^6.1.0", - "mensch": "^0.3.4", - "slick": "^1.12.2", - "web-resource-inliner": "^6.0.1" - }, - "bin": { - "juice": "bin/juice" + "binary-extensions": "^2.0.0" }, "engines": { - "node": ">=10.0.0" + "node": ">=8" } }, - "node_modules/juice/node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "license": "MIT", "engines": { - "node": ">= 6" - } + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" + "engines": { + "node": ">=8" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "license": "MIT", "dependencies": { - "readable-stream": "^2.0.5" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">= 0.6.3" + "node": ">=0.10.0" } }, - "node_modules/lazystream/node_modules/isarray": { + "node_modules/is-interactive": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "engines": { + "node": ">=8" } }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" + "engines": { + "node": ">=0.12.0" } }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, "engines": { - "node": ">= 0.8.0" + "node": ">=8" } }, - "node_modules/libphonenumber-js": { - "version": "1.12.38", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.38.tgz", - "integrity": "sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==", + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/linebreak": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", - "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "license": "MIT", "dependencies": { - "base64-js": "0.0.8", - "unicode-trie": "^2.0.0" + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/linebreak/node_modules/base64-js": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", - "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, - "node_modules/listenercount": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", - "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "license": "MIT", + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">=8" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "p-locate": "^5.0.0" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/lodash.compact": { + "node_modules/istanbul-lib-report": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/lodash.compact/-/lodash.compact-3.0.1.tgz", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "license": "MIT" + }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-rpc-2.0": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.7.1.tgz", + "integrity": "sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json2csv": { + "version": "6.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-6.0.0-alpha.2.tgz", + "integrity": "sha512-nJ3oP6QxN8z69IT1HmrJdfVxhU1kLTBVgMfRnNZc37YEY+jZ4nU27rBGxT4vaqM/KUCavLRhntmTuBFqZLBUcA==", + "license": "MIT", + "dependencies": { + "@streamparser/json": "^0.0.6", + "commander": "^6.2.0", + "lodash.get": "^4.4.2" + }, + "bin": { + "json2csv": "bin/json2csv.js" + }, + "engines": { + "node": ">= 12", + "npm": ">= 6.13.0" + } + }, + "node_modules/json2csv/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/juice": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/juice/-/juice-10.0.1.tgz", + "integrity": "sha512-ZhJT1soxJCkOiO55/mz8yeBKTAJhRzX9WBO+16ZTqNTONnnVlUPyVBIzQ7lDRjaBdTbid+bAnyIon/GM3yp4cA==", + "license": "MIT", + "dependencies": { + "cheerio": "1.0.0-rc.12", + "commander": "^6.1.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^6.0.1" + }, + "bin": { + "juice": "bin/juice" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/juice/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", + "dev": true + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.38", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.38.tgz", + "integrity": "sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==", + "license": "MIT" + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.compact": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.compact/-/lodash.compact-3.0.1.tgz", "integrity": "sha512-2ozeiPi+5eBXW1CLtzjk8XQFhQOEMwwfxblqeq6EGyTxZJ1bPATqilY0e6g2SLQpP4KuMeuioBhEnWz5Pr7ICQ==", "license": "MIT" }, @@ -13210,193 +15005,772 @@ "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lower-case": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", + "integrity": "sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-plural": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.5.0.tgz", + "integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==", + "license": "Unicode-DFS-2016" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-interval-parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/math-interval-parser/-/math-interval-parser-2.0.1.tgz", + "integrity": "sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mathjs": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.12.0.tgz", + "integrity": "sha512-UGhVw8rS1AyedyI55DGz9q1qZ0p98kyKPyc9vherBkoueLntPfKtPBh14x+V4cdUWK0NZV2TBwqRFlvadscSuw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.23.2", + "complex.js": "^2.1.1", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "4.3.4", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.1.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", + "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "mdast-util-to-string": "^3.1.0", + "micromark": "^3.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-decode-string": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "unist-util-stringify-position": "^3.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", + "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/mensch": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", + "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/mermaid": { + "version": "10.9.6", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-10.9.6.tgz", + "integrity": "sha512-XRjjRaI4aPCAMpVaOhxIwLYdx3U4Cb6mN0M268ggFAfFRqsvyFW8zxWbEZazN/mPkqsVWThb0oa1UawWK+XMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^6.0.1", + "@types/d3-scale": "^4.0.3", + "@types/d3-scale-chromatic": "^3.0.0", + "cytoscape": "^3.28.1", + "cytoscape-cose-bilkent": "^4.1.0", + "d3": "^7.4.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.13", + "dayjs": "^1.11.7", + "dompurify": "^3.2.4", + "elkjs": "^0.9.0", + "katex": "^0.16.9", + "khroma": "^2.0.0", + "lodash-es": "^4.17.21", + "mdast-util-from-markdown": "^1.3.0", + "non-layered-tidy-tree-layout": "^2.0.2", + "stylis": "^4.1.3", + "ts-dedent": "^2.2.0", + "uuid": "^9.0.0 || ^10 || ^11.1.0 || ^12 || ^13 || ^14.0.0", + "web-worker": "^1.2.0" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", + "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "micromark-core-commonmark": "^1.0.1", + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-combine-extensions": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", + "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-factory-destination": "^1.0.0", + "micromark-factory-label": "^1.0.0", + "micromark-factory-space": "^1.0.0", + "micromark-factory-title": "^1.0.0", + "micromark-factory-whitespace": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-classify-character": "^1.0.0", + "micromark-util-html-tag-name": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", + "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", "dev": true, - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "license": "MIT" - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "license": "MIT" + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "node_modules/micromark-factory-label": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", + "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" } }, - "node_modules/lower-case": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", - "integrity": "sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==", - "license": "MIT" + "node_modules/micromark-factory-space": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-types": "^1.0.0" + } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/micromark-factory-title": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", + "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", "dev": true, - "license": "ISC", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" } }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "node_modules/micromark-factory-whitespace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", + "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" } }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "node_modules/micromark-util-character": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "node_modules/micromark-util-chunked": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", + "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "micromark-util-symbol": "^1.0.0" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "node_modules/micromark-util-classify-character": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", + "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", "dev": true, - "license": "ISC" + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } }, - "node_modules/make-plural": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.5.0.tgz", - "integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==", - "license": "Unicode-DFS-2016" + "node_modules/micromark-util-combine-extensions": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", + "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-types": "^1.0.0" + } }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", + "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", "dev": true, - "license": "BSD-3-Clause", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "tmpl": "1.0.5" + "micromark-util-symbol": "^1.0.0" } }, - "node_modules/math-interval-parser": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/math-interval-parser/-/math-interval-parser-2.0.1.tgz", - "integrity": "sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA==", + "node_modules/micromark-util-decode-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", + "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-symbol": "^1.0.0" } }, - "node_modules/math-intrinsics": { + "node_modules/micromark-util-encode": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", + "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", + "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", + "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "micromark-util-symbol": "^1.0.0" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "node_modules/micromark-util-resolve-all": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", + "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "micromark-util-types": "^1.0.0" } }, - "node_modules/memfs": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", - "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", - "license": "Unlicense", + "node_modules/micromark-util-sanitize-uri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", + "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "fs-monkey": "^1.0.4" - }, - "engines": { - "node": ">= 4.0.0" + "micromark-util-character": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-symbol": "^1.0.0" } }, - "node_modules/mensch": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", - "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", - "license": "MIT" - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "node_modules/micromark-util-subtokenize": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", + "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "node_modules/micromark-util-symbol": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", @@ -13511,6 +15885,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/mjml": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.18.0.tgz", @@ -14096,6 +16477,16 @@ "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", "license": "BSD-3-Clause" }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -14174,6 +16565,16 @@ "node": ">=18" } }, + "node_modules/mutation-server-protocol/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/mutation-testing-elements": { "version": "3.7.3", "resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.7.3.tgz", @@ -14207,6 +16608,33 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/my-easy-fp": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/my-easy-fp/-/my-easy-fp-0.22.0.tgz", + "integrity": "sha512-fWM/HrbfUiBt8GcwMXad5r8s2urQezRqNiaa5GispeXGKptpaz5m+NUwBazkow5oxktZGWyQj1bSx40xtq00zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/my-node-fp": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/my-node-fp/-/my-node-fp-0.10.3.tgz", + "integrity": "sha512-LXTord8vAfBCP4nX1Ct/d7jXuWxeiFvDwmX+HCiLHIoNhzxg7CaippiPZV8MQ/nfX9pXSf6jpJLr/fs5QHjwKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "my-easy-fp": "^0.22.0" + } + }, + "node_modules/my-only-either": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/my-only-either/-/my-only-either-1.3.0.tgz", + "integrity": "sha512-Ww01dhgmQLLHwEww6b0F5eyPMz0zfCYId4SsP8p7VPxtxPrh6v+fwORrBDYAHgOcVE2Bg9+wwztDKkSEbuxZog==", + "dev": true, + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -14330,6 +16758,16 @@ "node": ">=8.10.0" } }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/no-case": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", @@ -14453,6 +16891,13 @@ } } }, + "node_modules/non-layered-tidy-tree-layout": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", + "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", + "dev": true, + "license": "MIT" + }, "node_modules/nopt": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", @@ -14468,6 +16913,29 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -14673,6 +17141,64 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -14783,6 +17309,38 @@ "node": ">= 0.8" } }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/pascal-case/node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/pascal-case/node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/passport": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", @@ -14919,6 +17477,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", @@ -14937,6 +17502,13 @@ "png-js": "^1.0.0" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/pg": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", @@ -15326,6 +17898,67 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-agent/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -15335,6 +17968,17 @@ "node": ">=10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -15344,6 +17988,71 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "22.15.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz", + "integrity": "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==", + "deprecated": "< 24.15.0 is no longer supported", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.3.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1312386", + "puppeteer-core": "22.15.0" + }, + "bin": { + "puppeteer": "lib/esm/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "22.15.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", + "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.3.0", + "chromium-bidi": "0.6.3", + "debug": "^4.3.6", + "devtools-protocol": "0.0.1312386", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer/node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -15570,6 +18279,53 @@ "dev": true, "license": "MIT" }, + "node_modules/react-native-fs": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz", + "integrity": "sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "base-64": "^0.1.0", + "utf8": "^3.0.0" + }, + "peerDependencies": { + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "react-native-windows": { + "optional": true + } + } + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -15885,6 +18641,13 @@ "node": "*" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -15911,6 +18674,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -15935,6 +18708,13 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -15944,6 +18724,19 @@ "tslib": "^2.1.0" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -16037,6 +18830,13 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -16324,6 +19124,17 @@ "node": "*" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/socket.io": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", @@ -16408,6 +19219,46 @@ "node": ">= 0.6" } }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/sodium-native": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", @@ -16446,6 +19297,42 @@ "node": ">=0.10.0" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -16524,6 +19411,18 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -16640,6 +19539,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-outer/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/strnum": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", @@ -16671,6 +19593,13 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "dev": true, + "license": "MIT" + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -16801,6 +19730,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-fs/node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -16817,6 +19774,16 @@ "node": ">=6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/terser": { "version": "5.48.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", @@ -17034,6 +20001,16 @@ "node": "*" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -17041,6 +20018,20 @@ "dev": true, "license": "MIT" }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -17164,6 +20155,29 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/trim-repeated/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -17177,6 +20191,16 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/ts-jest": { "version": "29.4.11", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", @@ -17452,6 +20476,16 @@ "node": ">= 0.4" } }, + "node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/typed-inject": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz", @@ -17774,6 +20808,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/underscore": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", @@ -17826,6 +20871,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unist-util-stringify-position": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", + "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -17949,6 +21008,21 @@ "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", "license": "MIT" }, + "node_modules/urlpattern-polyfill": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", + "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", + "dev": true, + "license": "MIT" + }, + "node_modules/utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", + "integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -17973,6 +21047,45 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uvu": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", + "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0", + "diff": "^5.0.0", + "kleur": "^4.0.3", + "sade": "^1.7.3" + }, + "bin": { + "uvu": "bin.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uvu/node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/uvu/node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -18004,6 +21117,17 @@ "node": ">=10" } }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/validator": { "version": "13.15.26", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", @@ -18174,6 +21298,13 @@ "url": "https://github.com/fb55/htmlparser2?sponsor=1" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -18524,6 +21655,17 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -18657,9 +21799,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "dev": true, "license": "MIT", "funding": { diff --git a/backend/package.json b/backend/package.json index ed4d95ee..3441b446 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "demo:seed": "node scripts/demo-data.js seed", "demo:clear": "node scripts/demo-data.js clear", "demo:info": "node scripts/demo-data.js info", + "db:diagram": "node --require ts-node/register/transpile-only node_modules/erdia/dist/cjs/cli.cjs build -d src/config/typeorm.config.ts -o docs/erd --format md --components er", "test:mutation": "stryker run" }, "dependencies": { @@ -109,6 +110,7 @@ "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", + "erdia": "^3.7.1", "eslint": "^8.0.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", @@ -139,6 +141,9 @@ "collectCoverageFrom": [ "**/*.(t|j)s" ], + "transformIgnorePatterns": [ + "node_modules/(?!(bcrypt|@aws-sdk|@nestjs|otplib|@otplib|@noble|@scure)/)" + ], "coverageDirectory": "../coverage", "testEnvironment": "node" } diff --git a/backend/src/audit-log/audit-log.controller.spec.ts b/backend/src/audit-log/audit-log.controller.spec.ts new file mode 100644 index 00000000..8a599d13 --- /dev/null +++ b/backend/src/audit-log/audit-log.controller.spec.ts @@ -0,0 +1,188 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuditLogController } from './audit-log.controller'; +import { AuditLogService } from './providers/audit-log.service'; +import { UserRole } from '../users/enums/userRoles.enum'; + +describe('AuditLogController', () => { + let controller: AuditLogController; + let service: jest.Mocked>; + + const mockPaginatedResult = { + data: [ + { + id: 'log-1', + actorId: 'user-1', + actorEmail: 'admin@example.com', + actorRole: 'admin', + action: 'users.create', + targetType: 'user', + targetId: 'target-1', + createdAt: new Date('2024-01-15'), + }, + { + id: 'log-2', + actorId: 'user-1', + actorEmail: 'admin@example.com', + actorRole: 'admin', + action: 'workspaces.update', + targetType: 'workspace', + targetId: 'ws-1', + createdAt: new Date('2024-01-14'), + }, + ], + total: 2, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + service = { + findAll: jest.fn(), + create: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuditLogController], + providers: [{ provide: AuditLogService, useValue: service }], + }).compile(); + + controller = module.get(AuditLogController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('findAll', () => { + it('returns paginated audit logs with metadata for admin user', async () => { + service.findAll.mockResolvedValue(mockPaginatedResult as any); + + const filter = { + page: 1, + limit: 20, + targetType: 'user', + }; + + const result = await controller.findAll( + filter as any, + 'admin-1', + UserRole.ADMIN, + ); + + expect(service.findAll).toHaveBeenCalledWith( + filter, + 'admin-1', + UserRole.ADMIN, + ); + expect(result).toEqual({ + message: 'Audit logs retrieved successfully', + data: mockPaginatedResult.data, + meta: { + currentPage: 1, + itemsPerPage: 20, + totalItems: 2, + totalPages: 1, + hasPreviousPage: false, + hasNextPage: false, + }, + totalAmount: '2', + }); + }); + + it('delegates filter params to service', async () => { + service.findAll.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 10, + totalPages: 0, + } as any); + + const filter = { + actorId: 'specific-user', + action: 'users.create', + targetType: 'workspace', + targetId: 'ws-1', + dateFrom: '2024-01-01', + dateTo: '2024-01-31', + search: 'admin', + page: 2, + limit: 10, + }; + + await controller.findAll(filter as any, 'admin-1', UserRole.SUPER_ADMIN); + + expect(service.findAll).toHaveBeenCalledWith( + filter, + 'admin-1', + UserRole.SUPER_ADMIN, + ); + }); + + it('handles empty results', async () => { + service.findAll.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + } as any); + + const result = await controller.findAll( + {} as any, + 'user-1', + UserRole.USER, + ); + + expect(result.meta).toEqual({ + currentPage: 1, + itemsPerPage: 20, + totalItems: 0, + totalPages: 0, + hasPreviousPage: false, + hasNextPage: false, + }); + expect(result.data).toEqual([]); + expect(result.totalAmount).toBe('0'); + }); + + it('computes hasNextPage correctly when on first page of many', async () => { + service.findAll.mockResolvedValue({ + data: [{ id: 'log-1' }], + total: 25, + page: 1, + limit: 10, + totalPages: 3, + } as any); + + const result = await controller.findAll( + { page: 1, limit: 10 } as any, + 'admin-1', + UserRole.ADMIN, + ); + + expect(result.meta.hasNextPage).toBe(true); + expect(result.meta.hasPreviousPage).toBe(false); + }); + + it('computes hasPreviousPage correctly when on last page', async () => { + service.findAll.mockResolvedValue({ + data: [{ id: 'log-5' }], + total: 25, + page: 3, + limit: 10, + totalPages: 3, + } as any); + + const result = await controller.findAll( + { page: 3, limit: 10 } as any, + 'admin-1', + UserRole.ADMIN, + ); + + expect(result.meta.hasPreviousPage).toBe(true); + expect(result.meta.hasNextPage).toBe(false); + }); + }); +}); diff --git a/backend/src/audit-log/interceptors/audit-log.interceptor.spec.ts b/backend/src/audit-log/interceptors/audit-log.interceptor.spec.ts new file mode 100644 index 00000000..daad0ca9 --- /dev/null +++ b/backend/src/audit-log/interceptors/audit-log.interceptor.spec.ts @@ -0,0 +1,163 @@ +import { AuditLogInterceptor } from './audit-log.interceptor'; +import { AuditLogService } from '../providers/audit-log.service'; +import { of } from 'rxjs'; + +describe('AuditLogInterceptor', () => { + let interceptor: AuditLogInterceptor; + let auditLogService: jest.Mocked>; + + beforeEach(() => { + auditLogService = { create: jest.fn().mockResolvedValue(undefined) }; + interceptor = new AuditLogInterceptor(auditLogService as any); + }); + + function createContext(method: string, overrides: any = {}) { + const request = { + method, + originalUrl: overrides.url ?? '/api/users', + url: overrides.url ?? '/api/users', + ip: '192.168.1.1', + get: jest.fn().mockReturnValue('Chrome/120'), + params: { id: 'target-1' }, + body: { email: 'test@example.com' }, + user: { + id: 'user-1', + email: 'admin@example.com', + role: 'admin', + }, + ...overrides, + }; + + const response = { statusCode: 200 }; + + return { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => response, + }), + getHandler: () => ({ name: 'createUser' }), + getClass: () => ({ name: 'UsersController' }), + } as any; + } + + it('skips logging for GET requests', (done) => { + const context = createContext('GET'); + const next = { handle: () => of('data') }; + + interceptor.intercept(context, next).subscribe({ + next: (result) => { + expect(result).toBe('data'); + expect(auditLogService.create).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it('logs audit entry on successful mutating request', (done) => { + const context = createContext('POST'); + const next = { handle: () => of('created') }; + + interceptor.intercept(context, next).subscribe({ + next: () => { + expect(auditLogService.create).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + action: expect.stringContaining('create'), + targetType: 'user', + targetId: 'target-1', + }), + ); + done(); + }, + }); + }); + + it('logs audit entry on error', (done) => { + const context = createContext('DELETE'); + const { Subject } = require('rxjs'); + const subject = new Subject(); + const next = { handle: () => subject }; + + interceptor.intercept(context, next).subscribe({ + error: (err: Error) => { + expect(err.message).toBe('Delete failed'); + expect(auditLogService.create).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.stringContaining('delete'), + metadata: expect.objectContaining({ + error: 'Delete failed', + }), + }), + ); + done(); + }, + }); + + // Trigger error after microtask so async tap callback completes + setTimeout(() => subject.error(new Error('Delete failed')), 10); + }); + + it('sanitizes sensitive fields from request body', (done) => { + const context = createContext('PATCH', { + body: { + password: 'secret123', + email: 'user@example.com', + token: 'abc', + nested: { password: 'nested-secret' }, + }, + }); + const next = { handle: () => of('updated') }; + + interceptor.intercept(context, next).subscribe({ + next: () => { + expect(auditLogService.create).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + password: '[REDACTED]', + token: '[REDACTED]', + email: 'user@example.com', + nested: { password: '[REDACTED]' }, + }), + }), + ); + done(); + }, + }); + }); + + it('extracts targetType from URL', (done) => { + const context = createContext('POST', { url: '/api/workspaces/123/book' }); + const next = { handle: () => of('done') }; + + interceptor.intercept(context, next).subscribe({ + next: () => { + expect(auditLogService.create).toHaveBeenCalledWith( + expect.objectContaining({ + targetType: 'workspace', + }), + ); + done(); + }, + }); + }); + + it('handles missing user gracefully', (done) => { + const context = createContext('POST', { + user: undefined, + }); + const next = { handle: () => of('created') }; + + interceptor.intercept(context, next).subscribe({ + next: () => { + expect(auditLogService.create).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: undefined, + actorEmail: undefined, + actorRole: undefined, + }), + ); + done(); + }, + }); + }); +}); diff --git a/backend/src/audit-log/interceptors/audit-log.interceptor.ts b/backend/src/audit-log/interceptors/audit-log.interceptor.ts index fc09f002..f27ee337 100644 --- a/backend/src/audit-log/interceptors/audit-log.interceptor.ts +++ b/backend/src/audit-log/interceptors/audit-log.interceptor.ts @@ -70,7 +70,6 @@ export class AuditLogInterceptor implements NestInterceptor { error: err.message, }, }); - throw err; }, }), ); diff --git a/backend/src/audit-log/providers/create-audit-log.provider.spec.ts b/backend/src/audit-log/providers/create-audit-log.provider.spec.ts new file mode 100644 index 00000000..781b2b29 --- /dev/null +++ b/backend/src/audit-log/providers/create-audit-log.provider.spec.ts @@ -0,0 +1,92 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CreateAuditLogProvider } from './create-audit-log.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AuditLog } from '../entities/audit-log.entity'; +import { Repository } from 'typeorm'; + +describe('CreateAuditLogProvider', () => { + let provider: CreateAuditLogProvider; + let repository: any; + + beforeEach(async () => { + repository = { + create: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CreateAuditLogProvider, + { provide: getRepositoryToken(AuditLog), useValue: repository }, + ], + }).compile(); + + provider = module.get(CreateAuditLogProvider); + }); + + it('creates an audit log with all fields', async () => { + const input = { + actorId: 'user-1', + actorEmail: 'admin@example.com', + actorRole: 'admin', + action: 'users.create', + targetType: 'user', + targetId: 'target-1', + ipAddress: '192.168.1.1', + userAgent: 'Chrome/120', + metadata: { handler: 'createUser', statusCode: 201 }, + }; + + const createdEntity = { id: 'log-1', ...input }; + repository.create.mockReturnValue(createdEntity); + repository.save.mockResolvedValue(createdEntity); + + const result = await provider.create(input); + + expect(result).toEqual(createdEntity); + expect(repository.create).toHaveBeenCalledWith({ + actorId: 'user-1', + actorEmail: 'admin@example.com', + actorRole: 'admin', + action: 'users.create', + targetType: 'user', + targetId: 'target-1', + ipAddress: '192.168.1.1', + userAgent: 'Chrome/120', + metadata: { handler: 'createUser', statusCode: 201 }, + }); + expect(repository.save).toHaveBeenCalledWith(createdEntity); + }); + + it('sets null for missing optional fields', async () => { + const input = { + action: 'auth.login', + } as any; + + repository.create.mockReturnValue({ id: 'log-2', ...input }); + repository.save.mockResolvedValue({ id: 'log-2', ...input }); + + await provider.create(input); + + expect(repository.create).toHaveBeenCalledWith({ + actorId: null, + actorEmail: null, + actorRole: null, + action: 'auth.login', + targetType: null, + targetId: null, + ipAddress: null, + userAgent: null, + metadata: null, + }); + }); + + it('re-throws error when save fails', async () => { + repository.create.mockReturnValue({}); + repository.save.mockRejectedValue(new Error('DB error')); + + await expect(provider.create({ action: 'test' } as any)).rejects.toThrow( + 'DB error', + ); + }); +}); diff --git a/backend/src/audit-log/providers/find-audit-logs.provider.spec.ts b/backend/src/audit-log/providers/find-audit-logs.provider.spec.ts new file mode 100644 index 00000000..489b9dc0 --- /dev/null +++ b/backend/src/audit-log/providers/find-audit-logs.provider.spec.ts @@ -0,0 +1,141 @@ +import { FindAuditLogsProvider } from './find-audit-logs.provider'; +import { UserRole } from '../../users/enums/userRoles.enum'; + +describe('FindAuditLogsProvider', () => { + let provider: FindAuditLogsProvider; + let auditLogRepository: any; + + beforeEach(async () => { + auditLogRepository = { + createQueryBuilder: jest.fn(), + }; + provider = new FindAuditLogsProvider(auditLogRepository); + }); + + function mockQueryBuilder(overrides: any = {}) { + const qb = { + orderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(overrides.total ?? 0), + getMany: jest.fn().mockResolvedValue(overrides.data ?? []), + }; + auditLogRepository.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + describe('findAll', () => { + it('returns paginated audit logs for admin', async () => { + mockQueryBuilder({ + total: 2, + data: [{ id: 'log-1' }, { id: 'log-2' }], + }); + + const result = await provider.findAll({}, 'admin-1', UserRole.ADMIN); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + expect(result.totalPages).toBe(1); + }); + + it('forces actorId to requestingUserId for non-admin users', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({}, 'user-1', UserRole.USER); + + expect(qb.andWhere).toHaveBeenCalledWith('auditLog.actorId = :actorId', { + actorId: 'user-1', + }); + }); + + it('allows admins to filter by specific actorId', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { actorId: 'specific-user' }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith('auditLog.actorId = :actorId', { + actorId: 'specific-user', + }); + }); + + it('applies action filter', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { action: 'users.create' }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith('auditLog.action = :action', { + action: 'users.create', + }); + }); + + it('applies targetType filter', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { targetType: 'workspace' }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'auditLog.targetType = :targetType', + { targetType: 'workspace' }, + ); + }); + + it('applies date range filters', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { dateFrom: '2024-01-01', dateTo: '2024-01-31' }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'auditLog.createdAt >= :dateFrom', + { dateFrom: '2024-01-01' }, + ); + expect(qb.andWhere).toHaveBeenCalledWith( + 'auditLog.createdAt <= :dateTo', + { dateTo: '2024-01-31' }, + ); + }); + + it('applies search filter with ILIKE', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({ search: 'admin' }, 'admin-1', UserRole.ADMIN); + + expect(qb.andWhere).toHaveBeenCalledWith( + '(auditLog.actorEmail ILIKE :search OR auditLog.targetType ILIKE :search OR auditLog.action ILIKE :search)', + { search: '%admin%' }, + ); + }); + + it('applies pagination', async () => { + const qb = mockQueryBuilder({ total: 25, data: [] }); + + const result = await provider.findAll( + { page: 2, limit: 10 }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.skip).toHaveBeenCalledWith(10); + expect(qb.take).toHaveBeenCalledWith(10); + expect(result.totalPages).toBe(3); + }); + }); +}); diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts new file mode 100644 index 00000000..5c6c7853 --- /dev/null +++ b/backend/src/auth/auth.controller.spec.ts @@ -0,0 +1,194 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { UserRole } from '../users/enums/userRoles.enum'; + +jest.mock('otplib', () => ({ + generateSecret: jest.fn(() => 'mock-secret'), + generateURI: jest.fn(() => 'otpauth://...'), + verifySync: jest.fn(() => ({ valid: true })), +})); + +describe('AuthController', () => { + let controller: AuthController; + let authService: jest.Mocked>; + + beforeEach(async () => { + authService = { + createUser: jest.fn(), + createAdminUser: jest.fn(), + login: jest.fn(), + verifyOtp: jest.fn(), + resendVerificationOtp: jest.fn(), + refreshToken: jest.fn(), + requestResetPasswordOtp: jest.fn(), + resendResetPasswordVerificationOtp: jest.fn(), + verifyResetPasswordOtp: jest.fn(), + resetPassword: jest.fn(), + setup2fa: jest.fn(), + confirm2fa: jest.fn(), + verifyTotpLogin: jest.fn(), + verifyBackupCode: jest.fn(), + disable2fa: jest.fn(), + get2faStatus: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [{ provide: AuthService, useValue: authService }], + }).compile(); + + controller = module.get(AuthController); + }); + + describe('POST /auth/register', () => { + it('calls authService.createUser', async () => { + const dto = { + email: 'a@b.com', + password: 'Valid1', + firstname: 'A', + lastname: 'B', + }; + authService.createUser.mockResolvedValue({ accessToken: 'at' } as any); + const result = await controller.create(dto); + expect(authService.createUser).toHaveBeenCalledWith(dto); + expect(result).toEqual({ accessToken: 'at' }); + }); + }); + + describe('POST /auth/login', () => { + it('calls authService.login', async () => { + const dto = { email: 'a@b.com', password: 'pass' }; + authService.login.mockResolvedValue({ accessToken: 'at' } as any); + const result = await controller.login(dto); + expect(authService.login).toHaveBeenCalledWith(dto); + expect(result).toHaveProperty('accessToken'); + }); + }); + + describe('POST /auth/verify-otp', () => { + it('calls authService.verifyOtp', async () => { + const dto = { email: 'a@b.com', otp: '1234' }; + authService.verifyOtp.mockResolvedValue({ message: 'verified' } as any); + const result = await controller.verifyOtp(dto); + expect(authService.verifyOtp).toHaveBeenCalledWith(dto); + expect(result.message).toBe('verified'); + }); + }); + + describe('POST /auth/resend-verification-otp', () => { + it('calls authService.resendVerificationOtp', async () => { + const dto = { email: 'a@b.com' }; + authService.resendVerificationOtp.mockResolvedValue({ + message: 'sent', + } as any); + const result = await controller.resendVerificationOtp(dto); + expect(authService.resendVerificationOtp).toHaveBeenCalledWith('a@b.com'); + expect(result.message).toBe('sent'); + }); + }); + + describe('POST /auth/register-admin', () => { + it('calls authService.createAdminUser', async () => { + const dto = { + email: 'admin@x.com', + password: 'Admin1', + firstname: 'A', + lastname: 'B', + }; + authService.createAdminUser.mockResolvedValue({ + accessToken: 'admin-token', + } as any); + const result = await controller.createAdmin(dto); + expect(authService.createAdminUser).toHaveBeenCalledWith(dto); + expect(result.accessToken).toBe('admin-token'); + }); + }); + + describe('POST /auth/refresh-token', () => { + it('calls authService.refreshToken', async () => { + authService.refreshToken.mockResolvedValue({ + accessToken: 'new-at', + refreshToken: 'new-rt', + } as any); + const result = await controller.refreshToken('valid-rt'); + expect(authService.refreshToken).toHaveBeenCalledWith('valid-rt'); + expect(result).toHaveProperty('accessToken'); + }); + }); + + describe('GET /auth/current-user', () => { + it('returns the user', async () => { + const user = { id: 'u1', email: 'a@b.com' } as any; + const result = await controller.retrieveCurrentUser(user); + expect(result).toEqual(user); + }); + }); + + describe('POST /auth/forgot-password', () => { + it('calls authService.requestResetPasswordOtp', async () => { + const dto = { email: 'a@b.com' }; + authService.requestResetPasswordOtp.mockResolvedValue({ + message: 'OTP sent', + } as any); + const result = await controller.forgotPassword(dto); + expect(authService.requestResetPasswordOtp).toHaveBeenCalledWith(dto); + }); + }); + + describe('POST /auth/reset-password', () => { + it('calls authService.resetPassword', async () => { + const dto = { + otp: '1234', + newPassword: 'New1', + confirmNewPassword: 'New1', + }; + authService.resetPassword.mockResolvedValue({ + message: 'reset', + } as any); + const result = await controller.resetPassword(dto); + expect(authService.resetPassword).toHaveBeenCalledWith(dto); + expect(result.message).toBe('reset'); + }); + }); + + describe('2FA endpoints', () => { + it('POST /auth/2fa/setup calls service.setup2fa', async () => { + authService.setup2fa.mockReturnValue({ secret: 's' } as any); + const result = await controller.setup2fa('user-1'); + expect(authService.setup2fa).toHaveBeenCalledWith('user-1'); + }); + + it('POST /auth/2fa/confirm calls service.confirm2fa', async () => { + const dto = { token: '123456' } as any; + authService.confirm2fa.mockReturnValue({ backupCodes: [] } as any); + await controller.confirm2fa('user-1', dto); + expect(authService.confirm2fa).toHaveBeenCalledWith('user-1', dto); + }); + + it('POST /auth/2fa/verify calls service.verifyTotpLogin', async () => { + const dto = { tempToken: 't', token: '123456' } as any; + authService.verifyTotpLogin.mockResolvedValue({ + accessToken: 'at', + } as any); + await controller.verifyTotpLogin(dto); + expect(authService.verifyTotpLogin).toHaveBeenCalledWith(dto); + }); + + it('POST /auth/2fa/disable calls service.disable2fa', async () => { + const dto = { password: 'pass' } as any; + authService.disable2fa.mockResolvedValue({ message: 'disabled' } as any); + await controller.disable2fa('user-1', dto); + expect(authService.disable2fa).toHaveBeenCalledWith('user-1', dto); + }); + + it('GET /auth/2fa/status calls service.get2faStatus', async () => { + authService.get2faStatus.mockReturnValue({ + enabled: false, + backupCodesRemaining: 0, + } as any); + await controller.get2faStatus('user-1'); + expect(authService.get2faStatus).toHaveBeenCalledWith('user-1'); + }); + }); +}); diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts new file mode 100644 index 00000000..5b962502 --- /dev/null +++ b/backend/src/auth/auth.service.spec.ts @@ -0,0 +1,742 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../users/entities/user.entity'; +import { Repository } from 'typeorm'; +import { + ConflictException, + BadRequestException, + UnauthorizedException, + NotFoundException, + InternalServerErrorException, +} from '@nestjs/common'; +import { UserHelper } from './helper/user-helper'; +import { JwtHelper } from './helper/jwt-helper'; +import { EmailService } from '../email/email.service'; +import { SetupTotpProvider } from './providers/setup-totp.provider'; +import { VerifyTotpProvider } from './providers/verify-totp.provider'; +import { ManageTotpProvider } from './providers/manage-totp.provider'; +import { RefreshTokenRepositoryOperations } from './providers/refreshToken.repository'; +import { AuditLogService } from '../audit-log/providers/audit-log.service'; +import { UserRole } from '../users/enums/userRoles.enum'; +import { MembershipStatus } from '../users/enums/membership-status.enum'; +import * as moment from 'moment'; + +jest.mock('otplib', () => ({ + generateSecret: jest.fn(() => 'mock-secret'), + generateURI: jest.fn(() => 'otpauth://...'), + verifySync: jest.fn(() => ({ valid: true })), +})); + +describe('AuthService', () => { + let service: AuthService; + let userRepository: jest.Mocked>>; + let userHelper: jest.Mocked>; + let jwtHelper: jest.Mocked>; + let emailService: jest.Mocked>; + let refreshTokenRepoOps: jest.Mocked< + Partial + >; + let auditLogService: jest.Mocked>; + let setupTotpProvider: jest.Mocked>; + let verifyTotpProvider: jest.Mocked>; + let manageTotpProvider: jest.Mocked>; + + function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + email: 'test@example.com', + firstname: 'John', + lastname: 'Doe', + password: 'hashed-password', + role: UserRole.USER, + isVerified: false, + isActive: true, + isSuspended: false, + isDeleted: false, + twoFactorEnabled: false, + totpSecret: null, + totpBackupCodes: null, + verificationCode: null, + verificationCodeExpiresAt: null, + passwordResetCode: null, + passwordResetCodeExpiresAt: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + profilePicture: null, + phone: null, + username: null, + memberSince: null, + profileCompleteness: 0, + membershipStatus: MembershipStatus.INACTIVE, + fullName: 'John Doe', + refreshTokens: [], + passwordResetToken: null, + passwordResetExpiresIn: null, + lastPasswordResetSentAt: null, + verificationToken: null, + verificationTokenExpiry: null, + lastVerificationEmailSent: null, + ...overrides, + } as User; + } + + beforeEach(async () => { + userRepository = { + findOne: jest.fn(), + findOneBy: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + userHelper = { + isValidPassword: jest.fn(), + hashPassword: jest.fn(), + generateVerificationCode: jest.fn(), + formatUserResponse: jest.fn(), + verifyPassword: jest.fn(), + }; + + jwtHelper = { + generateAccessToken: jest.fn(), + generateTokens: jest.fn(), + generateTempToken: jest.fn(), + validateRefreshToken: jest.fn(), + }; + + emailService = { + sendVerificationEmail: jest.fn().mockResolvedValue(true), + sendPasswordResetEmail: jest.fn().mockResolvedValue(true), + sendRefreshTokenFamilyRevokedEmail: jest.fn().mockResolvedValue(true), + }; + + refreshTokenRepoOps = { + saveRefreshToken: jest.fn(), + findByToken: jest.fn(), + markTokenConsumed: jest.fn(), + revokeFamily: jest.fn(), + createRefreshToken: jest.fn(), + }; + + auditLogService = { + create: jest.fn().mockResolvedValue(undefined), + }; + + setupTotpProvider = { + initiate2faSetup: jest.fn(), + confirm2faSetup: jest.fn(), + }; + + verifyTotpProvider = { + verifyTotpLogin: jest.fn(), + verifyBackupCode: jest.fn(), + }; + + manageTotpProvider = { + disable2fa: jest.fn(), + get2faStatus: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: getRepositoryToken(User), useValue: userRepository }, + { provide: UserHelper, useValue: userHelper }, + { provide: JwtHelper, useValue: jwtHelper }, + { provide: EmailService, useValue: emailService }, + { provide: SetupTotpProvider, useValue: setupTotpProvider }, + { provide: VerifyTotpProvider, useValue: verifyTotpProvider }, + { provide: ManageTotpProvider, useValue: manageTotpProvider }, + { + provide: RefreshTokenRepositoryOperations, + useValue: refreshTokenRepoOps, + }, + { provide: AuditLogService, useValue: auditLogService }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + // ───────────────────────────────────────────── + // createUser + // ───────────────────────────────────────────── + describe('createUser', () => { + const dto = { + email: 'new@example.com', + password: 'ValidPass1', + firstname: 'Jane', + lastname: 'Doe', + }; + + it('creates a user and returns tokens', async () => { + userRepository.findOne.mockResolvedValue(null); + userHelper.isValidPassword.mockReturnValue(true); + userHelper.hashPassword.mockResolvedValue('hashed-pass'); + userHelper.generateVerificationCode.mockReturnValue('1234'); + userHelper.formatUserResponse.mockReturnValue({ id: 'new-id' } as any); + userRepository.create.mockReturnValue(makeUser({ id: 'new-id' })); + userRepository.save.mockResolvedValue(makeUser({ id: 'new-id' })); + jwtHelper.generateAccessToken.mockReturnValue('access-token'); + + const result = await service.createUser(dto); + + expect(result).toHaveProperty('accessToken', 'access-token'); + expect(result.user).toEqual({ id: 'new-id' }); + expect(emailService.sendVerificationEmail).toHaveBeenCalled(); + }); + + it('throws ConflictException when email already exists', async () => { + userRepository.findOne.mockResolvedValue(makeUser()); + await expect(service.createUser(dto)).rejects.toThrow(ConflictException); + }); + + it('throws ConflictException when password is invalid', async () => { + userRepository.findOne.mockResolvedValue(null); + userHelper.isValidPassword.mockReturnValue(false); + await expect(service.createUser(dto)).rejects.toThrow(ConflictException); + }); + }); + + // ───────────────────────────────────────────── + // createAdminUser + // ───────────────────────────────────────────── + describe('createAdminUser', () => { + const dto = { + email: 'admin@example.com', + password: 'AdminPass1', + firstname: 'Admin', + lastname: 'User', + }; + + it('creates an admin user', async () => { + userRepository.findOne.mockResolvedValue(null); + userHelper.isValidPassword.mockReturnValue(true); + userHelper.hashPassword.mockResolvedValue('hashed'); + userHelper.formatUserResponse.mockReturnValue({ + role: UserRole.ADMIN, + } as any); + userRepository.create.mockReturnValue(makeUser({ role: UserRole.ADMIN })); + userRepository.save.mockResolvedValue(makeUser({ role: UserRole.ADMIN })); + jwtHelper.generateAccessToken.mockReturnValue('admin-token'); + + const result = await service.createAdminUser(dto); + expect(result.accessToken).toBe('admin-token'); + }); + + it('throws ConflictException on duplicate email', async () => { + userRepository.findOne.mockResolvedValue(makeUser()); + await expect(service.createAdminUser(dto)).rejects.toThrow( + ConflictException, + ); + }); + }); + + // ───────────────────────────────────────────── + // verifyOtp + // ───────────────────────────────────────────── + describe('verifyOtp', () => { + const dto = { email: 'test@example.com', otp: '1234' }; + + it('verifies OTP and returns tokens', async () => { + const user = makeUser({ + verificationCode: '1234', + verificationCodeExpiresAt: moment().add(1, 'hour').toDate(), + }); + userRepository.findOne.mockResolvedValue(user); + userHelper.formatUserResponse.mockReturnValue({ id: user.id } as any); + jwtHelper.generateTokens.mockReturnValue({ + accessToken: 'at', + refreshToken: 'rt', + }); + refreshTokenRepoOps.saveRefreshToken.mockResolvedValue(undefined); + + const result = await service.verifyOtp(dto); + expect(result.message).toContain('verified'); + expect(result.tokens).toBeDefined(); + }); + + it('throws BadRequestException when email missing', async () => { + await expect( + service.verifyOtp({ email: '', otp: '1234' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when OTP missing', async () => { + await expect( + service.verifyOtp({ email: 'a@b.com', otp: '' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws UnauthorizedException when user not found', async () => { + userRepository.findOne.mockResolvedValue(null); + await expect(service.verifyOtp(dto)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('throws UnauthorizedException when OTP mismatches', async () => { + userRepository.findOne.mockResolvedValue( + makeUser({ + verificationCode: 'wrong', + verificationCodeExpiresAt: moment().add(1, 'hour').toDate(), + }), + ); + await expect(service.verifyOtp(dto)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('throws UnauthorizedException when OTP expired', async () => { + userRepository.findOne.mockResolvedValue( + makeUser({ + verificationCode: '1234', + verificationCodeExpiresAt: moment().subtract(1, 'hour').toDate(), + }), + ); + await expect(service.verifyOtp(dto)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + // ───────────────────────────────────────────── + // login + // ───────────────────────────────────────────── + describe('login', () => { + const dto = { email: 'test@example.com', password: 'ValidPass1' }; + + it('returns tokens on successful login', async () => { + userRepository.findOne.mockResolvedValue(makeUser({ isVerified: true })); + userHelper.verifyPassword.mockResolvedValue(true); + jwtHelper.generateTokens.mockReturnValue({ + accessToken: 'at', + refreshToken: 'rt', + }); + + const result = await service.login(dto); + expect(result).toHaveProperty('accessToken'); + }); + + it('throws on wrong credentials', async () => { + userRepository.findOne.mockResolvedValue(null); + await expect(service.login(dto)).rejects.toThrow(UnauthorizedException); + }); + + it('returns unverified message for unverified user', async () => { + userRepository.findOne.mockResolvedValue(makeUser()); + userHelper.verifyPassword.mockResolvedValue(true); + userHelper.generateVerificationCode.mockReturnValue('1234'); + + const result = await service.login(dto); + expect(result).toHaveProperty('message'); + expect(result).not.toHaveProperty('accessToken'); + }); + + it('returns requiresTwoFactor when 2FA enabled', async () => { + userRepository.findOne.mockResolvedValue( + makeUser({ isVerified: true, twoFactorEnabled: true }), + ); + userHelper.verifyPassword.mockResolvedValue(true); + jwtHelper.generateTempToken.mockReturnValue('temp-token'); + + const result = await service.login(dto); + expect(result.requiresTwoFactor).toBe(true); + expect(result.tempToken).toBe('temp-token'); + }); + }); + + // ───────────────────────────────────────────── + // refreshToken + // ───────────────────────────────────────────── + describe('refreshToken', () => { + it('returns new tokens for valid refresh token', async () => { + jwtHelper.validateRefreshToken.mockReturnValue('user-1'); + userRepository.findOne.mockResolvedValue(makeUser()); + refreshTokenRepoOps.findByToken.mockResolvedValue({ + id: 'rt1', + familyId: 'fam1', + version: 1, + revoked: false, + consumedAt: null, + expiresAt: moment().add(1, 'day').toDate(), + } as any); + jwtHelper.generateTokens.mockReturnValue({ + accessToken: 'new-at', + refreshToken: 'new-rt', + }); + refreshTokenRepoOps.markTokenConsumed.mockResolvedValue(undefined); + refreshTokenRepoOps.createRefreshToken.mockResolvedValue({} as any); + + const result = await service.refreshToken('valid-token'); + expect(result.accessToken).toBe('new-at'); + expect(result.refreshToken).toBe('new-rt'); + }); + + it('throws when token is revoked (and audits family)', async () => { + jwtHelper.validateRefreshToken.mockReturnValue('user-1'); + userRepository.findOne.mockResolvedValue(makeUser()); + refreshTokenRepoOps.findByToken.mockResolvedValue({ + id: 'rt1', + familyId: 'fam1', + version: 1, + revoked: true, + consumedAt: null, + expiresAt: moment().add(1, 'day').toDate(), + } as any); + + await expect(service.refreshToken('revoked')).rejects.toThrow( + UnauthorizedException, + ); + expect(auditLogService.create).toHaveBeenCalled(); + }); + + it('throws when token has expired', async () => { + jwtHelper.validateRefreshToken.mockReturnValue('user-1'); + userRepository.findOne.mockResolvedValue(makeUser()); + refreshTokenRepoOps.findByToken.mockResolvedValue({ + id: 'rt1', + familyId: 'fam1', + version: 1, + revoked: false, + consumedAt: null, + expiresAt: moment().subtract(1, 'day').toDate(), + } as any); + + await expect(service.refreshToken('expired')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('throws UnauthorizedException when user not found', async () => { + jwtHelper.validateRefreshToken.mockReturnValue('user-1'); + userRepository.findOne.mockResolvedValue(null); + await expect(service.refreshToken('token')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + // ───────────────────────────────────────────── + // resendVerificationOtp + // ───────────────────────────────────────────── + describe('resendVerificationOtp', () => { + it('resends OTP for existing user', async () => { + userRepository.findOne.mockResolvedValue(makeUser()); + userHelper.generateVerificationCode.mockReturnValue('5678'); + userRepository.save.mockResolvedValue(makeUser()); + + const result = await service.resendVerificationOtp('test@example.com'); + expect(result.message).toContain('Otp'); + }); + + it('throws InternalServerErrorException for unknown email (wrapped by try/catch)', async () => { + userRepository.findOne.mockResolvedValue(null); + await expect( + service.resendVerificationOtp('unknown@example.com'), + ).rejects.toThrow(InternalServerErrorException); + }); + }); + + // ───────────────────────────────────────────── + // requestResetPasswordOtp + // ───────────────────────────────────────────── + describe('requestResetPasswordOtp', () => { + it('sends password reset OTP', async () => { + userRepository.findOne.mockResolvedValue(makeUser()); + userHelper.generateVerificationCode.mockReturnValue('9999'); + + const result = await service.requestResetPasswordOtp({ + email: 'test@example.com', + }); + expect(result.message).toContain('Otp'); + }); + + it('throws when email missing', async () => { + await expect( + service.requestResetPasswordOtp({ email: '' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when user not found', async () => { + userRepository.findOne.mockResolvedValue(null); + await expect( + service.requestResetPasswordOtp({ email: 'no@user.com' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ───────────────────────────────────────────── + // resetPassword + // ───────────────────────────────────────────── + describe('resetPassword', () => { + it('resets password successfully', async () => { + userRepository.findOneBy.mockResolvedValue( + makeUser({ + passwordResetCode: '1234', + passwordResetCodeExpiresAt: moment().add(1, 'hour').toDate(), + }), + ); + userHelper.isValidPassword.mockReturnValue(true); + userHelper.hashPassword.mockResolvedValue('new-hashed'); + + const result = await service.resetPassword({ + otp: '1234', + newPassword: 'NewPass1', + confirmNewPassword: 'NewPass1', + }); + expect(result.message).toContain('reset'); + }); + + it('throws when OTP expired', async () => { + userRepository.findOneBy.mockResolvedValue( + makeUser({ + passwordResetCode: '1234', + passwordResetCodeExpiresAt: moment().subtract(1, 'hour').toDate(), + }), + ); + await expect( + service.resetPassword({ + otp: '1234', + newPassword: 'NewPass1', + confirmNewPassword: 'NewPass1', + }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws when passwords do not match', async () => { + userRepository.findOneBy.mockResolvedValue( + makeUser({ + passwordResetCode: '1234', + passwordResetCodeExpiresAt: moment().add(1, 'hour').toDate(), + }), + ); + userHelper.isValidPassword.mockReturnValue(true); + await expect( + service.resetPassword({ + otp: '1234', + newPassword: 'NewPass1', + confirmNewPassword: 'Different1', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when new password fails validation', async () => { + userRepository.findOneBy.mockResolvedValue( + makeUser({ + passwordResetCode: '1234', + passwordResetCodeExpiresAt: moment().add(1, 'hour').toDate(), + }), + ); + userHelper.isValidPassword.mockReturnValue(false); + + await expect( + service.resetPassword({ + otp: '1234', + newPassword: 'short', + confirmNewPassword: 'short', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when no user matches the OTP', async () => { + userRepository.findOneBy.mockResolvedValue(null); + await expect( + service.resetPassword({ + otp: 'unknown-otp', + newPassword: 'NewPass1', + confirmNewPassword: 'NewPass1', + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ───────────────────────────────────────────── + // verifyResetPasswordOtp + // ───────────────────────────────────────────── + describe('verifyResetPasswordOtp', () => { + it('verifies the reset OTP successfully', async () => { + userRepository.findOne.mockResolvedValue( + makeUser({ + passwordResetCode: '1234', + passwordResetCodeExpiresAt: moment().add(1, 'hour').toDate(), + }), + ); + userRepository.save.mockResolvedValue(makeUser()); + + const result = await service.verifyResetPasswordOtp({ + email: 'test@example.com', + otp: '1234', + }); + expect(result.message).toContain('verified'); + }); + + it('throws BadRequestException when email missing', async () => { + await expect( + service.verifyResetPasswordOtp({ email: '', otp: '1234' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when OTP missing', async () => { + await expect( + service.verifyResetPasswordOtp({ email: 'a@b.com', otp: '' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when user not found', async () => { + userRepository.findOne.mockResolvedValue(null); + await expect( + service.verifyResetPasswordOtp({ + email: 'no@user.com', + otp: '1234', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('throws UnauthorizedException when OTP does not match', async () => { + userRepository.findOne.mockResolvedValue( + makeUser({ + passwordResetCode: 'wrong', + passwordResetCodeExpiresAt: moment().add(1, 'hour').toDate(), + }), + ); + await expect( + service.verifyResetPasswordOtp({ + email: 'test@example.com', + otp: '1234', + }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when OTP expired', async () => { + userRepository.findOne.mockResolvedValue( + makeUser({ + passwordResetCode: '1234', + passwordResetCodeExpiresAt: moment().subtract(1, 'hour').toDate(), + }), + ); + await expect( + service.verifyResetPasswordOtp({ + email: 'test@example.com', + otp: '1234', + }), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + // ───────────────────────────────────────────── + // resendResetPasswordVerificationOtp + // ───────────────────────────────────────────── + describe('resendResetPasswordVerificationOtp', () => { + it('resends the reset password OTP for existing user', async () => { + userRepository.findOne.mockResolvedValue(makeUser()); + userHelper.generateVerificationCode.mockReturnValue('5678'); + userRepository.save.mockResolvedValue(makeUser()); + + const result = await service.resendResetPasswordVerificationOtp({ + email: 'test@example.com', + }); + expect(result.message).toContain('Otp'); + }); + + it('throws InternalServerErrorException when email missing (wrapped by try/catch)', async () => { + await expect( + service.resendResetPasswordVerificationOtp({ email: '' }), + ).rejects.toThrow(InternalServerErrorException); + }); + + it('throws InternalServerErrorException when user not found (wrapped by try/catch)', async () => { + userRepository.findOne.mockResolvedValue(null); + await expect( + service.resendResetPasswordVerificationOtp({ email: 'no@user.com' }), + ).rejects.toThrow(InternalServerErrorException); + }); + }); + + // ───────────────────────────────────────────── + // retrieveUserById + // ───────────────────────────────────────────── + describe('retrieveUserById', () => { + it('returns the formatted user when found', async () => { + userRepository.findOne.mockResolvedValue(makeUser()); + userHelper.formatUserResponse.mockReturnValue({ id: 'user-1' } as any); + + const result = await service.retrieveUserById('user-1'); + expect(result).toEqual({ id: 'user-1' }); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + }); + }); + + it('throws UnauthorizedException when user not found', async () => { + userRepository.findOne.mockResolvedValue(null); + await expect(service.retrieveUserById('unknown')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + // ───────────────────────────────────────────── + // 2FA delegation methods + // ───────────────────────────────────────────── + describe('2FA delegation', () => { + it('setup2fa delegates to SetupTotpProvider', () => { + setupTotpProvider.initiate2faSetup.mockReturnValue({ + secret: 'secret', + qrCodeDataUrl: 'data:image/png;base64,...', + } as any); + const result = service.setup2fa('user-1'); + expect(setupTotpProvider.initiate2faSetup).toHaveBeenCalledWith('user-1'); + expect(result).toHaveProperty('secret'); + }); + + it('confirm2fa delegates to SetupTotpProvider', () => { + const dto = { token: '123456' } as any; + setupTotpProvider.confirm2faSetup.mockReturnValue({ + backupCodes: ['code1'], + } as any); + service.confirm2fa('user-1', dto); + expect(setupTotpProvider.confirm2faSetup).toHaveBeenCalledWith( + 'user-1', + dto, + ); + }); + + it('disable2fa delegates to ManageTotpProvider', () => { + const dto = { password: 'pass' } as any; + manageTotpProvider.disable2fa.mockResolvedValue({ + message: '2FA disabled', + } as any); + service.disable2fa('user-1', dto); + expect(manageTotpProvider.disable2fa).toHaveBeenCalledWith('user-1', dto); + }); + + it('get2faStatus delegates to ManageTotpProvider', () => { + manageTotpProvider.get2faStatus.mockReturnValue({ + enabled: false, + backupCodesRemaining: 0, + } as any); + service.get2faStatus('user-1'); + expect(manageTotpProvider.get2faStatus).toHaveBeenCalledWith('user-1'); + }); + + it('verifyTotpLogin delegates to VerifyTotpProvider', () => { + const dto = { tempToken: 't', token: '123456' } as any; + verifyTotpProvider.verifyTotpLogin.mockResolvedValue({ + accessToken: 'at', + } as any); + service.verifyTotpLogin(dto); + expect(verifyTotpProvider.verifyTotpLogin).toHaveBeenCalledWith(dto); + }); + + it('verifyBackupCode delegates to VerifyTotpProvider', () => { + const dto = { tempToken: 't', backupCode: 'code' } as any; + verifyTotpProvider.verifyBackupCode.mockResolvedValue({ + accessToken: 'at', + } as any); + service.verifyBackupCode(dto); + expect(verifyTotpProvider.verifyBackupCode).toHaveBeenCalledWith(dto); + }); + }); +}); diff --git a/backend/src/auth/providers/generateTokens.provider.spec.ts b/backend/src/auth/providers/generateTokens.provider.spec.ts new file mode 100644 index 00000000..b627bcd7 --- /dev/null +++ b/backend/src/auth/providers/generateTokens.provider.spec.ts @@ -0,0 +1,66 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GenerateTokensProvider } from './generateTokens.provider'; +import { JwtService } from '@nestjs/jwt'; +import { User } from '../../users/entities/user.entity'; +import { UserRole } from '../../users/enums/userRoles.enum'; + +describe('GenerateTokensProvider', () => { + let provider: GenerateTokensProvider; + let jwtService: jest.Mocked>; + + beforeEach(async () => { + jwtService = { + signAsync: jest.fn(), + }; + + provider = new GenerateTokensProvider(jwtService as any); + }); + + const mockUser = { + id: 'user-1', + email: 'user@example.com', + role: UserRole.USER, + } as User; + + describe('generateAccessToken', () => { + it('signs a JWT with user payload and access token expiry', async () => { + jwtService.signAsync.mockResolvedValue('access-token-123'); + + const result = await provider.generateAccessToken(mockUser); + + expect(jwtService.signAsync).toHaveBeenCalledWith( + { sub: 'user-1', role: UserRole.USER, email: 'user@example.com' }, + expect.objectContaining({ expiresIn: expect.any(String) }), + ); + expect(result).toBe('access-token-123'); + }); + }); + + describe('generateRefreshToken', () => { + it('signs a JWT with user id and refresh token expiry', async () => { + jwtService.signAsync.mockResolvedValue('refresh-token-123'); + + const result = await provider.generateRefreshToken(mockUser); + + expect(jwtService.signAsync).toHaveBeenCalledWith( + { sub: 'user-1' }, + expect.objectContaining({ expiresIn: expect.any(String) }), + ); + expect(result).toBe('refresh-token-123'); + }); + }); + + describe('generateBothTokens', () => { + it('generates both access and refresh tokens', async () => { + jwtService.signAsync + .mockResolvedValueOnce('access-token-123') + .mockResolvedValueOnce('refresh-token-123'); + + const result = await provider.generateBothTokens(mockUser); + + expect(jwtService.signAsync).toHaveBeenCalledTimes(2); + expect(result.accessToken).toBe('access-token-123'); + expect(result.refreshToken).toBe('refresh-token-123'); + }); + }); +}); diff --git a/backend/src/auth/providers/hashing.provider.spec.ts b/backend/src/auth/providers/hashing.provider.spec.ts new file mode 100644 index 00000000..2d9d0e01 --- /dev/null +++ b/backend/src/auth/providers/hashing.provider.spec.ts @@ -0,0 +1,52 @@ +import { HashingProvider } from './hashing.provider'; +import * as bcrypt from 'bcrypt'; + +jest.mock('bcrypt'); + +describe('HashingProvider', () => { + let provider: HashingProvider; + + beforeEach(() => { + provider = new HashingProvider(); + jest.clearAllMocks(); + }); + + describe('hash', () => { + it('hashes a plain string using bcrypt with 10 salt rounds', async () => { + (bcrypt.hash as jest.Mock).mockResolvedValue('$2b$10$hashedvalue'); + + const result = await provider.hash('plain-password'); + + expect(bcrypt.hash).toHaveBeenCalledWith('plain-password', 10); + expect(result).toBe('$2b$10$hashedvalue'); + }); + }); + + describe('compare', () => { + it('returns true when plain text matches hash', async () => { + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + + const result = await provider.compare( + 'plain-password', + '$2b$10$hashedvalue', + ); + + expect(bcrypt.compare).toHaveBeenCalledWith( + 'plain-password', + '$2b$10$hashedvalue', + ); + expect(result).toBe(true); + }); + + it('returns false when plain text does not match hash', async () => { + (bcrypt.compare as jest.Mock).mockResolvedValue(false); + + const result = await provider.compare( + 'wrong-password', + '$2b$10$hashedvalue', + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/backend/src/auth/providers/manage-totp.provider.spec.ts b/backend/src/auth/providers/manage-totp.provider.spec.ts new file mode 100644 index 00000000..67ca33ac --- /dev/null +++ b/backend/src/auth/providers/manage-totp.provider.spec.ts @@ -0,0 +1,106 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { ManageTotpProvider } from './manage-totp.provider'; +import { HashingProvider } from './hashing.provider'; + +describe('ManageTotpProvider', () => { + let provider: ManageTotpProvider; + let usersRepository: any; + let hashingProvider: jest.Mocked>; + + beforeEach(() => { + usersRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + hashingProvider = { compare: jest.fn() }; + provider = new ManageTotpProvider( + usersRepository, + hashingProvider as HashingProvider, + ); + }); + + describe('disable2fa', () => { + it('disables 2FA when correct password is provided', async () => { + const user = { + id: 'user-1', + password: '$2b$10$hashed', + twoFactorEnabled: true, + totpSecret: 'MOCK_SECRET', + totpBackupCodes: ['code1', 'code2'], + }; + usersRepository.findOne.mockResolvedValue(user); + hashingProvider.compare.mockResolvedValue(true); + + const result = await provider.disable2fa('user-1', { + password: 'correct-password', + }); + + expect(hashingProvider.compare).toHaveBeenCalledWith( + 'correct-password', + user.password, + ); + expect(user.twoFactorEnabled).toBe(false); + expect(user.totpSecret).toBeNull(); + expect(user.totpBackupCodes).toBeNull(); + expect(usersRepository.save).toHaveBeenCalledWith(user); + expect(result.message).toBe('2FA has been disabled'); + }); + + it('throws UnauthorizedException when password is invalid', async () => { + const user = { + id: 'user-1', + password: '$2b$10$hashed', + }; + usersRepository.findOne.mockResolvedValue(user); + hashingProvider.compare.mockResolvedValue(false); + + await expect( + provider.disable2fa('user-1', { password: 'wrong-password' }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when user not found', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect( + provider.disable2fa('unknown', { password: 'password' }), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('get2faStatus', () => { + it('returns 2FA status for a user with 2FA enabled', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'user-1', + twoFactorEnabled: true, + totpBackupCodes: ['code1', 'code2', 'code3'], + }); + + const result = await provider.get2faStatus('user-1'); + + expect(result.enabled).toBe(true); + expect(result.backupCodesRemaining).toBe(3); + }); + + it('returns 2FA status for a user without 2FA', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'user-1', + twoFactorEnabled: false, + totpBackupCodes: null, + }); + + const result = await provider.get2faStatus('user-1'); + + expect(result.enabled).toBe(false); + expect(result.backupCodesRemaining).toBe(0); + }); + + it('throws UnauthorizedException when user not found', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect(provider.get2faStatus('unknown')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); +}); diff --git a/backend/src/auth/providers/refreshToken.repository.spec.ts b/backend/src/auth/providers/refreshToken.repository.spec.ts new file mode 100644 index 00000000..27e9aa66 --- /dev/null +++ b/backend/src/auth/providers/refreshToken.repository.spec.ts @@ -0,0 +1,200 @@ +import { NotFoundException } from '@nestjs/common'; +import { RefreshTokenRepositoryOperations } from './refreshToken.repository'; +import { User } from '../../users/entities/user.entity'; + +describe('RefreshTokenRepositoryOperations', () => { + let repo: RefreshTokenRepositoryOperations; + let refreshTokenRepo: any; + + beforeEach(() => { + refreshTokenRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + }; + repo = new RefreshTokenRepositoryOperations(refreshTokenRepo); + }); + + const mockUser = { id: 'user-1' } as User; + + const mockToken = { + id: 'rt-1', + userId: 'user-1', + token: 'some-token', + familyId: 'fam_123', + version: 1, + expiresAt: new Date(Date.now() + 86400000), + revoked: false, + consumedAt: null, + user: null as any, + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('createRefreshToken', () => { + afterEach(() => { + delete process.env.JWT_REFRESH_EXPIRATION; + }); + + it('creates and saves a refresh token', async () => { + refreshTokenRepo.create.mockReturnValue(mockToken); + refreshTokenRepo.save.mockResolvedValue(mockToken); + + process.env.JWT_REFRESH_EXPIRATION = '604800000'; + const result = await repo.createRefreshToken( + mockUser, + 'some-token', + 'fam_123', + 1, + ); + + expect(refreshTokenRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + token: 'some-token', + familyId: 'fam_123', + version: 1, + revoked: false, + consumedAt: null, + }), + ); + expect(result).toEqual(mockToken); + }); + }); + + describe('saveRefreshToken', () => { + it('generates a family ID and creates a refresh token', async () => { + refreshTokenRepo.create.mockReturnValue(mockToken); + refreshTokenRepo.save.mockResolvedValue(mockToken); + + const result = await repo.saveRefreshToken(mockUser, 'some-token'); + + expect(refreshTokenRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + token: 'some-token', + version: 1, + revoked: false, + }), + ); + expect(result).toEqual(mockToken); + }); + }); + + describe('findByToken', () => { + it('finds a token by its value', async () => { + refreshTokenRepo.findOne.mockResolvedValue(mockToken); + + const result = await repo.findByToken('some-token'); + + expect(refreshTokenRepo.findOne).toHaveBeenCalledWith({ + where: { token: 'some-token' }, + }); + expect(result).toEqual(mockToken); + }); + + it('returns null when token not found', async () => { + refreshTokenRepo.findOne.mockResolvedValue(null); + + const result = await repo.findByToken('unknown'); + + expect(result).toBeNull(); + }); + }); + + describe('revokeToken', () => { + it('marks a token as revoked', async () => { + await repo.revokeToken('some-token'); + + expect(refreshTokenRepo.update).toHaveBeenCalledWith( + { token: 'some-token' }, + { revoked: true }, + ); + }); + }); + + describe('findValidToken', () => { + it('returns token when valid', async () => { + refreshTokenRepo.findOne.mockResolvedValue(mockToken); + + const result = await repo.findValidToken('some-token'); + + expect(result).toEqual(mockToken); + }); + + it('returns null when token is revoked', async () => { + refreshTokenRepo.findOne.mockResolvedValue({ + ...mockToken, + revoked: true, + }); + + const result = await repo.findValidToken('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null when token is consumed', async () => { + refreshTokenRepo.findOne.mockResolvedValue({ + ...mockToken, + consumedAt: new Date(), + }); + + const result = await repo.findValidToken('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null when token is expired', async () => { + refreshTokenRepo.findOne.mockResolvedValue({ + ...mockToken, + expiresAt: new Date(Date.now() - 86400000), + }); + + const result = await repo.findValidToken('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null when token not found', async () => { + refreshTokenRepo.findOne.mockResolvedValue(null); + + const result = await repo.findValidToken('unknown'); + + expect(result).toBeNull(); + }); + }); + + describe('markTokenConsumed', () => { + it('sets consumedAt on the token', async () => { + await repo.markTokenConsumed(mockToken); + + expect(refreshTokenRepo.update).toHaveBeenCalledWith( + { id: 'rt-1' }, + { consumedAt: expect.any(Date) }, + ); + }); + }); + + describe('revokeFamily', () => { + it('revokes all non-revoked tokens in a family', async () => { + await repo.revokeFamily('fam_123'); + + expect(refreshTokenRepo.update).toHaveBeenCalledWith( + { familyId: 'fam_123', revoked: false }, + { revoked: true, consumedAt: expect.any(Date) }, + ); + }); + }); + + describe('revokeAllRefreshTokens', () => { + it('revokes all tokens for a user', async () => { + await repo.revokeAllRefreshTokens('user-1'); + + expect(refreshTokenRepo.update).toHaveBeenCalledWith( + { userId: 'user-1' }, + { revoked: true }, + ); + }); + }); +}); diff --git a/backend/src/auth/providers/setup-totp.provider.spec.ts b/backend/src/auth/providers/setup-totp.provider.spec.ts new file mode 100644 index 00000000..6bcef70f --- /dev/null +++ b/backend/src/auth/providers/setup-totp.provider.spec.ts @@ -0,0 +1,134 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { SetupTotpProvider } from './setup-totp.provider'; +import * as otplib from 'otplib'; +import * as QRCode from 'qrcode'; +import * as bcrypt from 'bcrypt'; +import * as crypto from 'crypto'; + +jest.mock('otplib'); +jest.mock('qrcode'); +jest.mock('bcrypt'); + +describe('SetupTotpProvider', () => { + let provider: SetupTotpProvider; + let usersRepository: any; + + beforeEach(() => { + usersRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + provider = new SetupTotpProvider(usersRepository); + + jest.spyOn(otplib, 'generateSecret').mockReturnValue('MOCK_SECRET'); + jest + .spyOn(otplib, 'generateURI') + .mockReturnValue( + 'otpauth://totp/NovaLabs:user@test.com?secret=MOCK_SECRET', + ); + (QRCode.toDataURL as jest.Mock).mockResolvedValue( + 'data:image/png;base64,qrcode', + ); + (bcrypt.hash as jest.Mock).mockResolvedValue('$2b$10$hashed'); + }); + + describe('initiate2faSetup', () => { + it('generates secret, saves to user, and returns QR code', async () => { + const user = { + id: 'user-1', + email: 'user@test.com', + totpSecret: null, + }; + usersRepository.findOne.mockResolvedValue(user); + usersRepository.save.mockResolvedValue({ + ...user, + totpSecret: 'MOCK_SECRET', + }); + + const result = await provider.initiate2faSetup('user-1'); + + expect(usersRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + }); + expect(user.totpSecret).toBe('MOCK_SECRET'); + expect(usersRepository.save).toHaveBeenCalledWith(user); + expect(QRCode.toDataURL).toHaveBeenCalledWith( + 'otpauth://totp/NovaLabs:user@test.com?secret=MOCK_SECRET', + ); + expect(result.secret).toBe('MOCK_SECRET'); + expect(result.qrCodeDataUrl).toBe('data:image/png;base64,qrcode'); + }); + + it('throws UnauthorizedException when user not found', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect(provider.initiate2faSetup('unknown')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('confirm2faSetup', () => { + it('verifies TOTP code and generates backup codes', async () => { + const user = { + id: 'user-1', + email: 'user@test.com', + totpSecret: 'MOCK_SECRET', + twoFactorEnabled: false, + totpBackupCodes: null, + }; + usersRepository.findOne.mockResolvedValue(user); + + jest.spyOn(otplib, 'verifySync').mockReturnValue({ valid: true } as any); + // crypto.randomBytes runs for real — it's a Node.js built-in + + const result = await provider.confirm2faSetup('user-1', { + token: '123456', + }); + + expect(otplib.verifySync).toHaveBeenCalledWith({ + token: '123456', + secret: 'MOCK_SECRET', + }); + expect(user.twoFactorEnabled).toBe(true); + expect(user.totpBackupCodes).toBeDefined(); + expect(user.totpBackupCodes).toHaveLength(8); + expect(bcrypt.hash).toHaveBeenCalledTimes(8); + expect(result.backupCodes).toBeDefined(); + expect(result.backupCodes).toHaveLength(8); + }); + + it('throws UnauthorizedException when TOTP code is invalid', async () => { + const user = { + id: 'user-1', + totpSecret: 'MOCK_SECRET', + }; + usersRepository.findOne.mockResolvedValue(user); + + jest.spyOn(otplib, 'verifySync').mockReturnValue({ valid: false } as any); + + await expect( + provider.confirm2faSetup('user-1', { token: '000000' }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when 2FA setup not initiated', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'user-1', + totpSecret: null, + }); + + await expect( + provider.confirm2faSetup('user-1', { token: '123456' }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when user not found', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect( + provider.confirm2faSetup('unknown', { token: '123456' }), + ).rejects.toThrow(UnauthorizedException); + }); + }); +}); diff --git a/backend/src/auth/providers/verify-totp.provider.spec.ts b/backend/src/auth/providers/verify-totp.provider.spec.ts new file mode 100644 index 00000000..d4f2e26d --- /dev/null +++ b/backend/src/auth/providers/verify-totp.provider.spec.ts @@ -0,0 +1,148 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { VerifyTotpProvider } from './verify-totp.provider'; +import { JwtHelper } from '../helper/jwt-helper'; +import * as otplib from 'otplib'; +import * as bcrypt from 'bcrypt'; + +jest.mock('otplib'); +jest.mock('bcrypt'); + +describe('VerifyTotpProvider', () => { + let provider: VerifyTotpProvider; + let usersRepository: any; + let jwtHelper: jest.Mocked>; + + beforeEach(() => { + usersRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + jwtHelper = { + verifyTempToken: jest.fn(), + generateTokens: jest.fn(), + }; + provider = new VerifyTotpProvider(usersRepository, jwtHelper as any); + }); + + const mockUser = { + id: 'user-1', + email: 'user@example.com', + role: 'user', + totpSecret: 'MOCK_SECRET', + totpBackupCodes: ['$2b$10$code1', '$2b$10$code2'], + }; + + describe('verifyTotpLogin', () => { + it('verifies TOTP code and returns tokens', async () => { + jwtHelper.verifyTempToken.mockReturnValue({ + sub: 'user-1', + type: '2fa_pending', + }); + usersRepository.findOne.mockResolvedValue(mockUser); + jest.spyOn(otplib, 'verifySync').mockReturnValue({ valid: true } as any); + jwtHelper.generateTokens.mockReturnValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + }); + + const result = await provider.verifyTotpLogin({ + tempToken: 'temp-token', + token: '123456', + }); + + expect(jwtHelper.verifyTempToken).toHaveBeenCalledWith('temp-token'); + expect(result.accessToken).toBe('access-token'); + expect(result.refreshToken).toBe('refresh-token'); + expect(result.user).toEqual({ + id: 'user-1', + email: 'user@example.com', + role: 'user', + }); + }); + + it('throws UnauthorizedException when TOTP code is invalid', async () => { + jwtHelper.verifyTempToken.mockReturnValue({ + sub: 'user-1', + type: '2fa_pending', + }); + usersRepository.findOne.mockResolvedValue(mockUser); + jest.spyOn(otplib, 'verifySync').mockReturnValue({ valid: false } as any); + + await expect( + provider.verifyTotpLogin({ tempToken: 'temp-token', token: '000000' }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when user not found', async () => { + jwtHelper.verifyTempToken.mockReturnValue({ + sub: 'user-1', + type: '2fa_pending', + }); + usersRepository.findOne.mockResolvedValue(null); + + await expect( + provider.verifyTotpLogin({ tempToken: 'temp-token', token: '123456' }), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('verifyBackupCode', () => { + it('verifies a backup code and removes it', async () => { + jwtHelper.verifyTempToken.mockReturnValue({ + sub: 'user-1', + type: '2fa_pending', + }); + usersRepository.findOne.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + jwtHelper.generateTokens.mockReturnValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + }); + + const result = await provider.verifyBackupCode({ + tempToken: 'temp-token', + backupCode: 'valid-code', + }); + + expect(bcrypt.compare).toHaveBeenCalledTimes(2); + expect(result.accessToken).toBe('access-token'); + expect(result.backupCodesRemaining).toBe(1); + }); + + it('throws UnauthorizedException when no backup codes match', async () => { + jwtHelper.verifyTempToken.mockReturnValue({ + sub: 'user-1', + type: '2fa_pending', + }); + usersRepository.findOne.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(false); + + await expect( + provider.verifyBackupCode({ + tempToken: 'temp-token', + backupCode: 'invalid-code', + }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when user has no backup codes', async () => { + jwtHelper.verifyTempToken.mockReturnValue({ + sub: 'user-1', + type: '2fa_pending', + }); + usersRepository.findOne.mockResolvedValue({ + ...mockUser, + totpBackupCodes: null, + }); + + await expect( + provider.verifyBackupCode({ + tempToken: 'temp-token', + backupCode: 'code', + }), + ).rejects.toThrow(UnauthorizedException); + }); + }); +}); diff --git a/backend/src/auth/strategy/jwt.strategy.spec.ts b/backend/src/auth/strategy/jwt.strategy.spec.ts new file mode 100644 index 00000000..ea0a2bab --- /dev/null +++ b/backend/src/auth/strategy/jwt.strategy.spec.ts @@ -0,0 +1,78 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { JwtStrategy } from './jwt.strategy'; +import { AuthService } from '../auth.service'; +import { UserRole } from '../../users/enums/userRoles.enum'; + +describe('JwtStrategy', () => { + let authService: jest.Mocked>; + + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + process.env.JWT_SECRET = 'test-jwt-secret'; + authService = { retrieveUserById: jest.fn() }; + }); + + afterEach(() => { + process.env = OLD_ENV; + }); + + describe('constructor', () => { + it('creates the strategy when JWT_SECRET is set', () => { + const strategy = new JwtStrategy(authService as any); + expect(strategy).toBeDefined(); + }); + + it('throws when JWT_SECRET is not set', () => { + delete process.env.JWT_SECRET; + expect(() => new JwtStrategy(authService as any)).toThrow( + 'ACCESS_TOKEN_SECRET environment variable is not defined', + ); + }); + }); + + describe('validate', () => { + it('returns user data when authService finds the user', async () => { + const strategy = new JwtStrategy(authService as any); + authService.retrieveUserById.mockResolvedValue({ + id: 'user-1', + firstname: 'John', + lastname: 'Doe', + email: 'john@example.com', + role: UserRole.USER, + } as any); + + const result = await strategy.validate({ + sub: 'user-1', + role: UserRole.USER as any, + email: 'john@example.com', + fullName: 'John Doe', + } as any); + + expect(authService.retrieveUserById).toHaveBeenCalledWith('user-1'); + expect(result).toEqual({ + id: 'user-1', + fullName: 'John Doe', + email: 'john@example.com', + role: UserRole.USER as any, + }); + }); + + it('throws UnauthorizedException when authService throws', async () => { + const strategy = new JwtStrategy(authService as any); + authService.retrieveUserById.mockRejectedValue( + new Error('User not found'), + ); + + await expect( + strategy.validate({ + sub: 'unknown', + role: UserRole.USER as any, + email: 'unknown@example.com', + fullName: 'Unknown', + } as any), + ).rejects.toThrow(UnauthorizedException); + }); + }); +}); diff --git a/backend/src/bookings/bookings.controller.spec.ts b/backend/src/bookings/bookings.controller.spec.ts new file mode 100644 index 00000000..d941352c --- /dev/null +++ b/backend/src/bookings/bookings.controller.spec.ts @@ -0,0 +1,194 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BookingsController } from './bookings.controller'; +import { BookingsService } from './bookings.service'; +import { PlanType } from './enums/plan-type.enum'; +import { UserRole } from '../users/enums/userRoles.enum'; + +describe('BookingsController', () => { + let controller: BookingsController; + let service: jest.Mocked>; + + beforeEach(async () => { + service = { + create: jest.fn(), + publicDayPass: jest.fn(), + confirm: jest.fn(), + cancel: jest.fn(), + complete: jest.fn(), + findAll: jest.fn(), + findById: jest.fn(), + calculatePrice: jest.fn(), + getPlanSummary: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [BookingsController], + providers: [{ provide: BookingsService, useValue: service }], + }).compile(); + + controller = module.get(BookingsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('publicDayPass', () => { + it('delegates to service and returns wrapped response', async () => { + const dto = { + guestName: 'John', + guestEmail: 'john@example.com', + guestPhone: '+2348000000000', + workspaceId: 'ws-1', + date: '2026-06-22', + }; + service.publicDayPass.mockResolvedValue({ + bookingId: 'b-1', + authorizationUrl: 'https://paystack.com', + } as any); + + const result = await controller.publicDayPass(dto); + expect(result).toEqual({ + message: 'Day-pass booking initiated', + data: { bookingId: 'b-1', authorizationUrl: 'https://paystack.com' }, + }); + }); + }); + + describe('create', () => { + it('delegates to service and returns wrapped response', async () => { + const dto = { workspaceId: 'ws-1' } as any; + service.create.mockResolvedValue({ id: 'booking-1' } as any); + + const result = await controller.create(dto, 'user-1'); + expect(result).toEqual({ + message: 'Booking created successfully', + data: { id: 'booking-1' }, + }); + expect(service.create).toHaveBeenCalledWith(dto, 'user-1'); + }); + }); + + describe('findAll', () => { + it('returns bookings with message and result spread', async () => { + const paginated = { + data: [{ id: 'booking-1' }], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + service.findAll.mockResolvedValue(paginated as any); + + const result = await controller.findAll( + {} as any, + 'user-1', + UserRole.USER, + ); + expect(result).toEqual({ + message: 'Bookings retrieved successfully', + ...paginated, + }); + expect(service.findAll).toHaveBeenCalledWith({}, 'user-1', UserRole.USER); + }); + }); + + describe('getPriceEstimate', () => { + it('returns calculated price estimate', async () => { + service.calculatePrice.mockReturnValue(400000); + service.getPlanSummary.mockReturnValue({ days: 1, discountPct: 0 }); + + const result = await controller.getPriceEstimate( + '50000', + PlanType.DAILY, + '1', + '2024-01-01', + '2024-01-01', + ); + expect(result).toEqual({ + message: 'Price estimate calculated', + data: { + amountKobo: 400000, + amountNaira: 4000, + days: 1, + discountPct: 0, + }, + }); + expect(service.calculatePrice).toHaveBeenCalledWith( + 50000, + PlanType.DAILY, + 1, + '2024-01-01', + '2024-01-01', + ); + }); + }); + + describe('findOne', () => { + it('returns booking by id', async () => { + service.findById.mockResolvedValue({ id: 'booking-1' } as any); + + const result = await controller.findOne( + 'booking-1', + 'user-1', + UserRole.USER, + ); + expect(result).toEqual({ + message: 'Booking retrieved successfully', + data: { id: 'booking-1' }, + }); + expect(service.findById).toHaveBeenCalledWith( + 'booking-1', + 'user-1', + UserRole.USER, + ); + }); + }); + + describe('confirm', () => { + it('confirms booking', async () => { + service.confirm.mockResolvedValue({ id: 'booking-1' } as any); + + const result = await controller.confirm('booking-1'); + expect(result).toEqual({ + message: 'Booking confirmed successfully', + data: { id: 'booking-1' }, + }); + expect(service.confirm).toHaveBeenCalledWith('booking-1'); + }); + }); + + describe('cancel', () => { + it('cancels booking', async () => { + service.cancel.mockResolvedValue({ id: 'booking-1' } as any); + + const result = await controller.cancel( + 'booking-1', + 'user-1', + UserRole.USER, + ); + expect(result).toEqual({ + message: 'Booking cancelled successfully', + data: { id: 'booking-1' }, + }); + expect(service.cancel).toHaveBeenCalledWith( + 'booking-1', + 'user-1', + UserRole.USER, + ); + }); + }); + + describe('complete', () => { + it('completes booking', async () => { + service.complete.mockResolvedValue({ id: 'booking-1' } as any); + + const result = await controller.complete('booking-1'); + expect(result).toEqual({ + message: 'Booking completed successfully', + data: { id: 'booking-1' }, + }); + expect(service.complete).toHaveBeenCalledWith('booking-1'); + }); + }); +}); diff --git a/backend/src/bookings/bookings.service.spec.ts b/backend/src/bookings/bookings.service.spec.ts new file mode 100644 index 00000000..ad4064a4 --- /dev/null +++ b/backend/src/bookings/bookings.service.spec.ts @@ -0,0 +1,188 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BookingsService } from './bookings.service'; +import { CreateBookingProvider } from './providers/create-booking.provider'; +import { CreatePublicDayPassProvider } from './providers/create-public-day-pass.provider'; +import { ConfirmBookingProvider } from './providers/confirm-booking.provider'; +import { CancelBookingProvider } from './providers/cancel-booking.provider'; +import { CompleteBookingProvider } from './providers/complete-booking.provider'; +import { FindBookingsProvider } from './providers/find-bookings.provider'; +import { PricingService } from './pricing/pricing.service'; +import { PlanType } from './enums/plan-type.enum'; +import { UserRole } from '../users/enums/userRoles.enum'; + +describe('BookingsService', () => { + let service: BookingsService; + let createProvider: jest.Mocked>; + let publicDayPassProvider: jest.Mocked>; + let confirmProvider: jest.Mocked>; + let cancelProvider: jest.Mocked>; + let completeProvider: jest.Mocked>; + let findProvider: jest.Mocked>; + let pricingService: jest.Mocked>; + + beforeEach(async () => { + createProvider = { create: jest.fn() }; + publicDayPassProvider = { create: jest.fn() }; + confirmProvider = { confirm: jest.fn() }; + cancelProvider = { cancel: jest.fn() }; + completeProvider = { complete: jest.fn() }; + findProvider = { findAll: jest.fn(), findById: jest.fn() }; + pricingService = { calculateAmount: jest.fn(), getPlanSummary: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BookingsService, + { provide: CreateBookingProvider, useValue: createProvider }, + { + provide: CreatePublicDayPassProvider, + useValue: publicDayPassProvider, + }, + { provide: ConfirmBookingProvider, useValue: confirmProvider }, + { provide: CancelBookingProvider, useValue: cancelProvider }, + { provide: CompleteBookingProvider, useValue: completeProvider }, + { provide: FindBookingsProvider, useValue: findProvider }, + { provide: PricingService, useValue: pricingService }, + ], + }).compile(); + + service = module.get(BookingsService); + }); + + describe('create', () => { + it('delegates to CreateBookingProvider', async () => { + const dto = { workspaceId: 'ws-1' } as any; + createProvider.create.mockResolvedValue({ id: 'booking-1' } as any); + + const result = await service.create(dto, 'user-1'); + expect(result).toEqual({ id: 'booking-1' }); + expect(createProvider.create).toHaveBeenCalledWith(dto, 'user-1'); + }); + }); + + describe('publicDayPass', () => { + it('delegates to CreatePublicDayPassProvider', async () => { + const dto = { guestName: 'John' } as any; + publicDayPassProvider.create.mockResolvedValue({ + bookingId: 'booking-1', + authorizationUrl: 'https://paystack.com', + } as any); + + const result = await service.publicDayPass(dto); + expect(result).toEqual({ + bookingId: 'booking-1', + authorizationUrl: 'https://paystack.com', + }); + expect(publicDayPassProvider.create).toHaveBeenCalledWith(dto); + }); + }); + + describe('confirm', () => { + it('delegates to ConfirmBookingProvider', async () => { + confirmProvider.confirm.mockResolvedValue({ id: 'booking-1' } as any); + + const result = await service.confirm('booking-1'); + expect(result).toEqual({ id: 'booking-1' }); + expect(confirmProvider.confirm).toHaveBeenCalledWith('booking-1'); + }); + }); + + describe('cancel', () => { + it('delegates to CancelBookingProvider', async () => { + cancelProvider.cancel.mockResolvedValue({ id: 'booking-1' } as any); + + const result = await service.cancel('booking-1', 'user-1', UserRole.USER); + expect(result).toEqual({ id: 'booking-1' }); + expect(cancelProvider.cancel).toHaveBeenCalledWith( + 'booking-1', + 'user-1', + UserRole.USER, + ); + }); + }); + + describe('complete', () => { + it('delegates to CompleteBookingProvider', async () => { + completeProvider.complete.mockResolvedValue({ id: 'booking-1' } as any); + + const result = await service.complete('booking-1'); + expect(result).toEqual({ id: 'booking-1' }); + expect(completeProvider.complete).toHaveBeenCalledWith('booking-1'); + }); + }); + + describe('findAll', () => { + it('delegates to FindBookingsProvider', async () => { + const query = { page: 1 } as any; + findProvider.findAll.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }); + + const result = await service.findAll(query, 'user-1', UserRole.USER); + expect(result.total).toBe(0); + expect(findProvider.findAll).toHaveBeenCalledWith( + query, + 'user-1', + UserRole.USER, + ); + }); + }); + + describe('findById', () => { + it('delegates to FindBookingsProvider', async () => { + findProvider.findById.mockResolvedValue({ id: 'booking-1' } as any); + + const result = await service.findById( + 'booking-1', + 'user-1', + UserRole.USER, + ); + expect(result).toEqual({ id: 'booking-1' }); + expect(findProvider.findById).toHaveBeenCalledWith( + 'booking-1', + 'user-1', + UserRole.USER, + ); + }); + }); + + describe('calculatePrice', () => { + it('delegates to PricingService', () => { + pricingService.calculateAmount.mockReturnValue(500000); + + const result = service.calculatePrice( + 50000, + PlanType.DAILY, + 1, + '2024-01-01', + '2024-01-01', + ); + expect(result).toBe(500000); + expect(pricingService.calculateAmount).toHaveBeenCalledWith( + 50000, + PlanType.DAILY, + 1, + '2024-01-01', + '2024-01-01', + ); + }); + }); + + describe('getPlanSummary', () => { + it('delegates to PricingService', () => { + pricingService.getPlanSummary.mockReturnValue({ + days: 22, + discountPct: 10, + }); + + const result = service.getPlanSummary(PlanType.MONTHLY); + expect(result).toEqual({ days: 22, discountPct: 10 }); + expect(pricingService.getPlanSummary).toHaveBeenCalledWith( + PlanType.MONTHLY, + ); + }); + }); +}); diff --git a/backend/src/bookings/pricing/pricing.service.spec.ts b/backend/src/bookings/pricing/pricing.service.spec.ts new file mode 100644 index 00000000..3d1a0a5e --- /dev/null +++ b/backend/src/bookings/pricing/pricing.service.spec.ts @@ -0,0 +1,129 @@ +import { PricingService } from './pricing.service'; +import { PlanType } from '../enums/plan-type.enum'; + +describe('PricingService', () => { + let service: PricingService; + + beforeEach(() => { + service = new PricingService(); + }); + + describe('calculateAmount', () => { + const hourlyRate = 50000; // 500 Naira per hour in kobo + + it('calculates DAILY plan amount using actual calendar days', () => { + // Jan 1 -> Jan 3 = 2 day diff, Math.ceil(2) = 2 days + const amount = service.calculateAmount( + hourlyRate, + PlanType.DAILY, + 1, + '2024-01-01', + '2024-01-03', + ); + // 50000 * 8h * 2 days * 1 seat = 800,000 kobo. No discount. + expect(amount).toBe(800_000); + }); + + it('uses minimum of 1 day for same-day DAILY plan', () => { + const amount = service.calculateAmount( + hourlyRate, + PlanType.DAILY, + 1, + '2024-01-01', + '2024-01-01', + ); + // 50000 * 8h * 1 day * 1 seat = 400,000 kobo. + expect(amount).toBe(400_000); + }); + + it('applies 5% WEEKLY discount', () => { + const amount = service.calculateAmount( + hourlyRate, + PlanType.WEEKLY, + 1, + '2024-01-01', + '2024-01-05', + ); + // 50000 * 8h * 5 days * 1 seat = 2,000,000 kobo. 5% off = 1,900,000. + expect(amount).toBe(1_900_000); + }); + + it('applies 10% MONTHLY discount', () => { + const amount = service.calculateAmount( + hourlyRate, + PlanType.MONTHLY, + 1, + '2024-01-01', + '2024-01-31', + ); + // 50000 * 8h * 22 days * 1 seat = 8,800,000 kobo. 10% off = 7,920,000. + expect(amount).toBe(7_920_000); + }); + + it('applies 15% QUARTERLY discount', () => { + const amount = service.calculateAmount( + hourlyRate, + PlanType.QUARTERLY, + 1, + '2024-01-01', + '2024-03-31', + ); + // 50000 * 8h * 66 days * 1 seat = 26,400,000 kobo. 15% off = 22,440,000. + expect(amount).toBe(22_440_000); + }); + + it('applies 20% YEARLY discount', () => { + const amount = service.calculateAmount( + hourlyRate, + PlanType.YEARLY, + 1, + '2024-01-01', + '2024-12-31', + ); + // 50000 * 8h * 264 days * 1 seat = 105,600,000 kobo. 20% off = 84,480,000. + expect(amount).toBe(84_480_000); + }); + + it('multiplies by seat count', () => { + // Jan 1 -> Jan 2 = 1 day diff, Math.ceil(1) = 1 day + const amount = service.calculateAmount( + hourlyRate, + PlanType.DAILY, + 3, + '2024-01-01', + '2024-01-02', + ); + // 50000 * 8h * 1 day * 3 seats = 1,200,000 kobo. + expect(amount).toBe(1_200_000); + }); + + it('floors the final amount', () => { + const amount = service.calculateAmount( + 1, // 1 kobo per hour + PlanType.YEARLY, + 1, + '2024-01-01', + '2024-12-31', + ); + // 1 * 8h * 264 days * 1 seat = 2112 kobo. 20% off = 1689.6 -> floor = 1689. + expect(amount).toBe(1689); + }); + }); + + describe('getPlanSummary', () => { + it('returns DAILY summary', () => { + const summary = service.getPlanSummary(PlanType.DAILY); + expect(summary).toEqual({ days: 1, discountPct: 0 }); + }); + + it('returns MONTHLY summary', () => { + const summary = service.getPlanSummary(PlanType.MONTHLY); + expect(summary).toEqual({ days: 22, discountPct: 10 }); + }); + + it('returns YEARLY summary', () => { + const summary = service.getPlanSummary(PlanType.YEARLY); + expect(summary).toEqual({ days: 264, discountPct: 20 }); + }); + }); +}); diff --git a/backend/src/bookings/providers/cancel-booking.provider.spec.ts b/backend/src/bookings/providers/cancel-booking.provider.spec.ts new file mode 100644 index 00000000..5a516941 --- /dev/null +++ b/backend/src/bookings/providers/cancel-booking.provider.spec.ts @@ -0,0 +1,103 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { CancelBookingProvider } from './cancel-booking.provider'; +import { BookingStatus } from '../enums/booking-status.enum'; +import { UserRole } from '../../users/enums/userRoles.enum'; + +describe('CancelBookingProvider', () => { + let provider: CancelBookingProvider; + let bookingsRepository: any; + let usersRepository: any; + let emailService: any; + let workspacesService: any; + + beforeEach(() => { + bookingsRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + usersRepository = { + findOne: jest.fn(), + }; + emailService = { + sendBookingCancelledEmail: jest.fn().mockResolvedValue(undefined), + }; + workspacesService = { + findById: jest.fn(), + }; + provider = new CancelBookingProvider( + bookingsRepository, + usersRepository, + emailService, + workspacesService, + ); + }); + + it('cancels a pending booking owned by the user', async () => { + const booking = { + id: 'booking-1', + userId: 'user-1', + status: BookingStatus.PENDING, + }; + bookingsRepository.findOne.mockResolvedValue(booking); + bookingsRepository.save.mockImplementation((b) => Promise.resolve(b)); + + const result = await provider.cancel('booking-1', 'user-1', UserRole.USER); + + expect(result.status).toBe(BookingStatus.CANCELLED); + expect(bookingsRepository.save).toHaveBeenCalledWith(booking); + }); + + it('allows admin to cancel any booking', async () => { + const booking = { + id: 'booking-1', + userId: 'user-2', + status: BookingStatus.PENDING, + }; + bookingsRepository.findOne.mockResolvedValue(booking); + bookingsRepository.save.mockImplementation((b) => Promise.resolve(b)); + + const result = await provider.cancel( + 'booking-1', + 'admin-1', + UserRole.ADMIN, + ); + + expect(result.status).toBe(BookingStatus.CANCELLED); + }); + + it('throws NotFoundException when booking does not exist', async () => { + bookingsRepository.findOne.mockResolvedValue(null); + + await expect( + provider.cancel('unknown', 'user-1', UserRole.USER), + ).rejects.toThrow(NotFoundException); + }); + + it('throws ForbiddenException when non-admin tries to cancel another users booking', async () => { + bookingsRepository.findOne.mockResolvedValue({ + id: 'booking-1', + userId: 'user-2', + status: BookingStatus.PENDING, + }); + + await expect( + provider.cancel('booking-1', 'user-1', UserRole.USER), + ).rejects.toThrow(ForbiddenException); + }); + + it('throws BadRequestException when booking is already completed', async () => { + bookingsRepository.findOne.mockResolvedValue({ + id: 'booking-1', + userId: 'user-1', + status: BookingStatus.COMPLETED, + }); + + await expect( + provider.cancel('booking-1', 'user-1', UserRole.USER), + ).rejects.toThrow(BadRequestException); + }); +}); diff --git a/backend/src/bookings/providers/complete-booking.provider.spec.ts b/backend/src/bookings/providers/complete-booking.provider.spec.ts new file mode 100644 index 00000000..57094d45 --- /dev/null +++ b/backend/src/bookings/providers/complete-booking.provider.spec.ts @@ -0,0 +1,48 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { CompleteBookingProvider } from './complete-booking.provider'; +import { BookingStatus } from '../enums/booking-status.enum'; + +describe('CompleteBookingProvider', () => { + let provider: CompleteBookingProvider; + let bookingsRepository: any; + + beforeEach(() => { + bookingsRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + provider = new CompleteBookingProvider(bookingsRepository); + }); + + it('completes a confirmed booking', async () => { + const booking = { + id: 'booking-1', + status: BookingStatus.CONFIRMED, + }; + bookingsRepository.findOne.mockResolvedValue(booking); + bookingsRepository.save.mockImplementation((b) => Promise.resolve(b)); + + const result = await provider.complete('booking-1'); + expect(result.status).toBe(BookingStatus.COMPLETED); + expect(bookingsRepository.save).toHaveBeenCalledWith(booking); + }); + + it('throws NotFoundException when booking does not exist', async () => { + bookingsRepository.findOne.mockResolvedValue(null); + + await expect(provider.complete('unknown')).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws BadRequestException when booking is not CONFIRMED', async () => { + bookingsRepository.findOne.mockResolvedValue({ + id: 'booking-1', + status: BookingStatus.PENDING, + }); + + await expect(provider.complete('booking-1')).rejects.toThrow( + BadRequestException, + ); + }); +}); diff --git a/backend/src/bookings/providers/confirm-booking.provider.spec.ts b/backend/src/bookings/providers/confirm-booking.provider.spec.ts new file mode 100644 index 00000000..e7868254 --- /dev/null +++ b/backend/src/bookings/providers/confirm-booking.provider.spec.ts @@ -0,0 +1,99 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ConfirmBookingProvider } from './confirm-booking.provider'; +import { BookingStatus } from '../enums/booking-status.enum'; +import { MembershipStatus } from '../../users/enums/membership-status.enum'; + +describe('ConfirmBookingProvider', () => { + let provider: ConfirmBookingProvider; + let bookingsRepository: any; + let usersRepository: any; + + beforeEach(() => { + bookingsRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + usersRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + provider = new ConfirmBookingProvider(bookingsRepository, usersRepository); + }); + + it('confirms a pending booking', async () => { + const booking = { + id: 'booking-1', + userId: 'user-1', + status: BookingStatus.PENDING, + }; + bookingsRepository.findOne.mockResolvedValue(booking); + bookingsRepository.save.mockImplementation((b) => Promise.resolve(b)); + usersRepository.findOne.mockResolvedValue(null); // no user activation needed + + const result = await provider.confirm('booking-1'); + expect(result.status).toBe(BookingStatus.CONFIRMED); + expect(bookingsRepository.save).toHaveBeenCalled(); + }); + + it('activates membership when user has no memberSince date', async () => { + const booking = { + id: 'booking-1', + userId: 'user-1', + status: BookingStatus.PENDING, + }; + const user = { + id: 'user-1', + memberSince: null, + membershipStatus: MembershipStatus.INACTIVE, + }; + bookingsRepository.findOne.mockResolvedValue(booking); + bookingsRepository.save.mockImplementation((b) => Promise.resolve(b)); + usersRepository.findOne.mockResolvedValue(user); + usersRepository.save.mockImplementation((u) => Promise.resolve(u)); + + await provider.confirm('booking-1'); + + expect(user.memberSince).toBeDefined(); + expect(user.membershipStatus).toBe(MembershipStatus.ACTIVE); + expect(usersRepository.save).toHaveBeenCalledWith(user); + }); + + it('does not activate membership when user already has memberSince', async () => { + const booking = { + id: 'booking-1', + userId: 'user-1', + status: BookingStatus.PENDING, + }; + const user = { + id: 'user-1', + memberSince: new Date('2024-01-01'), + membershipStatus: MembershipStatus.ACTIVE, + }; + bookingsRepository.findOne.mockResolvedValue(booking); + bookingsRepository.save.mockImplementation((b) => Promise.resolve(b)); + usersRepository.findOne.mockResolvedValue(user); + + await provider.confirm('booking-1'); + + expect(usersRepository.save).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when booking does not exist', async () => { + bookingsRepository.findOne.mockResolvedValue(null); + + await expect(provider.confirm('unknown')).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws BadRequestException when booking is not PENDING', async () => { + bookingsRepository.findOne.mockResolvedValue({ + id: 'booking-1', + status: BookingStatus.CONFIRMED, + }); + + await expect(provider.confirm('booking-1')).rejects.toThrow( + BadRequestException, + ); + }); +}); diff --git a/backend/src/bookings/providers/create-booking.provider.spec.ts b/backend/src/bookings/providers/create-booking.provider.spec.ts new file mode 100644 index 00000000..5d0db89c --- /dev/null +++ b/backend/src/bookings/providers/create-booking.provider.spec.ts @@ -0,0 +1,151 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { CreateBookingProvider } from './create-booking.provider'; +import { BookingStatus } from '../enums/booking-status.enum'; +import { PlanType } from '../enums/plan-type.enum'; + +describe('CreateBookingProvider', () => { + let provider: CreateBookingProvider; + let bookingsRepository: any; + let usersRepository: any; + let pricingService: any; + let dataSource: any; + let emailService: any; + let mockManager: any; + + beforeEach(() => { + bookingsRepository = { create: jest.fn(), save: jest.fn() }; + usersRepository = { findOne: jest.fn().mockResolvedValue(null) }; + pricingService = { calculateAmount: jest.fn() }; + emailService = { + sendBookingCreatedEmail: jest.fn().mockResolvedValue(undefined), + }; + + mockManager = { + createQueryBuilder: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + dataSource = { + transaction: jest.fn().mockImplementation((cb) => cb(mockManager)), + }; + + provider = new CreateBookingProvider( + bookingsRepository, + usersRepository, + pricingService, + dataSource, + emailService, + ); + }); + + function mockWorkspaceQuery(workspace: any) { + const qb = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(workspace), + }; + mockManager.createQueryBuilder.mockReturnValueOnce(qb); + return qb; + } + + function mockOverlapQuery(booked: number) { + const qb = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ booked: String(booked) }), + }; + mockManager.createQueryBuilder.mockReturnValueOnce(qb); + return qb; + } + + const validDto = { + workspaceId: 'ws-1', + planType: PlanType.DAILY, + startDate: '2024-06-01', + endDate: '2024-06-05', + seatCount: 2, + }; + + it('throws BadRequestException when endDate is before startDate', async () => { + await expect( + provider.create( + { ...validDto, startDate: '2024-06-10', endDate: '2024-06-05' }, + 'user-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when workspace not found', async () => { + mockWorkspaceQuery(null); + + await expect(provider.create(validDto, 'user-1')).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws BadRequestException when workspace is inactive', async () => { + mockWorkspaceQuery({ id: 'ws-1', isActive: false, totalSeats: 10 }); + + await expect(provider.create(validDto, 'user-1')).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws ConflictException when insufficient seats available', async () => { + mockWorkspaceQuery({ + id: 'ws-1', + isActive: true, + totalSeats: 10, + hourlyRate: 50000, + }); + mockOverlapQuery(9); // 9 already booked, requesting 2, total 11 > 10 + + await expect(provider.create(validDto, 'user-1')).rejects.toThrow( + ConflictException, + ); + }); + + it('creates a booking successfully within a transaction', async () => { + const workspace = { + id: 'ws-1', + isActive: true, + totalSeats: 10, + hourlyRate: 50000, + name: 'Hot Desk A', + }; + mockWorkspaceQuery(workspace); + mockOverlapQuery(3); // 3 already booked, requesting 2, total 5 <= 10 + + pricingService.calculateAmount.mockReturnValue(800000); + mockManager.create.mockReturnValue({ id: 'booking-1' }); + mockManager.save.mockResolvedValue({ + id: 'booking-1', + workspaceId: 'ws-1', + }); + + const result = await provider.create(validDto, 'user-1'); + + expect(result).toEqual({ id: 'booking-1', workspaceId: 'ws-1' }); + expect(pricingService.calculateAmount).toHaveBeenCalledWith( + 50000, + validDto.planType, + validDto.seatCount, + validDto.startDate, + validDto.endDate, + ); + expect(mockManager.create).toHaveBeenCalledWith(expect.anything(), { + ...validDto, + userId: 'user-1', + totalAmount: 800000, + status: BookingStatus.PENDING, + }); + expect(mockManager.save).toHaveBeenCalled(); + expect(dataSource.transaction).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/bookings/providers/create-public-day-pass.provider.spec.ts b/backend/src/bookings/providers/create-public-day-pass.provider.spec.ts new file mode 100644 index 00000000..d8f80ff0 --- /dev/null +++ b/backend/src/bookings/providers/create-public-day-pass.provider.spec.ts @@ -0,0 +1,164 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { CreatePublicDayPassProvider } from './create-public-day-pass.provider'; +import { PlanType } from '../enums/plan-type.enum'; + +describe('CreatePublicDayPassProvider', () => { + let provider: CreatePublicDayPassProvider; + let bookingsRepository: any; + let paymentsRepository: any; + let pricingService: any; + let paystackProvider: any; + let configService: any; + let dataSource: any; + let mockManager: any; + + beforeEach(() => { + bookingsRepository = { create: jest.fn(), save: jest.fn() }; + paymentsRepository = { create: jest.fn(), save: jest.fn() }; + pricingService = { calculateAmount: jest.fn() }; + paystackProvider = { initializeTransaction: jest.fn() }; + configService = { get: jest.fn() }; + mockManager = { + createQueryBuilder: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + dataSource = { + transaction: jest.fn().mockImplementation((cb) => cb(mockManager)), + }; + + provider = new CreatePublicDayPassProvider( + bookingsRepository, + paymentsRepository, + pricingService, + paystackProvider, + configService, + dataSource, + ); + }); + + function mockWorkspaceQuery(workspace: any) { + const qb = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(workspace), + }; + mockManager.createQueryBuilder.mockReturnValueOnce(qb); + return qb; + } + + function mockOverlapQuery(booked: number) { + const qb = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ booked: String(booked) }), + }; + mockManager.createQueryBuilder.mockReturnValueOnce(qb); + return qb; + } + + const validDto = { + guestName: 'John Doe', + guestEmail: 'john@example.com', + guestPhone: '+2348012345678', + workspaceId: 'ws-1', + date: '2026-06-22', + }; + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-01')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('throws BadRequestException when date is in the past', async () => { + await expect( + provider.create({ ...validDto, date: '2020-01-01' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when workspace not found', async () => { + mockWorkspaceQuery(null); + + await expect(provider.create(validDto)).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when workspace is inactive', async () => { + mockWorkspaceQuery({ id: 'ws-1', isActive: false, totalSeats: 10 }); + + await expect(provider.create(validDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws ConflictException when no seats available', async () => { + mockWorkspaceQuery({ id: 'ws-1', isActive: true, totalSeats: 10 }); + mockOverlapQuery(10); + + await expect(provider.create(validDto)).rejects.toThrow(ConflictException); + }); + + it('creates a day-pass booking with payment initialization', async () => { + const workspace = { + id: 'ws-1', + isActive: true, + totalSeats: 10, + hourlyRate: 50000, + }; + mockWorkspaceQuery(workspace); + mockOverlapQuery(5); // 5 already booked, requesting 1, total 6 <= 10 + + pricingService.calculateAmount.mockReturnValue(400000); + configService.get.mockReturnValue('https://example.com/callback'); + + // First save for booking + mockManager.save.mockResolvedValueOnce({ id: 'booking-1' }); + // Second save for payment + mockManager.save.mockResolvedValueOnce({ id: 'payment-1' }); + // Third save for updated payment with reference + mockManager.save.mockResolvedValueOnce({ + id: 'payment-1', + providerReference: 'paystack-ref-1', + }); + + mockManager.create + .mockReturnValueOnce({ id: 'booking-1' }) + .mockReturnValueOnce({ id: 'payment-1' }); + + paystackProvider.initializeTransaction.mockResolvedValue({ + authorization_url: 'https://paystack.com/pay/ref-1', + reference: 'paystack-ref-1', + }); + + const result = await provider.create(validDto); + + expect(result).toEqual({ + bookingId: 'booking-1', + paymentId: 'payment-1', + authorizationUrl: 'https://paystack.com/pay/ref-1', + reference: 'paystack-ref-1', + }); + expect(pricingService.calculateAmount).toHaveBeenCalledWith( + 50000, + PlanType.DAILY, + 1, + validDto.date, + validDto.date, + ); + expect(paystackProvider.initializeTransaction).toHaveBeenCalledWith( + 'john@example.com', + 400000, + 'payment-1', + 'https://example.com/callback', + { bookingId: 'booking-1', isGuestBooking: true }, + ); + }); +}); diff --git a/backend/src/bookings/providers/find-bookings.provider.spec.ts b/backend/src/bookings/providers/find-bookings.provider.spec.ts new file mode 100644 index 00000000..0ae1e017 --- /dev/null +++ b/backend/src/bookings/providers/find-bookings.provider.spec.ts @@ -0,0 +1,174 @@ +import { NotFoundException } from '@nestjs/common'; +import { FindBookingsProvider } from './find-bookings.provider'; +import { UserRole } from '../../users/enums/userRoles.enum'; +import { BookingStatus } from '../enums/booking-status.enum'; + +describe('FindBookingsProvider', () => { + let provider: FindBookingsProvider; + let bookingsRepository: any; + + beforeEach(() => { + bookingsRepository = { + createQueryBuilder: jest.fn(), + findOne: jest.fn(), + }; + provider = new FindBookingsProvider(bookingsRepository); + }); + + function mockQueryBuilder(overrides: any = {}) { + const qb = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(overrides.total ?? 0), + getMany: jest.fn().mockResolvedValue(overrides.data ?? []), + }; + bookingsRepository.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + describe('findAll', () => { + it('filters by userId for regular users', async () => { + const qb = mockQueryBuilder({ total: 1, data: [{ id: 'booking-1' }] }); + + const result = await provider.findAll({}, 'user-1', UserRole.USER); + + expect(qb.where).toHaveBeenCalledWith('booking.userId = :userId', { + userId: 'user-1', + }); + expect(result.data).toHaveLength(1); + }); + + it('does not filter by userId for admins', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({}, 'admin-1', UserRole.ADMIN); + + expect(qb.where).not.toHaveBeenCalled(); + }); + + it('filters by userId query param for admins', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { userId: 'specific-user' }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.where).toHaveBeenCalledWith('booking.userId = :userId', { + userId: 'specific-user', + }); + }); + + it('applies status filter', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { status: BookingStatus.PENDING }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith('booking.status = :status', { + status: BookingStatus.PENDING, + }); + }); + + it('applies workspaceId filter', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { workspaceId: 'ws-1' }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'booking.workspaceId = :workspaceId', + { workspaceId: 'ws-1' }, + ); + }); + + it('applies date range filters', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { startDate: '2024-01-01', endDate: '2024-01-31' }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'booking.startDate >= :startDate', + { startDate: '2024-01-01' }, + ); + expect(qb.andWhere).toHaveBeenCalledWith('booking.endDate <= :endDate', { + endDate: '2024-01-31', + }); + }); + + it('applies pagination', async () => { + const qb = mockQueryBuilder({ total: 30, data: [] }); + + const result = await provider.findAll( + { page: 2, limit: 10 }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.skip).toHaveBeenCalledWith(10); + expect(qb.take).toHaveBeenCalledWith(10); + expect(result.totalPages).toBe(3); + }); + }); + + describe('findById', () => { + it('returns booking when found and owned by user', async () => { + const booking = { id: 'booking-1', userId: 'user-1' }; + bookingsRepository.findOne.mockResolvedValue(booking); + + const result = await provider.findById( + 'booking-1', + 'user-1', + UserRole.USER, + ); + expect(result).toEqual(booking); + }); + + it('returns booking for admin regardless of ownership', async () => { + const booking = { id: 'booking-1', userId: 'user-2' }; + bookingsRepository.findOne.mockResolvedValue(booking); + + const result = await provider.findById( + 'booking-1', + 'admin-1', + UserRole.ADMIN, + ); + expect(result).toEqual(booking); + }); + + it('throws NotFoundException when booking does not exist', async () => { + bookingsRepository.findOne.mockResolvedValue(null); + + await expect( + provider.findById('unknown', 'user-1', UserRole.USER), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when non-admin tries to view another users booking', async () => { + bookingsRepository.findOne.mockResolvedValue({ + id: 'booking-1', + userId: 'user-2', + }); + + await expect( + provider.findById('booking-1', 'user-1', UserRole.USER), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/backend/src/cloudinary/cloudinary.service.spec.ts b/backend/src/cloudinary/cloudinary.service.spec.ts new file mode 100644 index 00000000..6bc1f080 --- /dev/null +++ b/backend/src/cloudinary/cloudinary.service.spec.ts @@ -0,0 +1,134 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CloudinaryService } from './cloudinary.service'; +import { ConfigService } from '@nestjs/config'; +import { BadRequestException } from '@nestjs/common'; + +const mockUploadStream = { end: jest.fn() }; + +jest.mock('cloudinary', () => ({ + v2: { + uploader: { + upload_stream: jest.fn(), + destroy: jest.fn(), + }, + }, +})); + +// Import cloudinary AFTER the mock is set up (jest.mock is hoisted) +import { v2 as cloudinary } from 'cloudinary'; +const cloudinaryUploader = cloudinary.uploader as any; + +describe('CloudinaryService', () => { + let service: CloudinaryService; + let configService: jest.Mocked>; + + beforeEach(async () => { + jest.clearAllMocks(); + configService = { get: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CloudinaryService, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + service = module.get(CloudinaryService); + }); + + describe('uploadImage', () => { + const mockFile = { + buffer: Buffer.from('fake-image-data'), + originalname: 'test.jpg', + mimetype: 'image/jpeg', + } as Express.Multer.File; + + it('uploads image with default folder', async () => { + configService.get.mockReturnValue(undefined); + cloudinaryUploader.upload_stream.mockImplementation( + (_options: any, callback: (...args: any[]) => void) => { + callback(null, { + secure_url: + 'https://res.cloudinary.com/demo/image/upload/v1/profile-pictures/abc123', + public_id: 'profile-pictures/abc123', + }); + return mockUploadStream; + }, + ); + + const result = await service.uploadImage(mockFile); + + expect(result).toHaveProperty('secure_url'); + expect(result).toHaveProperty('public_id', 'profile-pictures/abc123'); + expect(mockUploadStream.end).toHaveBeenCalledWith(mockFile.buffer); + }); + + it('uploads image with custom folder', async () => { + cloudinaryUploader.upload_stream.mockImplementation( + (_options: any, callback: (...args: any[]) => void) => { + callback(null, { + secure_url: + 'https://res.cloudinary.com/demo/image/upload/v1/custom/test123', + public_id: 'custom/test123', + }); + return mockUploadStream; + }, + ); + + const result = await service.uploadImage(mockFile, 'custom'); + + expect(result).toHaveProperty('public_id', 'custom/test123'); + }); + + it('rejects on upload error', async () => { + cloudinaryUploader.upload_stream.mockImplementation( + (_options: any, callback: (...args: any[]) => void) => { + callback(new Error('Upload failed'), null); + return mockUploadStream; + }, + ); + + await expect(service.uploadImage(mockFile)).rejects.toThrow( + 'Upload failed', + ); + }); + }); + + describe('deleteImage', () => { + it('deletes image by publicId', async () => { + cloudinaryUploader.destroy.mockResolvedValue({ + result: 'ok', + }); + + const result = await service.deleteImage('profile-pictures/abc123'); + expect(result).toEqual({ result: 'ok' }); + expect(cloudinaryUploader.destroy).toHaveBeenCalledWith( + 'profile-pictures/abc123', + ); + }); + + it('throws BadRequestException on delete error', async () => { + cloudinaryUploader.destroy.mockRejectedValue(new Error('Delete failed')); + + await expect( + service.deleteImage('profile-pictures/abc123'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('extractPublicIdFromUrl', () => { + it('extracts public_id from Cloudinary URL', () => { + const url = + 'https://res.cloudinary.com/demo/image/upload/v1/profile-pictures/abc123.jpg'; + const result = service.extractPublicIdFromUrl(url); + expect(result).toBe('profile-pictures/abc123'); + }); + + it('handles URLs without extension', () => { + const url = + 'https://res.cloudinary.com/demo/image/upload/v1/folder/test_image'; + const result = service.extractPublicIdFromUrl(url); + expect(result).toBe('folder/test_image'); + }); + }); +}); diff --git a/backend/src/common/guards/csrf.guard.spec.ts b/backend/src/common/guards/csrf.guard.spec.ts new file mode 100644 index 00000000..c708ff06 --- /dev/null +++ b/backend/src/common/guards/csrf.guard.spec.ts @@ -0,0 +1,131 @@ +import { CsrfGuard } from './csrf.guard'; +import { ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PUBLIC } from '../../auth/decorators/public.decorator'; + +describe('CsrfGuard', () => { + let guard: CsrfGuard; + let reflector: jest.Mocked; + + function createContext( + method: string, + cookies: Record | undefined, + headers: Record, + isPublic = false, + ): any { + const request = { + method, + cookies, + headers, + }; + + reflector.getAllAndOverride.mockReturnValue(isPublic); + + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + getHandler: () => ({}), + getClass: () => ({}), + }; + } + + beforeEach(() => { + reflector = { + getAllAndOverride: jest.fn(), + } as any; + + guard = new CsrfGuard(reflector); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + describe('canActivate', () => { + it('allows public routes without CSRF check', () => { + const context = createContext('POST', undefined, {}, true); + expect(guard.canActivate(context)).toBe(true); + expect(reflector.getAllAndOverride).toHaveBeenCalledWith(PUBLIC, [ + context.getHandler(), + context.getClass(), + ]); + }); + + it('allows GET requests without CSRF check', () => { + const context = createContext('GET', undefined, {}); + expect(guard.canActivate(context)).toBe(true); + }); + + it('allows OPTIONS requests without CSRF check', () => { + const context = createContext('OPTIONS', undefined, {}); + expect(guard.canActivate(context)).toBe(true); + }); + + it('throws ForbiddenException when CSRF cookie is missing on POST', () => { + const context = createContext('POST', undefined, { + 'x-csrf-token': 'token123', + }); + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + }); + + it('throws ForbiddenException when CSRF header is missing on POST', () => { + const context = createContext('POST', { csrf: 'token123' }, {}); + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + }); + + it('throws ForbiddenException when CSRF tokens mismatch', () => { + const context = createContext( + 'POST', + { csrf: 'cookie-token' }, + { 'x-csrf-token': 'header-token' }, + ); + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + }); + + it('allows state-changing method when CSRF tokens match', () => { + const context = createContext( + 'POST', + { csrf: 'matching-token' }, + { 'x-csrf-token': 'matching-token' }, + ); + expect(guard.canActivate(context)).toBe(true); + }); + + it('allows PUT with matching CSRF tokens', () => { + const context = createContext( + 'PUT', + { csrf: 'token' }, + { 'x-csrf-token': 'token' }, + ); + expect(guard.canActivate(context)).toBe(true); + }); + + it('allows PATCH with matching CSRF tokens', () => { + const context = createContext( + 'PATCH', + { csrf: 'token' }, + { 'x-csrf-token': 'token' }, + ); + expect(guard.canActivate(context)).toBe(true); + }); + + it('allows DELETE with matching CSRF tokens', () => { + const context = createContext( + 'DELETE', + { csrf: 'token' }, + { 'x-csrf-token': 'token' }, + ); + expect(guard.canActivate(context)).toBe(true); + }); + + it('throws ForbiddenException with proper error message', () => { + const context = createContext( + 'POST', + { csrf: 'a' }, + { 'x-csrf-token': 'b' }, + ); + expect(() => guard.canActivate(context)).toThrow('Invalid CSRF token'); + }); + }); +}); diff --git a/backend/src/common/middlewares/csrf.middleware.spec.ts b/backend/src/common/middlewares/csrf.middleware.spec.ts new file mode 100644 index 00000000..74f635cf --- /dev/null +++ b/backend/src/common/middlewares/csrf.middleware.spec.ts @@ -0,0 +1,106 @@ +import { CsrfMiddleware } from './csrf.middleware'; + +describe('CsrfMiddleware', () => { + let middleware: CsrfMiddleware; + let mockRequest: any; + let mockResponse: any; + let mockNext: jest.Mock; + + beforeEach(() => { + middleware = new CsrfMiddleware(); + mockNext = jest.fn(); + }); + + it('should be defined', () => { + expect(middleware).toBeDefined(); + }); + + describe('use', () => { + it('sets csrf cookie when no csrf cookie exists', () => { + mockRequest = { + cookies: {}, + }; + mockResponse = { + cookie: jest.fn(), + }; + + middleware.use(mockRequest, mockResponse, mockNext); + + expect(mockResponse.cookie).toHaveBeenCalledWith( + 'csrf', + expect.any(String), + { + httpOnly: false, + sameSite: 'lax', + secure: false, + path: '/', + }, + ); + expect(mockNext).toHaveBeenCalled(); + }); + + it('sets csrf cookie when cookies are undefined', () => { + mockRequest = { + cookies: undefined, + }; + mockResponse = { + cookie: jest.fn(), + }; + + middleware.use(mockRequest, mockResponse, mockNext); + + expect(mockResponse.cookie).toHaveBeenCalledWith( + 'csrf', + expect.any(String), + expect.any(Object), + ); + expect(mockNext).toHaveBeenCalled(); + }); + + it('does not set csrf cookie when one already exists', () => { + mockRequest = { + cookies: { csrf: 'existing-token' }, + }; + mockResponse = { + cookie: jest.fn(), + }; + + middleware.use(mockRequest, mockResponse, mockNext); + + expect(mockResponse.cookie).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + }); + + it('generates a 64-character hex token (two UUIDs without dashes)', () => { + mockRequest = { + cookies: {}, + }; + mockResponse = { + cookie: jest.fn(), + }; + + middleware.use(mockRequest, mockResponse, mockNext); + + const token = mockResponse.cookie.mock.calls[0][1]; + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + it('sets secure flag in production', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + mockRequest = { cookies: {} }; + mockResponse = { cookie: jest.fn() }; + + middleware.use(mockRequest, mockResponse, mockNext); + + expect(mockResponse.cookie).toHaveBeenCalledWith( + 'csrf', + expect.any(String), + expect.objectContaining({ secure: true }), + ); + + process.env.NODE_ENV = originalEnv; + }); + }); +}); diff --git a/backend/src/common/middlewares/httpLogger.middleware.spec.ts b/backend/src/common/middlewares/httpLogger.middleware.spec.ts new file mode 100644 index 00000000..fd76c366 --- /dev/null +++ b/backend/src/common/middlewares/httpLogger.middleware.spec.ts @@ -0,0 +1,122 @@ +import { Logger } from '@nestjs/common'; +import { HttpLogger } from './httpLogger.middleware'; + +describe('HttpLogger', () => { + let middleware: HttpLogger; + let mockRequest: any; + let mockResponse: any; + let mockNext: jest.Mock; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(); + middleware = new HttpLogger(); + mockNext = jest.fn(); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('should be defined', () => { + expect(middleware).toBeDefined(); + }); + + describe('use', () => { + it('logs request details on response finish', () => { + mockRequest = { + ip: '192.168.1.1', + method: 'GET', + originalUrl: '/api/users', + get: jest.fn().mockReturnValue('Chrome/120'), + }; + + mockResponse = { + get: jest.fn().mockReturnValue('1234'), + statusCode: 200, + on: jest.fn((event, callback) => { + if (event === 'finish') { + callback(); + } + }), + }; + + middleware.use(mockRequest, mockResponse, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalled(); + + const loggedMessage = logSpy.mock.calls[0][0]; + expect(loggedMessage).toContain('GET'); + expect(loggedMessage).toContain('/api/users'); + expect(loggedMessage).toContain('200'); + expect(loggedMessage).toContain('1234'); + expect(loggedMessage).toContain('Chrome/120'); + expect(loggedMessage).toContain('192.168.1.1'); + }); + + it('logs with empty user-agent when not provided', () => { + mockRequest = { + ip: '10.0.0.1', + method: 'POST', + originalUrl: '/api/auth/login', + get: jest.fn().mockReturnValue(''), + }; + + mockResponse = { + get: jest.fn().mockReturnValue(undefined), + statusCode: 201, + on: jest.fn((event, callback) => { + if (event === 'finish') { + callback(); + } + }), + }; + + middleware.use(mockRequest, mockResponse, mockNext); + + const loggedMessage = logSpy.mock.calls[0][0]; + expect(loggedMessage).toContain('POST /api/auth/login 201'); + expect(loggedMessage).toContain('10.0.0.1'); + }); + + it('calls next before response finishes', () => { + mockRequest = { + ip: '127.0.0.1', + method: 'GET', + originalUrl: '/', + get: jest.fn().mockReturnValue(''), + }; + + mockResponse = { + get: jest.fn(), + statusCode: 200, + on: jest.fn(), + }; + + middleware.use(mockRequest, mockResponse, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('calculates response time in milliseconds', () => { + mockRequest = { + ip: '127.0.0.1', + method: 'GET', + originalUrl: '/', + get: jest.fn().mockReturnValue(''), + }; + + mockResponse = { + get: jest.fn(), + statusCode: 200, + on: jest.fn((event, callback) => callback()), + }; + + middleware.use(mockRequest, mockResponse, mockNext); + + const loggedMessage = logSpy.mock.calls[0][0]; + expect(loggedMessage).toMatch(/ms/); + }); + }); +}); diff --git a/backend/src/common/transformers/sanitize-string.transformer.spec.ts b/backend/src/common/transformers/sanitize-string.transformer.spec.ts new file mode 100644 index 00000000..cae407b2 --- /dev/null +++ b/backend/src/common/transformers/sanitize-string.transformer.spec.ts @@ -0,0 +1,93 @@ +import { SanitizeString } from './sanitize-string.transformer'; +import { Transform } from 'class-transformer'; + +jest.mock('class-transformer', () => ({ + Transform: jest.fn((transformFn: any) => transformFn), +})); + +describe('SanitizeString', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(SanitizeString).toBeDefined(); + }); + + it('calls Transform with a function', () => { + SanitizeString(); + expect(Transform).toHaveBeenCalledWith(expect.any(Function)); + }); + + describe('transform function behavior', () => { + let transformFn: (params: { value: any }) => any; + + beforeEach(() => { + (Transform as jest.Mock).mockClear(); + SanitizeString(); + transformFn = (Transform as jest.Mock).mock.calls[0][0]; + }); + + it('trims leading and trailing whitespace', () => { + const result = transformFn({ value: ' hello world ' }); + expect(result).toBe('hello world'); + }); + + it('removes ASCII control characters', () => { + const result = transformFn({ value: 'hello\x00world\x1Ftest' }); + expect(result).toBe('helloworldtest'); + }); + + it('removes script tags (case-insensitive)', () => { + const result = transformFn({ + value: 'hello', + }); + expect(result).toBe('alert("xss")hello'); + }); + + it('removes self-closing script tags', () => { + const result = transformFn({ + value: 'before' }); + expect(result).toBe('evil'); + }); + + it('collapses multiple spaces into one', () => { + const result = transformFn({ value: 'hello world test' }); + expect(result).toBe('hello world test'); + }); + + it('applies all sanitization steps in sequence', () => { + const result = transformFn({ + value: ' hello\x00 world ', + }); + expect(result).toBe('alert(1) hello world'); + }); + + it('returns non-string values unchanged', () => { + expect(transformFn({ value: 123 })).toBe(123); + expect(transformFn({ value: null })).toBe(null); + expect(transformFn({ value: undefined })).toBe(undefined); + expect(transformFn({ value: {} })).toEqual({}); + expect(transformFn({ value: [] })).toEqual([]); + expect(transformFn({ value: true })).toBe(true); + }); + + it('returns empty string unchanged', () => { + const result = transformFn({ value: '' }); + expect(result).toBe(''); + }); + + it('removes only control characters leaving normal punctuation', () => { + const result = transformFn({ + value: 'hello!@#$%^&*()_+-=[]{}|;:,.<>?world', + }); + expect(result).toBe('hello!@#$%^&*()_+-=[]{}|;:,.<>?world'); + }); + }); +}); diff --git a/backend/src/config/typeorm.config.ts b/backend/src/config/typeorm.config.ts new file mode 100644 index 00000000..87254503 --- /dev/null +++ b/backend/src/config/typeorm.config.ts @@ -0,0 +1,26 @@ +import { DataSource, DataSourceOptions } from 'typeorm'; +import { join } from 'path'; + +/** + * Standalone TypeORM DataSource for CLI tools (migrations, erdia, etc.). + * + * Reads connection parameters from environment variables using the same + * naming convention as the NestJS ConfigModule. + * + * Entity auto-loading: src/** /entities/*.entity.ts + */ +const options: DataSourceOptions = { + type: 'postgres', + host: process.env.DATABASE_HOST ?? process.env.DB_HOST ?? 'localhost', + port: Number(process.env.DATABASE_PORT ?? process.env.DB_PORT ?? 5432), + username: + process.env.DATABASE_USERNAME ?? process.env.DB_USERNAME ?? 'postgres', + password: + process.env.DATABASE_PASSWORD ?? process.env.DB_PASSWORD ?? 'password', + database: process.env.DATABASE_NAME ?? process.env.DB_NAME ?? 'novalabs', + entities: [join(__dirname, '..', '**', 'entities', '*.entity.{ts,js}')], + synchronize: true, + logging: false, +}; + +export const AppDataSource = new DataSource(options); diff --git a/backend/src/contact/contact.controller.spec.ts b/backend/src/contact/contact.controller.spec.ts new file mode 100644 index 00000000..c64c9723 --- /dev/null +++ b/backend/src/contact/contact.controller.spec.ts @@ -0,0 +1,90 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ContactController } from './contact.controller'; +import { ContactService } from './contact.service'; + +describe('ContactController', () => { + let controller: ContactController; + let contactService: any; + + const submitDto = { + fullName: 'Alice Smith', + email: 'alice@example.com', + subject: 'Question', + message: 'Hello, I have a question.', + }; + + beforeEach(async () => { + contactService = { + submit: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ContactController], + providers: [{ provide: ContactService, useValue: contactService }], + }).compile(); + + controller = module.get(ContactController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('submit', () => { + it('submits contact form and returns service result', async () => { + contactService.submit.mockResolvedValue({ + message: 'Your message has been sent successfully.', + }); + + const req = { ip: '192.168.1.1', headers: {} }; + const result = await controller.submit(submitDto, req); + + expect(result).toEqual({ + message: 'Your message has been sent successfully.', + }); + expect(contactService.submit).toHaveBeenCalledWith( + submitDto, + '192.168.1.1', + ); + }); + + it('extracts IP from x-forwarded-for header', async () => { + contactService.submit.mockResolvedValue({ + message: 'Sent successfully.', + }); + + const req = { + ip: '10.0.0.1', + headers: { 'x-forwarded-for': '203.0.113.5, 10.0.0.1' }, + }; + await controller.submit(submitDto, req); + + expect(contactService.submit).toHaveBeenCalledWith( + submitDto, + '203.0.113.5', + ); + }); + + it('falls back to req.ip when x-forwarded-for is empty', async () => { + contactService.submit.mockResolvedValue({ + message: 'Sent successfully.', + }); + + const req = { ip: '10.0.0.1', headers: { 'x-forwarded-for': '' } }; + await controller.submit(submitDto, req); + + expect(contactService.submit).toHaveBeenCalledWith(submitDto, '10.0.0.1'); + }); + + it('passes null when both ip and x-forwarded-for are missing', async () => { + contactService.submit.mockResolvedValue({ + message: 'Sent successfully.', + }); + + const req = { headers: {} }; + await controller.submit(submitDto, req); + + expect(contactService.submit).toHaveBeenCalledWith(submitDto, null); + }); + }); +}); diff --git a/backend/src/contact/contact.service.spec.ts b/backend/src/contact/contact.service.spec.ts new file mode 100644 index 00000000..b3a00dc0 --- /dev/null +++ b/backend/src/contact/contact.service.spec.ts @@ -0,0 +1,150 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ContactService } from './contact.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ContactMessage } from './entities/contact-message.entity'; +import { EmailService } from '../email/email.service'; +import { Logger } from '@nestjs/common'; + +describe('ContactService', () => { + let service: ContactService; + let contactRepo: any; + let emailService: any; + let loggerWarnSpy: jest.SpyInstance; + + const submitDto = { + fullName: 'Alice Smith', + email: 'alice@example.com', + subject: 'Question about pricing', + message: 'I would like to know more about your premium plans.', + }; + + beforeEach(async () => { + contactRepo = { + create: jest.fn(), + save: jest.fn(), + }; + emailService = { + sendContactConfirmation: jest.fn(), + sendContactNotification: jest.fn(), + }; + + loggerWarnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ContactService, + { provide: getRepositoryToken(ContactMessage), useValue: contactRepo }, + { provide: EmailService, useValue: emailService }, + ], + }).compile(); + + service = module.get(ContactService); + }); + + afterEach(() => { + loggerWarnSpy.mockRestore(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('submit', () => { + it('saves contact message and sends both emails', async () => { + const createdEntity = { id: 'msg-1', ...submitDto }; + contactRepo.create.mockReturnValue(createdEntity); + contactRepo.save.mockResolvedValue(createdEntity); + emailService.sendContactConfirmation.mockResolvedValue(true); + emailService.sendContactNotification.mockResolvedValue(true); + + const result = await service.submit(submitDto); + + expect(result).toEqual({ + message: 'Your message has been sent successfully.', + }); + expect(contactRepo.create).toHaveBeenCalledWith({ + ...submitDto, + ipAddress: undefined, + }); + expect(contactRepo.save).toHaveBeenCalledWith(createdEntity); + expect(emailService.sendContactConfirmation).toHaveBeenCalledWith( + 'alice@example.com', + 'Alice Smith', + 'Question about pricing', + ); + expect(emailService.sendContactNotification).toHaveBeenCalledWith( + 'Alice Smith', + 'alice@example.com', + 'Question about pricing', + 'I would like to know more about your premium plans.', + ); + }); + + it('includes IP address when provided', async () => { + contactRepo.create.mockReturnValue({}); + contactRepo.save.mockResolvedValue({}); + emailService.sendContactConfirmation.mockResolvedValue(true); + emailService.sendContactNotification.mockResolvedValue(true); + + await service.submit(submitDto, '192.168.1.1'); + + expect(contactRepo.create).toHaveBeenCalledWith({ + ...submitDto, + ipAddress: '192.168.1.1', + }); + }); + + it('handles null IP address gracefully', async () => { + contactRepo.create.mockReturnValue({}); + contactRepo.save.mockResolvedValue({}); + emailService.sendContactConfirmation.mockResolvedValue(true); + emailService.sendContactNotification.mockResolvedValue(true); + + await service.submit(submitDto, null); + + expect(contactRepo.create).toHaveBeenCalledWith({ + ...submitDto, + ipAddress: undefined, + }); + }); + + it('does not throw when confirmation email fails (fire-and-forget)', async () => { + contactRepo.create.mockReturnValue({ id: 'msg-1' }); + contactRepo.save.mockResolvedValue({ id: 'msg-1' }); + emailService.sendContactConfirmation.mockRejectedValue( + new Error('SMTP error'), + ); + emailService.sendContactNotification.mockResolvedValue(true); + + const result = await service.submit(submitDto); + + expect(result.message).toBe('Your message has been sent successfully.'); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to send contact confirmation'), + ); + }); + + it('does not throw when admin notification fails (fire-and-forget)', async () => { + contactRepo.create.mockReturnValue({ id: 'msg-1' }); + contactRepo.save.mockResolvedValue({ id: 'msg-1' }); + emailService.sendContactConfirmation.mockResolvedValue(true); + emailService.sendContactNotification.mockRejectedValue( + new Error('SMTP error'), + ); + + const result = await service.submit(submitDto); + + expect(result.message).toBe('Your message has been sent successfully.'); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to send admin notification'), + ); + }); + + it('re-throws when repository save fails', async () => { + contactRepo.create.mockReturnValue({}); + contactRepo.save.mockRejectedValue(new Error('DB error')); + + await expect(service.submit(submitDto)).rejects.toThrow('DB error'); + }); + }); +}); diff --git a/backend/src/dashboard/dashboard.controller.spec.ts b/backend/src/dashboard/dashboard.controller.spec.ts new file mode 100644 index 00000000..e2dfb7a9 --- /dev/null +++ b/backend/src/dashboard/dashboard.controller.spec.ts @@ -0,0 +1,189 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +describe('DashboardController', () => { + let controller: DashboardController; + let dashboardService: any; + + const mockUser = { id: 'user-1', role: 'user' }; + + beforeEach(async () => { + dashboardService = { + getUserStats: jest.fn(), + getActivity: jest.fn(), + getAdminStats: jest.fn(), + getUsers: jest.fn(), + getAdminAnalytics: jest.fn(), + getMemberDashboard: jest.fn(), + getMemberBookings: jest.fn(), + getMemberPayments: jest.fn(), + getMemberInvoices: jest.fn(), + getMemberCheckIns: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [DashboardController], + providers: [{ provide: DashboardService, useValue: dashboardService }], + }).compile(); + + controller = module.get(DashboardController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getStats', () => { + it('returns user stats', async () => { + const stats = { totalMembers: 100, verifiedMembers: 60 }; + dashboardService.getUserStats.mockResolvedValue(stats); + + const result = await controller.getStats(mockUser as any); + + expect(result).toEqual({ success: true, data: stats }); + expect(dashboardService.getUserStats).toHaveBeenCalledWith('user-1'); + }); + }); + + describe('getActivity', () => { + it('returns recent activity', async () => { + const activity = [{ id: 'u-1', type: 'member_registered' }]; + dashboardService.getActivity.mockResolvedValue(activity); + + const result = await controller.getActivity(); + + expect(result).toEqual({ success: true, data: activity }); + }); + }); + + describe('getAdminStats', () => { + it('returns admin stats', async () => { + const stats = { users: { total: 100 } }; + dashboardService.getAdminStats.mockResolvedValue(stats); + + const result = await controller.getAdminStats(); + + expect(result).toEqual({ success: true, data: stats }); + }); + }); + + describe('getAdminUsers', () => { + it('returns paginated users with defaults', async () => { + const data = { + data: [], + meta: { total: 0, page: 1, limit: 10, totalPages: 0 }, + }; + dashboardService.getUsers.mockResolvedValue(data); + + const result = await controller.getAdminUsers('1', '10'); + + expect(result).toEqual({ success: true, ...data }); + expect(dashboardService.getUsers).toHaveBeenCalledWith(1, 10, undefined); + }); + + it('parses and clamps pagination parameters', async () => { + dashboardService.getUsers.mockResolvedValue({ data: [], meta: {} }); + + await controller.getAdminUsers('0', '100'); + + // page clamped to min 1, limit clamped to max 50 + expect(dashboardService.getUsers).toHaveBeenCalledWith(1, 50, undefined); + }); + + it('passes search term to service', async () => { + dashboardService.getUsers.mockResolvedValue({ data: [], meta: {} }); + + await controller.getAdminUsers('1', '10', 'alice'); + + expect(dashboardService.getUsers).toHaveBeenCalledWith(1, 10, 'alice'); + }); + }); + + describe('getAdminAnalytics', () => { + it('returns admin analytics', async () => { + dashboardService.getAdminAnalytics.mockResolvedValue({ revenue: {} }); + + const result = await controller.getAdminAnalytics( + '2024-01-01', + '2024-01-31', + ); + + expect(result).toEqual({ success: true, data: { revenue: {} } }); + expect(dashboardService.getAdminAnalytics).toHaveBeenCalledWith( + '2024-01-01', + '2024-01-31', + ); + }); + }); + + describe('getMemberDashboard', () => { + it('returns member dashboard', async () => { + const data = { stats: {} }; + dashboardService.getMemberDashboard.mockResolvedValue(data); + + const result = await controller.getMemberDashboard('user-1'); + + expect(result).toEqual({ success: true, data }); + }); + }); + + describe('getMemberBookings', () => { + it('returns paginated member bookings', async () => { + dashboardService.getMemberBookings.mockResolvedValue({ + data: [], + meta: { total: 0 }, + }); + + const result = await controller.getMemberBookings('user-1', '1', '10'); + + expect(result).toEqual({ success: true, data: [], meta: { total: 0 } }); + }); + }); + + describe('getMemberPayments', () => { + it('returns paginated member payments', async () => { + dashboardService.getMemberPayments.mockResolvedValue({ + data: [], + meta: { total: 0 }, + }); + + const result = await controller.getMemberPayments('user-1', '1', '10'); + + expect(result).toEqual({ + success: true, + ...{ data: [], meta: { total: 0 } }, + }); + }); + }); + + describe('getMemberInvoices', () => { + it('returns paginated member invoices', async () => { + dashboardService.getMemberInvoices.mockResolvedValue({ + data: [], + meta: { total: 0 }, + }); + + const result = await controller.getMemberInvoices('user-1', '1', '10'); + + expect(result).toEqual({ + success: true, + ...{ data: [], meta: { total: 0 } }, + }); + }); + }); + + describe('getMemberCheckIns', () => { + it('returns member check-ins', async () => { + dashboardService.getMemberCheckIns.mockResolvedValue([]); + + const result = await controller.getMemberCheckIns('user-1', '10'); + + expect(result).toEqual({ success: true, data: [] }); + expect(dashboardService.getMemberCheckIns).toHaveBeenCalledWith( + 'user-1', + 10, + ); + }); + }); +}); diff --git a/backend/src/dashboard/dashboard.service.spec.ts b/backend/src/dashboard/dashboard.service.spec.ts new file mode 100644 index 00000000..0a34d656 --- /dev/null +++ b/backend/src/dashboard/dashboard.service.spec.ts @@ -0,0 +1,257 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DashboardService } from './dashboard.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../users/entities/user.entity'; +import { NewsletterSubscriber } from '../newsletter/entities/newsletter.entity'; +import { AdminAnalyticsProvider } from './providers/admin-analytics.provider'; +import { MemberDashboardProvider } from './providers/member-dashboard.provider'; + +describe('DashboardService', () => { + let service: DashboardService; + let userRepository: any; + let newsletterRepository: any; + let adminAnalyticsProvider: any; + let memberDashboardProvider: any; + + beforeEach(async () => { + userRepository = { + count: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(), + }; + newsletterRepository = { + count: jest.fn(), + }; + adminAnalyticsProvider = { + getActiveWorkspacesCount: jest.fn(), + getFullAdminDashboard: jest.fn(), + }; + memberDashboardProvider = { + getMemberDashboard: jest.fn(), + getMemberBookings: jest.fn(), + getMemberPayments: jest.fn(), + getMemberInvoices: jest.fn(), + getMemberCheckIns: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DashboardService, + { provide: getRepositoryToken(User), useValue: userRepository }, + { + provide: getRepositoryToken(NewsletterSubscriber), + useValue: newsletterRepository, + }, + { + provide: AdminAnalyticsProvider, + useValue: adminAnalyticsProvider, + }, + { + provide: MemberDashboardProvider, + useValue: memberDashboardProvider, + }, + ], + }).compile(); + + service = module.get(DashboardService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getUserStats', () => { + it('returns user-visible stats', async () => { + userRepository.count + .mockResolvedValueOnce(100) // totalMembers (isActive) + .mockResolvedValueOnce(60); // verifiedMembers + adminAnalyticsProvider.getActiveWorkspacesCount.mockResolvedValue(10); + + const result = await service.getUserStats('user-1'); + + expect(result).toEqual({ + totalMembers: 100, + verifiedMembers: 60, + activeWorkspaces: 10, + deskOccupancy: 60, + }); + }); + }); + + describe('getActivity', () => { + it('returns recent user registration/verification activity', async () => { + const users = [ + { + id: '1', + firstname: 'Alice', + lastname: 'Smith', + email: 'alice@example.com', + createdAt: new Date('2024-01-15'), + isVerified: true, + }, + { + id: '2', + firstname: 'Bob', + lastname: 'Jones', + email: 'bob@example.com', + createdAt: new Date('2024-01-14'), + isVerified: false, + }, + ]; + userRepository.find.mockResolvedValue(users); + + const result = await service.getActivity(); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe('member_verified'); + expect(result[1].type).toBe('member_registered'); + }); + }); + + describe('getAdminStats', () => { + function mockCounts() { + let callCount = 0; + userRepository.count.mockImplementation(() => { + callCount++; + const counts = [200, 150, 5, 20, 100, 80, 70, 30]; + return Promise.resolve(counts[callCount - 1]); + }); + newsletterRepository.count.mockImplementation(() => { + callCount++; + const counts = [100, 80, 70, 30]; + return Promise.resolve(counts[callCount - 5]); + }); + } + + it('returns admin stats with registration trend', async () => { + mockCounts(); + userRepository.createQueryBuilder.mockReturnValue({ + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + }); + + const result = await service.getAdminStats(); + + expect(result).toHaveProperty('users'); + expect(result).toHaveProperty('newsletter'); + expect(result).toHaveProperty('registrationTrend'); + expect(result.users.total).toBe(200); + expect(result.users.active).toBe(150); + expect(result.newsletter.total).toBe(100); + expect(result.newsletter.confirmationRate).toBe(80); + }); + }); + + describe('getUsers', () => { + it('returns paginated users list', async () => { + const users = [{ id: 'user-1', email: 'alice@example.com' }]; + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([users, 1]), + }; + userRepository.createQueryBuilder.mockReturnValue(qb); + + const result = await service.getUsers(1, 10); + + expect(result.data).toEqual(users); + expect(result.meta.total).toBe(1); + expect(result.meta.totalPages).toBe(1); + }); + + it('filters by search term', async () => { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + userRepository.createQueryBuilder.mockReturnValue(qb); + + await service.getUsers(1, 10, 'alice'); + + expect(qb.andWhere).toHaveBeenCalledWith( + '(user.firstname ILIKE :search OR user.lastname ILIKE :search OR user.email ILIKE :search)', + { search: '%alice%' }, + ); + }); + }); + + describe('delegation methods', () => { + it('getAdminAnalytics delegates to AdminAnalyticsProvider', async () => { + const data = { revenue: {} }; + adminAnalyticsProvider.getFullAdminDashboard.mockResolvedValue(data); + + const result = await service.getAdminAnalytics('from', 'to'); + + expect(result).toEqual(data); + expect(adminAnalyticsProvider.getFullAdminDashboard).toHaveBeenCalledWith( + 'from', + 'to', + ); + }); + + it('getMemberDashboard delegates to MemberDashboardProvider', async () => { + memberDashboardProvider.getMemberDashboard.mockResolvedValue({ + stats: {}, + }); + + const result = await service.getMemberDashboard('user-1'); + + expect(result).toEqual({ stats: {} }); + }); + + it('getMemberBookings delegates to MemberDashboardProvider', async () => { + memberDashboardProvider.getMemberBookings.mockResolvedValue({ + data: [], + meta: {}, + }); + + const result = await service.getMemberBookings('user-1', 1, 20); + + expect(result.data).toEqual([]); + }); + + it('getMemberPayments delegates to MemberDashboardProvider', async () => { + memberDashboardProvider.getMemberPayments.mockResolvedValue({ + data: [], + meta: {}, + }); + + const result = await service.getMemberPayments('user-1', 1, 20); + + expect(result.data).toEqual([]); + }); + + it('getMemberInvoices delegates to MemberDashboardProvider', async () => { + memberDashboardProvider.getMemberInvoices.mockResolvedValue({ + data: [], + meta: {}, + }); + + const result = await service.getMemberInvoices('user-1', 1, 20); + + expect(result.data).toEqual([]); + }); + + it('getMemberCheckIns delegates to MemberDashboardProvider', async () => { + memberDashboardProvider.getMemberCheckIns.mockResolvedValue([]); + + const result = await service.getMemberCheckIns('user-1', 10); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/backend/src/dashboard/providers/admin-analytics.provider.spec.ts b/backend/src/dashboard/providers/admin-analytics.provider.spec.ts new file mode 100644 index 00000000..6da42a43 --- /dev/null +++ b/backend/src/dashboard/providers/admin-analytics.provider.spec.ts @@ -0,0 +1,274 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminAnalyticsProvider } from './admin-analytics.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Payment } from '../../payments/entities/payment.entity'; +import { Invoice } from '../../invoices/entities/invoice.entity'; +import { WorkspaceLog } from '../../workspace-tracking/entities/workspace-log.entity'; +import { Workspace } from '../../workspaces/entities/workspace.entity'; +import { DataSource } from 'typeorm'; +import { PaymentStatus } from '../../payments/enums/payment-status.enum'; + +describe('AdminAnalyticsProvider', () => { + let provider: AdminAnalyticsProvider; + let bookingsRepository: any; + let paymentsRepository: any; + let invoicesRepository: any; + let workspaceLogsRepository: any; + let workspacesRepository: any; + let dataSource: any; + + beforeEach(async () => { + bookingsRepository = { + createQueryBuilder: jest.fn(), + }; + paymentsRepository = { + createQueryBuilder: jest.fn(), + }; + invoicesRepository = { + createQueryBuilder: jest.fn(), + }; + workspaceLogsRepository = { + createQueryBuilder: jest.fn(), + getCount: jest.fn(), + }; + workspacesRepository = { + createQueryBuilder: jest.fn(), + count: jest.fn(), + }; + dataSource = { + query: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminAnalyticsProvider, + { provide: getRepositoryToken(Booking), useValue: bookingsRepository }, + { provide: getRepositoryToken(Payment), useValue: paymentsRepository }, + { provide: getRepositoryToken(Invoice), useValue: invoicesRepository }, + { + provide: getRepositoryToken(WorkspaceLog), + useValue: workspaceLogsRepository, + }, + { + provide: getRepositoryToken(Workspace), + useValue: workspacesRepository, + }, + { provide: DataSource, useValue: dataSource }, + ], + }).compile(); + + provider = module.get(AdminAnalyticsProvider); + }); + + // Helper to create a simple query builder + function mockQb(overrides: any = {}) { + return { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(overrides.rawMany ?? []), + getRawOne: jest + .fn() + .mockResolvedValue(overrides.rawOne ?? { total: '0' }), + getCount: jest.fn().mockResolvedValue(overrides.count ?? 0), + clone: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + }; + } + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('getRevenueStats', () => { + it('returns revenue statistics with trend', async () => { + paymentsRepository.createQueryBuilder + .mockReturnValueOnce(mockQb({ rawOne: { total: '500000' } })) // total + .mockReturnValueOnce(mockQb({ rawOne: { total: '200000' } })) // this month + .mockReturnValueOnce(mockQb({ rawOne: { total: '150000' } })); // last month + + dataSource.query.mockResolvedValue([ + { month: '2024-01-01', total: '80000' }, + { month: '2024-02-01', total: '90000' }, + ]); + + const result = await provider.getRevenueStats(); + + expect(result.total).toBe(500000); + expect(result.thisMonth).toBe(200000); + expect(result.lastMonth).toBe(150000); + expect(result.trend).toHaveLength(2); + }); + + it('applies date range filters', async () => { + const qbTotal = mockQb({ rawOne: { total: '100000' } }); + const qbThisMonth = mockQb({ rawOne: { total: '100000' } }); + const qbLastMonth = mockQb({ rawOne: { total: '100000' } }); + paymentsRepository.createQueryBuilder + .mockReturnValueOnce(qbTotal) + .mockReturnValueOnce(qbThisMonth) + .mockReturnValueOnce(qbLastMonth); + dataSource.query.mockResolvedValue([]); + + await provider.getRevenueStats('2024-01-01', '2024-01-31'); + + expect(qbTotal.where).toHaveBeenCalledWith('p.status = :status', { + status: PaymentStatus.SUCCESS, + }); + expect(qbTotal.andWhere).toHaveBeenCalledWith('p.paidAt >= :from', { + from: '2024-01-01', + }); + expect(qbTotal.andWhere).toHaveBeenCalledWith('p.paidAt <= :to', { + to: '2024-01-31', + }); + }); + }); + + describe('getActiveWorkspacesCount', () => { + it('returns count of active workspaces', async () => { + workspacesRepository.count.mockResolvedValue(5); + + const result = await provider.getActiveWorkspacesCount(); + + expect(result).toBe(5); + expect(workspacesRepository.count).toHaveBeenCalledWith({ + where: { isActive: true }, + }); + }); + }); + + describe('getOccupancySnapshot', () => { + it('returns occupancy data', async () => { + workspacesRepository.createQueryBuilder.mockReturnValue( + mockQb({ rawOne: { total: '50' } }), + ); + workspaceLogsRepository.createQueryBuilder.mockReturnValue( + mockQb({ count: 10 }), + ); + workspacesRepository.count.mockResolvedValue(5); + + const result = await provider.getOccupancySnapshot(); + + expect(result).toEqual({ + totalSeats: 50, + occupiedSeats: 10, + availableSeats: 40, + occupancyPercent: 20, + activeWorkspaces: 5, + }); + }); + }); + + describe('getBookingStats', () => { + it('returns booking stats by status and trend', async () => { + const qb = mockQb({ + rawMany: [ + { status: 'confirmed', count: '10' }, + { status: 'pending', count: '5' }, + ], + }); + bookingsRepository.createQueryBuilder.mockReturnValue(qb); + dataSource.query.mockResolvedValue([ + { month: '2024-01-01', count: '15' }, + ]); + + const result = await provider.getBookingStats(); + + expect(result.byStatus).toEqual({ confirmed: 10, pending: 5 }); + expect(result.trend).toHaveLength(1); + }); + }); + + describe('getTopWorkspaces', () => { + it('returns top workspaces via raw query', async () => { + dataSource.query.mockResolvedValue([ + { id: 'ws-1', name: 'Desk A', bookings: '10', revenueKobo: '500000' }, + ]); + + const result = await provider.getTopWorkspaces(5); + + expect(result).toHaveLength(1); + expect(dataSource.query).toHaveBeenCalled(); + }); + }); + + describe('getTopMembers', () => { + it('returns top members via raw query', async () => { + dataSource.query.mockResolvedValue([ + { id: 'user-1', fullName: 'Alice Smith', totalKobo: '300000' }, + ]); + + const result = await provider.getTopMembers(5); + + expect(result).toHaveLength(1); + }); + }); + + describe('getInvoiceStats', () => { + it('returns invoice statistics', async () => { + const baseQb = mockQb({ count: 20, rawOne: { total: '500000' } }); + + // Prepare distinct QBs for clone() calls so that getCount returns + // different values for total vs paid vs pending. + const totalQb = mockQb({ count: 20, rawOne: { total: '500000' } }); + const amountQb = mockQb({ count: 20, rawOne: { total: '500000' } }); + const paidQb = mockQb({ count: 0, rawOne: { total: '500000' } }); + const pendingQb = mockQb({ count: 0, rawOne: { total: '500000' } }); + + let cloneIdx = 0; + baseQb.clone = jest.fn().mockImplementation(() => { + const clones = [totalQb, amountQb, paidQb, pendingQb]; + return clones[cloneIdx++]; + }); + + invoicesRepository.createQueryBuilder.mockReturnValue(baseQb); + + const result = await provider.getInvoiceStats(); + + expect(result.total).toBe(20); + expect(result.totalAmountKobo).toBe(500000); + expect(result.totalAmountNaira).toBe(5000); + expect(result.paid).toBe(0); + expect(result.pending).toBe(0); + }); + }); + + describe('getFullAdminDashboard', () => { + it('returns combined dashboard object', async () => { + paymentsRepository.createQueryBuilder + .mockReturnValueOnce(mockQb({ rawOne: { total: '100000' } })) + .mockReturnValueOnce(mockQb({ rawOne: { total: '40000' } })) + .mockReturnValueOnce(mockQb({ rawOne: { total: '30000' } })); + dataSource.query.mockResolvedValue([]); + bookingsRepository.createQueryBuilder.mockReturnValue( + mockQb({ rawMany: [] }), + ); + dataSource.query + .mockResolvedValueOnce([]) // trend (bookings) + .mockResolvedValueOnce([]) // topWorkspaces + .mockResolvedValueOnce([]); // topMembers + invoicesRepository.createQueryBuilder.mockReturnValue( + mockQb({ count: 0, rawOne: { total: '0' } }), + ); + workspacesRepository.createQueryBuilder.mockReturnValue( + mockQb({ rawOne: { total: '0' } }), + ); + workspaceLogsRepository.createQueryBuilder.mockReturnValue( + mockQb({ count: 0 }), + ); + workspacesRepository.count.mockResolvedValue(0); + + const result = await provider.getFullAdminDashboard(); + + expect(result).toHaveProperty('revenue'); + expect(result).toHaveProperty('bookings'); + expect(result).toHaveProperty('topWorkspaces'); + expect(result).toHaveProperty('topMembers'); + expect(result).toHaveProperty('invoices'); + expect(result).toHaveProperty('occupancy'); + }); + }); +}); diff --git a/backend/src/dashboard/providers/member-dashboard.provider.spec.ts b/backend/src/dashboard/providers/member-dashboard.provider.spec.ts new file mode 100644 index 00000000..d470195d --- /dev/null +++ b/backend/src/dashboard/providers/member-dashboard.provider.spec.ts @@ -0,0 +1,196 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MemberDashboardProvider } from './member-dashboard.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Payment } from '../../payments/entities/payment.entity'; +import { Invoice } from '../../invoices/entities/invoice.entity'; +import { WorkspaceLog } from '../../workspace-tracking/entities/workspace-log.entity'; +import { BookingStatus } from '../../bookings/enums/booking-status.enum'; +import { PaymentStatus } from '../../payments/enums/payment-status.enum'; + +describe('MemberDashboardProvider', () => { + let provider: MemberDashboardProvider; + let bookingsRepository: any; + let paymentsRepository: any; + let invoicesRepository: any; + let workspaceLogsRepository: any; + + beforeEach(async () => { + bookingsRepository = { + count: jest.fn(), + findAndCount: jest.fn(), + }; + paymentsRepository = { + createQueryBuilder: jest.fn(), + findAndCount: jest.fn(), + }; + invoicesRepository = { + count: jest.fn(), + findAndCount: jest.fn(), + }; + workspaceLogsRepository = { + findOne: jest.fn(), + find: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MemberDashboardProvider, + { provide: getRepositoryToken(Booking), useValue: bookingsRepository }, + { provide: getRepositoryToken(Payment), useValue: paymentsRepository }, + { provide: getRepositoryToken(Invoice), useValue: invoicesRepository }, + { + provide: getRepositoryToken(WorkspaceLog), + useValue: workspaceLogsRepository, + }, + ], + }).compile(); + + provider = module.get(MemberDashboardProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('getMemberStats', () => { + function mockPaymentsQueryBuilder(total: string) { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total }), + }; + paymentsRepository.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + it('returns aggregated member stats', async () => { + bookingsRepository.count.mockResolvedValue(3); + mockPaymentsQueryBuilder('50000'); + invoicesRepository.count.mockResolvedValue(5); + workspaceLogsRepository.findOne.mockResolvedValue({ + checkedInAt: new Date('2024-01-15'), + }); + + const result = await provider.getMemberStats('user-1'); + + expect(result).toEqual({ + activeBookings: 3, + totalSpentKobo: 50000, + totalSpentNaira: 500, + invoiceCount: 5, + lastCheckIn: expect.any(Date), + }); + expect(bookingsRepository.count).toHaveBeenCalledWith({ + where: [ + { userId: 'user-1', status: BookingStatus.CONFIRMED }, + { userId: 'user-1', status: BookingStatus.PENDING }, + ], + }); + }); + + it('returns zero values when no data exists', async () => { + bookingsRepository.count.mockResolvedValue(0); + mockPaymentsQueryBuilder('0'); + invoicesRepository.count.mockResolvedValue(0); + workspaceLogsRepository.findOne.mockResolvedValue(null); + + const result = await provider.getMemberStats('user-1'); + + expect(result).toEqual({ + activeBookings: 0, + totalSpentKobo: 0, + totalSpentNaira: 0, + invoiceCount: 0, + lastCheckIn: null, + }); + }); + }); + + describe('getMemberBookings', () => { + it('returns paginated bookings', async () => { + const bookings = [ + { id: 'booking-1', status: 'confirmed' }, + { id: 'booking-2', status: 'pending' }, + ]; + bookingsRepository.findAndCount.mockResolvedValue([bookings, 2]); + + const result = await provider.getMemberBookings('user-1', 1, 20); + + expect(result.data).toEqual(bookings); + expect(result.meta.total).toBe(2); + expect(result.meta.page).toBe(1); + expect(result.meta.limit).toBe(20); + expect(result.meta.totalPages).toBe(1); + }); + }); + + describe('getMemberPayments', () => { + it('returns paginated payments', async () => { + const payments = [{ id: 'pay-1', amount: 50000 }]; + paymentsRepository.findAndCount.mockResolvedValue([payments, 1]); + + const result = await provider.getMemberPayments('user-1', 1, 10); + + expect(result.data).toEqual(payments); + expect(result.meta.total).toBe(1); + }); + }); + + describe('getMemberInvoices', () => { + it('returns paginated invoices', async () => { + const invoices = [{ id: 'inv-1', invoiceNumber: 'INV-001' }]; + invoicesRepository.findAndCount.mockResolvedValue([invoices, 1]); + + const result = await provider.getMemberInvoices('user-1', 1, 10); + + expect(result.data).toEqual(invoices); + expect(result.meta.total).toBe(1); + }); + }); + + describe('getMemberCheckIns', () => { + it('returns recent check-ins', async () => { + const logs = [{ id: 'log-1', checkedInAt: new Date() }]; + workspaceLogsRepository.find.mockResolvedValue(logs); + + const result = await provider.getMemberCheckIns('user-1', 10); + + expect(result).toEqual(logs); + expect(workspaceLogsRepository.find).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + order: { checkedInAt: 'DESC' }, + take: 10, + }); + }); + }); + + describe('getMemberDashboard', () => { + it('returns combined dashboard data', async () => { + bookingsRepository.count.mockResolvedValue(2); + const payQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: '30000' }), + }; + paymentsRepository.createQueryBuilder.mockReturnValue(payQb); + invoicesRepository.count.mockResolvedValue(3); + workspaceLogsRepository.findOne.mockResolvedValue(null); + bookingsRepository.findAndCount.mockResolvedValue([ + [{ id: 'booking-1' }], + 1, + ]); + paymentsRepository.findAndCount.mockResolvedValue([[{ id: 'pay-1' }], 1]); + + const result = await provider.getMemberDashboard('user-1'); + + expect(result).toHaveProperty('stats'); + expect(result).toHaveProperty('recentBookings'); + expect(result).toHaveProperty('recentPayments'); + expect(result.recentBookings).toHaveLength(1); + expect(result.recentPayments).toHaveLength(1); + }); + }); +}); diff --git a/backend/src/email/email.service.spec.ts b/backend/src/email/email.service.spec.ts new file mode 100644 index 00000000..570f43cd --- /dev/null +++ b/backend/src/email/email.service.spec.ts @@ -0,0 +1,380 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EmailService } from './email.service'; +import { ConfigService } from '@nestjs/config'; + +// Mock nodemailer +const mockSendMail = jest.fn(); +jest.mock('nodemailer', () => ({ + createTransport: jest.fn(() => ({ + sendMail: mockSendMail, + })), +})); + +// Mock handlebars +jest.mock('handlebars', () => ({ + compile: jest.fn(() => jest.fn(() => 'rendered')), +})); + +// Mock fs +jest.mock('fs', () => ({ + readFileSync: jest.fn(() => '{{fullName}} template content'), +})); + +describe('EmailService', () => { + let service: EmailService; + let configService: any; + + beforeEach(async () => { + mockSendMail.mockReset(); + configService = { + get: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + service = module.get(EmailService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('sendVerificationEmail', () => { + it('sends verification OTP email', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendVerificationEmail( + 'alice@example.com', + '123456', + 'Alice Smith', + ); + + expect(result).toBe(true); + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'alice@example.com', + subject: 'Verify Your Email', + from: 'noreply@novalabs.com', + }), + ); + }); + }); + + describe('sendPasswordResetEmail', () => { + it('sends password reset OTP email', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendPasswordResetEmail( + 'alice@example.com', + '654321', + 'Alice Smith', + ); + + expect(result).toBe(true); + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'alice@example.com', + subject: 'Password Reset Code', + }), + ); + }); + }); + + describe('sendVerificationLinkEmail', () => { + it('sends verification link with frontend URL', async () => { + configService.get + .mockReturnValueOnce('noreply@novalabs.com') + .mockReturnValueOnce('https://novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendVerificationLinkEmail( + 'alice@example.com', + 'token-123', + 'Alice Smith', + ); + + expect(result).toBe(true); + }); + + it('uses empty string when FRONTEND_URL is not set', async () => { + configService.get + .mockReturnValueOnce('noreply@novalabs.com') + .mockReturnValueOnce(''); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendVerificationLinkEmail( + 'alice@example.com', + 'token-123', + 'Alice', + ); + + expect(result).toBe(true); + }); + }); + + describe('sendPasswordResetLinkEmail', () => { + it('sends password reset link email', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendPasswordResetLinkEmail( + 'alice@example.com', + 'Alice Smith', + 'https://novalabs.com/reset?token=abc', + ); + + expect(result).toBe(true); + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'alice@example.com', + subject: 'Reset Your Password', + }), + ); + }); + }); + + describe('sendPasswordResetSuccessEmail', () => { + it('sends password reset success email', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendPasswordResetSuccessEmail( + 'alice@example.com', + 'Alice Smith', + ); + + expect(result).toBe(true); + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'alice@example.com', + subject: 'Password Reset Successful', + }), + ); + }); + }); + + describe('sendTemplateEmail', () => { + it('sends email with custom template', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendTemplateEmail( + 'alice@example.com', + 'Custom Subject', + 'custom-template', + { fullName: 'Alice' }, + ); + + expect(result).toBe(true); + }); + }); + + describe('sendContactConfirmation', () => { + it('sends contact confirmation email', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendContactConfirmation( + 'alice@example.com', + 'Alice Smith', + 'Question', + ); + + expect(result).toBe(true); + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'We received your message', + }), + ); + }); + }); + + describe('sendContactNotification', () => { + it('sends admin notification using ADMIN_EMAIL', async () => { + configService.get + .mockReturnValueOnce('admin@novalabs.com') + .mockReturnValueOnce('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendContactNotification( + 'Alice Smith', + 'alice@example.com', + 'Question', + 'Message body', + ); + + expect(result).toBe(true); + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'admin@novalabs.com', + }), + ); + }); + + it('falls back to EMAIL_FROM when ADMIN_EMAIL is not set', async () => { + configService.get + .mockReturnValueOnce(undefined) + .mockReturnValueOnce('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendContactNotification( + 'Alice Smith', + 'alice@example.com', + 'Question', + 'Message body', + ); + + expect(result).toBe(true); + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'noreply@novalabs.com', + }), + ); + }); + }); + + describe('sendInvoiceReadyEmail', () => { + it('sends invoice email with PDF attachment', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendInvoiceReadyEmail( + 'alice@example.com', + 'Alice Smith', + { invoiceNumber: 'INV-001', amountNaira: '5000', paidAt: '2024-01-15' }, + Buffer.from('pdf content'), + ); + + expect(result).toBe(true); + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: expect.arrayContaining([ + expect.objectContaining({ + filename: 'INV-001.pdf', + contentType: 'application/pdf', + }), + ]), + }), + ); + }); + }); + + describe('sendBookingCreatedEmail', () => { + it('sends booking confirmation email', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendBookingCreatedEmail( + 'alice@example.com', + 'Alice Smith', + { + bookingId: 'booking-1', + workspaceName: 'Desk A', + planType: 'monthly', + startDate: '2024-02-01', + endDate: '2024-03-01', + seatCount: 1, + totalAmountNaira: '50000', + }, + ); + + expect(result).toBe(true); + }); + }); + + describe('sendPaymentSuccessEmail', () => { + it('sends payment success email', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendPaymentSuccessEmail( + 'alice@example.com', + 'Alice Smith', + { + bookingId: 'booking-1', + workspaceName: 'Desk A', + amountNaira: '50000', + paidAt: '2024-02-01', + invoiceNumber: 'INV-001', + }, + ); + + expect(result).toBe(true); + }); + }); + + describe('sendPaymentFailedEmail', () => { + it('sends payment failed email', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendPaymentFailedEmail( + 'alice@example.com', + 'Alice Smith', + { + paymentReference: 'ref-123', + amountNaira: '50000', + }, + ); + + expect(result).toBe(true); + }); + }); + + describe('sendBookingCancelledEmail', () => { + it('sends booking cancelled email', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendBookingCancelledEmail( + 'alice@example.com', + 'Alice Smith', + { + bookingId: 'booking-1', + workspaceName: 'Desk A', + startDate: '2024-02-01', + endDate: '2024-03-01', + cancelledBy: 'admin', + }, + ); + + expect(result).toBe(true); + }); + }); + + describe('sendRefreshTokenFamilyRevokedEmail', () => { + it('sends session revocation email', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockResolvedValue({ accepted: true }); + + const result = await service.sendRefreshTokenFamilyRevokedEmail( + 'alice@example.com', + 'Alice Smith', + ); + + expect(result).toBe(true); + }); + }); + + describe('sendMail error handling', () => { + it('returns false when sendMail throws', async () => { + configService.get.mockReturnValue('noreply@novalabs.com'); + mockSendMail.mockRejectedValue(new Error('SMTP connection failed')); + + const result = await service.sendVerificationEmail( + 'alice@example.com', + '123456', + 'Alice', + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/backend/src/invoices/invoices.controller.spec.ts b/backend/src/invoices/invoices.controller.spec.ts new file mode 100644 index 00000000..cecb514c --- /dev/null +++ b/backend/src/invoices/invoices.controller.spec.ts @@ -0,0 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { InvoicesController } from './invoices.controller'; +import { InvoicesService } from './invoices.service'; +import { UserRole } from '../users/enums/userRoles.enum'; + +describe('InvoicesController', () => { + let controller: InvoicesController; + let service: jest.Mocked>; + + beforeEach(async () => { + service = { + findAll: jest.fn(), + findById: jest.fn(), + downloadPdf: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [InvoicesController], + providers: [{ provide: InvoicesService, useValue: service }], + }).compile(); + + controller = module.get(InvoicesController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('findAll', () => { + it('returns paginated invoices with message', async () => { + const paginated = { + data: [{ id: 'inv-1' }], + total: 1, + page: 1, + limit: 20, + } as any; + service.findAll.mockResolvedValue(paginated); + + const result = await controller.findAll( + {} as any, + 'user-1', + UserRole.USER, + ); + expect(result).toEqual({ + message: 'Invoices retrieved successfully', + ...paginated, + }); + expect(service.findAll).toHaveBeenCalledWith({}, 'user-1', UserRole.USER); + }); + }); + + describe('findOne', () => { + it('returns invoice by id', async () => { + service.findById.mockResolvedValue({ id: 'inv-1' } as any); + + const result = await controller.findOne('inv-1', 'user-1', UserRole.USER); + expect(result).toEqual({ + message: 'Invoice retrieved successfully', + data: { id: 'inv-1' }, + }); + expect(service.findById).toHaveBeenCalledWith( + 'inv-1', + 'user-1', + UserRole.USER, + ); + }); + }); +}); diff --git a/backend/src/invoices/invoices.service.spec.ts b/backend/src/invoices/invoices.service.spec.ts new file mode 100644 index 00000000..e610d397 --- /dev/null +++ b/backend/src/invoices/invoices.service.spec.ts @@ -0,0 +1,103 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { InvoicesService } from './invoices.service'; +import { GenerateInvoiceProvider } from './providers/generate-invoice.provider'; +import { FindInvoicesProvider } from './providers/find-invoices.provider'; +import { PdfInvoiceProvider } from './providers/pdf-invoice.provider'; +import { UserRole } from '../users/enums/userRoles.enum'; + +describe('InvoicesService', () => { + let service: InvoicesService; + let generateProvider: jest.Mocked>; + let findProvider: jest.Mocked>; + let pdfProvider: jest.Mocked>; + + beforeEach(async () => { + generateProvider = { generateForPayment: jest.fn() }; + findProvider = { findAll: jest.fn(), findById: jest.fn() }; + pdfProvider = { generate: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + InvoicesService, + { provide: GenerateInvoiceProvider, useValue: generateProvider }, + { provide: FindInvoicesProvider, useValue: findProvider }, + { provide: PdfInvoiceProvider, useValue: pdfProvider }, + ], + }).compile(); + + service = module.get(InvoicesService); + }); + + describe('generateForPayment', () => { + it('delegates to GenerateInvoiceProvider', async () => { + generateProvider.generateForPayment.mockResolvedValue({ + id: 'inv-1', + } as any); + + const result = await service.generateForPayment('payment-1'); + expect(result).toEqual({ id: 'inv-1' }); + expect(generateProvider.generateForPayment).toHaveBeenCalledWith( + 'payment-1', + ); + }); + }); + + describe('findAll', () => { + it('delegates to FindInvoicesProvider', async () => { + findProvider.findAll.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 20, + }); + + const result = await service.findAll({}, 'user-1', UserRole.USER); + expect(result.total).toBe(0); + expect(findProvider.findAll).toHaveBeenCalledWith( + {}, + 'user-1', + UserRole.USER, + ); + }); + }); + + describe('findById', () => { + it('delegates to FindInvoicesProvider', async () => { + findProvider.findById.mockResolvedValue({ id: 'inv-1' } as any); + + const result = await service.findById('inv-1', 'user-1', UserRole.USER); + expect(result).toEqual({ id: 'inv-1' }); + expect(findProvider.findById).toHaveBeenCalledWith( + 'inv-1', + 'user-1', + UserRole.USER, + ); + }); + }); + + describe('downloadPdf', () => { + it('generates PDF from found invoice', async () => { + const invoice = { + id: 'inv-1', + invoiceNumber: 'INV-00001', + }; + findProvider.findById.mockResolvedValue(invoice as any); + pdfProvider.generate.mockResolvedValue(Buffer.from('pdf-content')); + + const result = await service.downloadPdf( + 'inv-1', + 'user-1', + UserRole.USER, + ); + + expect(result.pdf).toBeDefined(); + expect(result.invoiceNumber).toBe('INV-00001'); + expect(findProvider.findById).toHaveBeenCalledWith( + 'inv-1', + 'user-1', + UserRole.USER, + ); + expect(pdfProvider.generate).toHaveBeenCalledWith(invoice); + }); + }); +}); diff --git a/backend/src/invoices/providers/find-invoices.provider.spec.ts b/backend/src/invoices/providers/find-invoices.provider.spec.ts new file mode 100644 index 00000000..a992eefd --- /dev/null +++ b/backend/src/invoices/providers/find-invoices.provider.spec.ts @@ -0,0 +1,131 @@ +import { NotFoundException } from '@nestjs/common'; +import { FindInvoicesProvider } from './find-invoices.provider'; +import { UserRole } from '../../users/enums/userRoles.enum'; +import { InvoiceStatus } from '../enums/invoice-status.enum'; + +describe('FindInvoicesProvider', () => { + let provider: FindInvoicesProvider; + let invoicesRepository: any; + + beforeEach(() => { + invoicesRepository = { + createQueryBuilder: jest.fn(), + }; + provider = new FindInvoicesProvider(invoicesRepository); + }); + + function mockQueryBuilder(overrides: any = {}) { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest + .fn() + .mockResolvedValue([overrides.data ?? [], overrides.total ?? 0]), + getOne: jest.fn().mockResolvedValue(overrides.invoice ?? null), + }; + invoicesRepository.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + describe('findAll', () => { + it('filters by userId for regular users', async () => { + const qb = mockQueryBuilder({ total: 1, data: [{ id: 'inv-1' }] }); + + const result = await provider.findAll({}, 'user-1', UserRole.USER); + + expect(qb.where).toHaveBeenCalledWith('invoice.userId = :userId', { + userId: 'user-1', + }); + expect(result.data).toHaveLength(1); + }); + + it('does not filter by userId for admins', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({}, 'admin-1', UserRole.ADMIN); + + expect(qb.where).not.toHaveBeenCalled(); + }); + + it('filters by status', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { status: InvoiceStatus.PAID }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith('invoice.status = :status', { + status: InvoiceStatus.PAID, + }); + }); + + it('filters by bookingId', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { bookingId: 'booking-1' }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'invoice.bookingId = :bookingId', + { bookingId: 'booking-1' }, + ); + }); + + it('applies pagination', async () => { + const qb = mockQueryBuilder({ total: 25, data: [] }); + + await provider.findAll({ page: 2, limit: 10 }, 'admin-1', UserRole.ADMIN); + + expect(qb.skip).toHaveBeenCalledWith(10); + expect(qb.take).toHaveBeenCalledWith(10); + }); + + it('caps limit to 100', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({ limit: 500 }, 'admin-1', UserRole.ADMIN); + + expect(qb.take).toHaveBeenCalledWith(100); + }); + }); + + describe('findById', () => { + it('returns invoice when found and owned by user', async () => { + const qb = mockQueryBuilder({ + invoice: { id: 'inv-1', userId: 'user-1' }, + }); + + const result = await provider.findById('inv-1', 'user-1', UserRole.USER); + expect(result).toEqual({ id: 'inv-1', userId: 'user-1' }); + }); + + it('returns invoice for admin regardless of ownership', async () => { + const qb = mockQueryBuilder({ + invoice: { id: 'inv-1', userId: 'user-2' }, + }); + + const result = await provider.findById( + 'inv-1', + 'admin-1', + UserRole.ADMIN, + ); + expect(result).toEqual({ id: 'inv-1', userId: 'user-2' }); + }); + + it('throws NotFoundException when invoice does not exist', async () => { + mockQueryBuilder({ invoice: null }); + + await expect( + provider.findById('unknown', 'user-1', UserRole.USER), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/backend/src/invoices/providers/generate-invoice.provider.spec.ts b/backend/src/invoices/providers/generate-invoice.provider.spec.ts new file mode 100644 index 00000000..1985ce41 --- /dev/null +++ b/backend/src/invoices/providers/generate-invoice.provider.spec.ts @@ -0,0 +1,159 @@ +import { GenerateInvoiceProvider } from './generate-invoice.provider'; +import { InvoiceStatus } from '../enums/invoice-status.enum'; + +describe('GenerateInvoiceProvider', () => { + let provider: GenerateInvoiceProvider; + let invoicesRepository: any; + let paymentsRepository: any; + let bookingsRepository: any; + let usersRepository: any; + let workspacesRepository: any; + let dataSource: any; + let emailService: any; + let pdfInvoiceProvider: any; + + beforeEach(() => { + invoicesRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + paymentsRepository = { findOne: jest.fn() }; + bookingsRepository = { findOne: jest.fn() }; + usersRepository = { findOne: jest.fn() }; + workspacesRepository = { findOne: jest.fn() }; + dataSource = { query: jest.fn() }; + emailService = { + sendInvoiceReadyEmail: jest.fn().mockResolvedValue(undefined), + }; + pdfInvoiceProvider = { + generate: jest.fn().mockResolvedValue(Buffer.from('pdf')), + }; + + provider = new GenerateInvoiceProvider( + invoicesRepository, + paymentsRepository, + bookingsRepository, + usersRepository, + workspacesRepository, + dataSource, + emailService, + pdfInvoiceProvider, + ); + }); + + it('returns existing invoice if already generated (idempotent)', async () => { + const existingInvoice = { id: 'inv-1', invoiceNumber: 'INV-00001' }; + invoicesRepository.findOne.mockResolvedValue(existingInvoice); + + const result = await provider.generateForPayment('payment-1'); + + expect(result).toEqual(existingInvoice); + expect(paymentsRepository.findOne).not.toHaveBeenCalled(); + }); + + it('throws error when payment not found', async () => { + invoicesRepository.findOne.mockResolvedValue(null); + paymentsRepository.findOne.mockResolvedValue(null); + + await expect(provider.generateForPayment('unknown')).rejects.toThrow( + 'not found', + ); + }); + + it('generates invoice with proper line items and sequence', async () => { + // No existing invoice + invoicesRepository.findOne + .mockResolvedValueOnce(null) // first call: existing check + .mockResolvedValueOnce({ id: 'inv-1', invoiceNumber: 'INV-00001' }); // save result + + invoicesRepository.findOne.mockResolvedValue(null); + + paymentsRepository.findOne.mockResolvedValue({ + id: 'payment-1', + bookingId: 'booking-1', + userId: 'user-1', + amount: 500000, + currency: 'NGN', + paidAt: new Date('2024-01-15'), + }); + + bookingsRepository.findOne.mockResolvedValue({ + id: 'booking-1', + workspaceId: 'ws-1', + planType: 'monthly', + startDate: '2024-01-01', + endDate: '2024-01-31', + seatCount: 2, + }); + + usersRepository.findOne.mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + fullName: 'John Doe', + }); + + workspacesRepository.findOne.mockResolvedValue({ + id: 'ws-1', + name: 'Hot Desk A', + }); + + // Sequence query + dataSource.query.mockResolvedValue([{ nextval: '1' }]); + + invoicesRepository.create.mockReturnValue({ + id: 'inv-1', + invoiceNumber: 'INV-00001', + lineItems: [], + }); + invoicesRepository.save.mockResolvedValue({ + id: 'inv-1', + invoiceNumber: 'INV-00001', + lineItems: [], + }); + + const result = await provider.generateForPayment('payment-1'); + + expect(result.invoiceNumber).toBe('INV-00001'); + expect(invoicesRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + invoiceNumber: 'INV-00001', + amountKobo: 500000, + status: InvoiceStatus.PAID, + }), + ); + expect(dataSource.query).toHaveBeenCalledWith( + `SELECT nextval('invoice_number_seq')`, + ); + }); + + it('uses fallback description when workspace is null', async () => { + invoicesRepository.findOne.mockResolvedValueOnce(null); + + paymentsRepository.findOne.mockResolvedValue({ + id: 'payment-1', + bookingId: 'booking-1', + userId: 'user-1', + amount: 50000, + currency: 'NGN', + paidAt: new Date(), + }); + + bookingsRepository.findOne.mockResolvedValue({ + id: 'booking-1', + workspaceId: null, + }); + + usersRepository.findOne.mockResolvedValue(null); // no user -> no email + workspacesRepository.findOne.mockResolvedValue(null); + dataSource.query.mockResolvedValue([{ nextval: '2' }]); + + invoicesRepository.create.mockReturnValue({ id: 'inv-2' }); + invoicesRepository.save.mockResolvedValue({ id: 'inv-2' }); + + const result = await provider.generateForPayment('payment-1'); + + expect(result).toBeDefined(); + expect(invoicesRepository.create).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/invoices/providers/invoice-sequence.provider.spec.ts b/backend/src/invoices/providers/invoice-sequence.provider.spec.ts new file mode 100644 index 00000000..f4b2f7a5 --- /dev/null +++ b/backend/src/invoices/providers/invoice-sequence.provider.spec.ts @@ -0,0 +1,30 @@ +import { InvoiceSequenceProvider } from './invoice-sequence.provider'; + +describe('InvoiceSequenceProvider', () => { + let provider: InvoiceSequenceProvider; + let dataSource: any; + + beforeEach(() => { + dataSource = { query: jest.fn() }; + provider = new InvoiceSequenceProvider(dataSource); + }); + + describe('onApplicationBootstrap', () => { + it('creates the invoice_number_seq on startup', async () => { + dataSource.query.mockResolvedValue(undefined); + + await provider.onApplicationBootstrap(); + + expect(dataSource.query).toHaveBeenCalledWith( + `CREATE SEQUENCE IF NOT EXISTS invoice_number_seq START 1`, + ); + }); + + it('handles errors gracefully without throwing', async () => { + dataSource.query.mockRejectedValue(new Error('Connection failed')); + + // Should not throw + await expect(provider.onApplicationBootstrap()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/backend/src/invoices/providers/pdf-invoice.provider.spec.ts b/backend/src/invoices/providers/pdf-invoice.provider.spec.ts new file mode 100644 index 00000000..93841557 --- /dev/null +++ b/backend/src/invoices/providers/pdf-invoice.provider.spec.ts @@ -0,0 +1,110 @@ +import { PdfInvoiceProvider } from './pdf-invoice.provider'; +import { InvoiceStatus } from '../enums/invoice-status.enum'; + +// Mock PDFDocument +const mockPdfMethods = { + fontSize: jest.fn().mockReturnThis(), + font: jest.fn().mockReturnThis(), + text: jest.fn().mockReturnThis(), + moveDown: jest.fn().mockReturnThis(), + moveTo: jest.fn().mockReturnThis(), + lineTo: jest.fn().mockReturnThis(), + stroke: jest.fn().mockReturnThis(), + fillColor: jest.fn().mockReturnThis(), + currentLineHeight: jest.fn().mockReturnValue(14), + on: jest.fn(), + end: jest.fn(), +}; + +jest.mock('pdfkit', () => { + const MockPDFDocument = jest.fn().mockImplementation(() => mockPdfMethods); + return MockPDFDocument; +}); + +describe('PdfInvoiceProvider', () => { + let provider: PdfInvoiceProvider; + + beforeEach(() => { + provider = new PdfInvoiceProvider(); + jest.clearAllMocks(); + + // Set up event handlers + mockPdfMethods.on.mockImplementation( + (event: string, handler: (...args: any[]) => void) => { + if (event === 'data') { + // Don't trigger data — just resolve + } + if (event === 'end') { + // Don't trigger end — just set up + } + return mockPdfMethods; + }, + ); + }); + + const mockInvoice = { + id: 'inv-1', + invoiceNumber: 'INV-00001', + amountKobo: 500000, + status: InvoiceStatus.PAID, + createdAt: new Date('2024-01-15'), + lineItems: null, + } as any; + + it('generates a PDF buffer', async () => { + // Simulate successful PDF generation + mockPdfMethods.on.mockImplementation( + (event: string, handler: (...args: any[]) => void) => { + if (event === 'end') { + setTimeout(() => handler(), 10); + } + return mockPdfMethods; + }, + ); + + const result = await provider.generate(mockInvoice); + expect(Buffer.isBuffer(result)).toBe(true); + expect(mockPdfMethods.fontSize).toHaveBeenCalled(); + expect(mockPdfMethods.text).toHaveBeenCalled(); + expect(mockPdfMethods.end).toHaveBeenCalled(); + }); + + it('renders invoice with line items when present', async () => { + mockPdfMethods.on.mockImplementation( + (event: string, handler: (...args: any[]) => void) => { + if (event === 'end') { + setTimeout(() => handler(), 10); + } + return mockPdfMethods; + }, + ); + + const invoiceWithItems = { + ...mockInvoice, + lineItems: [ + { + description: 'Hot Desk A — monthly booking', + startDate: '2024-01-01', + endDate: '2024-01-31', + amountNaira: 5000, + }, + ], + }; + + const result = await provider.generate(invoiceWithItems); + expect(Buffer.isBuffer(result)).toBe(true); + }); + + it('handles PDF generation errors', async () => { + mockPdfMethods.on.mockImplementation( + (event: string, handler: (...args: any[]) => void) => { + if (event === 'error') { + setTimeout(() => handler(new Error('PDF error')), 10); + } + return mockPdfMethods; + }, + ); + + await expect(provider.generate(mockInvoice)).rejects.toThrow('PDF error'); + }); +}); diff --git a/backend/src/newsletter/newsletter.controller.spec.ts b/backend/src/newsletter/newsletter.controller.spec.ts new file mode 100644 index 00000000..853b1b17 --- /dev/null +++ b/backend/src/newsletter/newsletter.controller.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NewsletterController } from './newsletter.controller'; +import { NewsletterService } from './newsletter.service'; + +describe('NewsletterController', () => { + let controller: NewsletterController; + let newsletterService: any; + + beforeEach(async () => { + newsletterService = { + subscribe: jest.fn(), + confirm: jest.fn(), + unsubscribe: jest.fn(), + listSubscribers: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [NewsletterController], + providers: [{ provide: NewsletterService, useValue: newsletterService }], + }).compile(); + + controller = module.get(NewsletterController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('subscribe', () => { + it('subscribes email and returns success response', async () => { + const dto = { email: 'alice@example.com' }; + const serviceResult = { + id: 'sub-1', + email: 'alice@example.com', + subscribedAt: new Date(), + isActive: false, + }; + newsletterService.subscribe.mockResolvedValue(serviceResult); + + const req = { ip: '192.168.1.1', headers: {} }; + const result = await controller.subscribe(dto, req); + + expect(result).toEqual({ + success: true, + message: 'Subscribed successfully.', + data: serviceResult, + }); + expect(newsletterService.subscribe).toHaveBeenCalledWith( + 'alice@example.com', + '192.168.1.1', + ); + }); + + it('extracts IP from x-forwarded-for header', async () => { + newsletterService.subscribe.mockResolvedValue({}); + + const req = { + ip: '10.0.0.1', + headers: { 'x-forwarded-for': '203.0.113.5' }, + }; + await controller.subscribe({ email: 'test@test.com' }, req); + + expect(newsletterService.subscribe).toHaveBeenCalledWith( + 'test@test.com', + '203.0.113.5', + ); + }); + + it('passes null when IP is missing', async () => { + newsletterService.subscribe.mockResolvedValue({}); + + const req = { headers: {} }; + await controller.subscribe({ email: 'test@test.com' }, req); + + expect(newsletterService.subscribe).toHaveBeenCalledWith( + 'test@test.com', + null, + ); + }); + }); + + describe('confirm', () => { + it('confirms subscription', async () => { + newsletterService.confirm.mockResolvedValue({ + success: true, + message: 'Subscription confirmed successfully.', + }); + + const result = await controller.confirm({ token: 'confirm-token' }); + + expect(newsletterService.confirm).toHaveBeenCalledWith('confirm-token'); + }); + }); + + describe('unsubscribe', () => { + it('unsubscribes email', async () => { + newsletterService.unsubscribe.mockResolvedValue({ + success: true, + message: 'Unsubscribed successfully.', + }); + + const result = await controller.unsubscribe({ token: 'unsub-token' }); + + expect(newsletterService.unsubscribe).toHaveBeenCalledWith('unsub-token'); + }); + }); + + describe('listSubscribers', () => { + it('returns paginated subscribers list', async () => { + const subscribers = { + items: [], + meta: { currentPage: 1, totalItems: 0 }, + }; + newsletterService.listSubscribers.mockResolvedValue(subscribers); + + const result = await controller.listSubscribers({ page: 1, perPage: 10 }); + + expect(newsletterService.listSubscribers).toHaveBeenCalledWith({ + page: 1, + perPage: 10, + }); + }); + }); +}); diff --git a/backend/src/newsletter/newsletter.service.spec.ts b/backend/src/newsletter/newsletter.service.spec.ts new file mode 100644 index 00000000..400f6556 --- /dev/null +++ b/backend/src/newsletter/newsletter.service.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NewsletterService } from './newsletter.service'; +import { NewsletterProvider } from './providers/subscription.provider'; +import { ListNewsletterSubscribersProvider } from './providers/list-subscribers.provider'; + +describe('NewsletterService', () => { + let service: NewsletterService; + let subscriptionProvider: any; + let listSubscribersProvider: any; + + beforeEach(async () => { + subscriptionProvider = { + subscribe: jest.fn(), + unsubscribe: jest.fn(), + confirm: jest.fn(), + }; + listSubscribersProvider = { + execute: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NewsletterService, + { provide: NewsletterProvider, useValue: subscriptionProvider }, + { + provide: ListNewsletterSubscribersProvider, + useValue: listSubscribersProvider, + }, + ], + }).compile(); + + service = module.get(NewsletterService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('subscribe', () => { + it('delegates to NewsletterProvider', async () => { + const result = { + id: 'sub-1', + email: 'alice@example.com', + subscribedAt: new Date(), + isActive: false, + }; + subscriptionProvider.subscribe.mockResolvedValue(result); + + const response = await service.subscribe( + 'alice@example.com', + '192.168.1.1', + ); + + expect(response).toEqual(result); + expect(subscriptionProvider.subscribe).toHaveBeenCalledWith({ + email: 'alice@example.com', + ipAddress: '192.168.1.1', + }); + }); + + it('passes null IP when not provided', async () => { + subscriptionProvider.subscribe.mockResolvedValue({}); + + await service.subscribe('alice@example.com'); + + expect(subscriptionProvider.subscribe).toHaveBeenCalledWith({ + email: 'alice@example.com', + ipAddress: undefined, + }); + }); + }); + + describe('confirm', () => { + it('delegates to NewsletterProvider', async () => { + subscriptionProvider.confirm.mockResolvedValue({ + success: true, + message: 'Confirmed.', + }); + + const result = await service.confirm('token-123'); + + expect(result).toEqual({ success: true, message: 'Confirmed.' }); + expect(subscriptionProvider.confirm).toHaveBeenCalledWith({ + token: 'token-123', + }); + }); + }); + + describe('unsubscribe', () => { + it('delegates to NewsletterProvider', async () => { + subscriptionProvider.unsubscribe.mockResolvedValue({ + success: true, + message: 'Unsubscribed.', + }); + + const result = await service.unsubscribe('unsub-token'); + + expect(result).toEqual({ success: true, message: 'Unsubscribed.' }); + expect(subscriptionProvider.unsubscribe).toHaveBeenCalledWith({ + token: 'unsub-token', + }); + }); + }); + + describe('listSubscribers', () => { + it('delegates to ListNewsletterSubscribersProvider', async () => { + const paginated = { items: [], meta: {} }; + listSubscribersProvider.execute.mockResolvedValue(paginated); + + const result = await service.listSubscribers({ page: 1, perPage: 10 }); + + expect(result).toEqual(paginated); + expect(listSubscribersProvider.execute).toHaveBeenCalledWith({ + page: 1, + perPage: 10, + }); + }); + }); +}); diff --git a/backend/src/newsletter/providers/list-subscribers.provider.spec.ts b/backend/src/newsletter/providers/list-subscribers.provider.spec.ts new file mode 100644 index 00000000..a401234a --- /dev/null +++ b/backend/src/newsletter/providers/list-subscribers.provider.spec.ts @@ -0,0 +1,141 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ListNewsletterSubscribersProvider } from './list-subscribers.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NewsletterSubscriber } from '../entities/newsletter.entity'; + +describe('ListNewsletterSubscribersProvider', () => { + let provider: ListNewsletterSubscribersProvider; + let repo: any; + + beforeEach(async () => { + repo = { + createQueryBuilder: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ListNewsletterSubscribersProvider, + { + provide: getRepositoryToken(NewsletterSubscriber), + useValue: repo, + }, + ], + }).compile(); + + provider = module.get( + ListNewsletterSubscribersProvider, + ); + }); + + function mockQb(overrides: { rows?: any[]; total?: number } = {}) { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest + .fn() + .mockResolvedValue([overrides.rows ?? [], overrides.total ?? 0]), + }; + repo.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('execute', () => { + it('returns paginated subscribers with defaults', async () => { + const qb = mockQb({ + rows: [ + { + id: 'sub-1', + email: 'alice@example.com', + subscribedAt: new Date(), + isActive: true, + isVerified: true, + verifiedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + total: 1, + }); + + const result = await provider.execute({}); + + expect(result.items).toHaveLength(1); + expect(result.meta.totalItems).toBe(1); + expect(result.meta.currentPage).toBe(1); + expect(result.meta.itemsPerPage).toBe(10); + expect(result.meta.totalPages).toBe(1); + expect(qb.where).toHaveBeenCalledWith('s.deletedAt IS NULL'); + }); + + it('applies search filter', async () => { + const qb = mockQb({ rows: [], total: 0 }); + + await provider.execute({ searchTerm: 'alice' }); + + expect(qb.andWhere).toHaveBeenCalledWith('LOWER(s.email) LIKE :email', { + email: '%alice%', + }); + }); + + it('filters by active status', async () => { + const qb = mockQb({ rows: [], total: 0 }); + + await provider.execute({ category: 'active' }); + + expect(qb.andWhere).toHaveBeenCalledWith('s.isActive = true'); + }); + + it('filters by inactive status', async () => { + const qb = mockQb({ rows: [], total: 0 }); + + await provider.execute({ category: 'inactive' }); + + expect(qb.andWhere).toHaveBeenCalledWith('s.isActive = false'); + }); + + it('filters by verified status', async () => { + const qb = mockQb({ rows: [], total: 0 }); + + await provider.execute({ category: 'verified' }); + + expect(qb.andWhere).toHaveBeenCalledWith('s.isVerified = true'); + }); + + it('filters by pending (unverified) status', async () => { + const qb = mockQb({ rows: [], total: 0 }); + + await provider.execute({ category: 'pending' }); + + expect(qb.andWhere).toHaveBeenCalledWith('s.isVerified = false'); + }); + + it('applies custom pagination', async () => { + const qb = mockQb({ rows: [], total: 25 }); + + const result = await provider.execute({ page: 2, perPage: 10 }); + + expect(qb.skip).toHaveBeenCalledWith(10); + expect(qb.take).toHaveBeenCalledWith(10); + expect(result.meta.currentPage).toBe(2); + expect(result.meta.totalPages).toBe(3); + expect(result.meta.hasNextPage).toBe(true); + expect(result.meta.hasPreviousPage).toBe(true); + }); + + it('computes hasPreviousPage as false on first page', async () => { + mockQb({ rows: [], total: 25 }); + + const result = await provider.execute({ page: 1, perPage: 10 }); + + expect(result.meta.hasPreviousPage).toBe(false); + expect(result.meta.hasNextPage).toBe(true); + }); + }); +}); diff --git a/backend/src/newsletter/providers/subscription.provider.spec.ts b/backend/src/newsletter/providers/subscription.provider.spec.ts new file mode 100644 index 00000000..5e0d695c --- /dev/null +++ b/backend/src/newsletter/providers/subscription.provider.spec.ts @@ -0,0 +1,274 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NewsletterProvider } from './subscription.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NewsletterSubscriber } from '../entities/newsletter.entity'; +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { EmailService } from '../../email/email.service'; + +describe('NewsletterProvider', () => { + let provider: NewsletterProvider; + let repo: any; + let emailService: any; + + beforeEach(async () => { + repo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + softRemove: jest.fn(), + }; + emailService = { + sendTemplateEmail: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NewsletterProvider, + { provide: getRepositoryToken(NewsletterSubscriber), useValue: repo }, + { provide: EmailService, useValue: emailService }, + ], + }).compile(); + + provider = module.get(NewsletterProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('subscribe', () => { + it('creates a new subscriber and sends confirmation email', async () => { + repo.findOne.mockResolvedValue(null); + repo.create.mockReturnValue({ email: 'alice@example.com' }); + repo.save.mockResolvedValue({ + id: 'sub-1', + email: 'alice@example.com', + subscribedAt: new Date(), + isActive: false, + }); + emailService.sendTemplateEmail.mockResolvedValue(true); + + const result = await provider.subscribe({ + email: 'alice@example.com', + ipAddress: '192.168.1.1', + }); + + expect(result).toHaveProperty('id'); + expect(result.email).toBe('alice@example.com'); + expect(result.isActive).toBe(false); + expect(repo.save).toHaveBeenCalledTimes(1); + expect(emailService.sendTemplateEmail).toHaveBeenCalledWith( + 'alice@example.com', + 'Confirm your newsletter subscription', + 'newsletter-confirmation', + expect.any(Object), + ); + }); + + it('throws ConflictException for already subscribed verified email', async () => { + repo.findOne.mockResolvedValue({ + email: 'existing@example.com', + isActive: true, + isVerified: true, + deletedAt: null, + }); + + await expect( + provider.subscribe({ email: 'existing@example.com' }), + ).rejects.toThrow(ConflictException); + }); + + it('throws ConflictException on unique constraint violation', async () => { + repo.findOne.mockResolvedValue(null); + repo.create.mockReturnValue({ email: 'test@example.com' }); + repo.save.mockRejectedValue({ code: '23505' }); + + await expect( + provider.subscribe({ email: 'test@example.com' }), + ).rejects.toThrow(ConflictException); + }); + + it('throws BadRequestException for disposable email', async () => { + await expect( + provider.subscribe({ email: 'test@mailinator.com' }), + ).rejects.toThrow(BadRequestException); + }); + + it('re-activates soft-deleted subscriber', async () => { + const deletedSub = { + email: 'alice@example.com', + isActive: false, + isVerified: false, + deletedAt: new Date(), + }; + repo.findOne.mockResolvedValue(deletedSub); + repo.save.mockResolvedValue({ + id: 'sub-1', + email: 'alice@example.com', + isActive: false, + }); + emailService.sendTemplateEmail.mockResolvedValue(true); + + const result = await provider.subscribe({ email: 'alice@example.com' }); + + expect(result).toBeDefined(); + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: null }), + ); + }); + + it('trims and lowercases email', async () => { + repo.findOne.mockResolvedValue(null); + repo.create.mockReturnValue({ email: 'alice@example.com' }); + repo.save.mockResolvedValue({ + id: 's-1', + email: 'alice@example.com', + isActive: false, + }); + emailService.sendTemplateEmail.mockResolvedValue(true); + + const result = await provider.subscribe({ + email: ' Alice@Example.com ', + }); + + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ email: 'alice@example.com' }), + ); + }); + }); + + describe('unsubscribe', () => { + it('unsubscribes an active subscriber', async () => { + repo.findOne.mockResolvedValue({ + id: 'sub-1', + email: 'alice@example.com', + isActive: true, + unsubscribeToken: 'token-123', + deletedAt: null, + }); + repo.softRemove.mockResolvedValue({}); + emailService.sendTemplateEmail.mockResolvedValue(true); + + const result = await provider.unsubscribe({ token: 'token-123' }); + + expect(result).toEqual({ + success: true, + message: 'Unsubscribed successfully.', + }); + expect(repo.softRemove).toHaveBeenCalled(); + }); + + it('returns idempotent success for already unsubscribed', async () => { + repo.findOne.mockResolvedValue({ + id: 'sub-1', + email: 'alice@example.com', + isActive: false, + deletedAt: new Date(), + }); + + const result = await provider.unsubscribe({ token: 'token-123' }); + + expect(result).toEqual({ + success: true, + message: 'You are already unsubscribed.', + }); + expect(repo.softRemove).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException for invalid token', async () => { + repo.findOne.mockResolvedValue(null); + + await expect( + provider.unsubscribe({ token: 'invalid-token' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('confirm', () => { + it('confirms a pending subscription', async () => { + process.env.FRONTEND_URL = 'https://novalabs.com'; + repo.findOne.mockResolvedValue({ + id: 'sub-1', + email: 'alice@example.com', + isVerified: false, + isActive: false, + verificationToken: 'confirm-token', + verificationTokenExpiresAt: new Date(Date.now() + 3600000), + unsubscribeToken: 'unsub-token', + deletedAt: null, + }); + repo.save.mockResolvedValue({}); + emailService.sendTemplateEmail.mockResolvedValue(true); + + const result = await provider.confirm({ token: 'confirm-token' }); + + expect(result).toEqual({ + success: true, + message: 'Subscription confirmed successfully.', + }); + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ + isVerified: true, + isActive: true, + verifiedAt: expect.any(Date), + verificationToken: null, + verificationTokenExpiresAt: null, + }), + ); + }); + + it('returns idempotent success for already confirmed', async () => { + repo.findOne.mockResolvedValue({ + isVerified: true, + isActive: true, + deletedAt: null, + }); + + const result = await provider.confirm({ token: 'already-confirmed' }); + + expect(result).toEqual({ + success: true, + message: 'Subscription already confirmed.', + }); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException for invalid token', async () => { + repo.findOne.mockResolvedValue(null); + + await expect( + provider.confirm({ token: 'invalid-token' }), + ).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException for expired token', async () => { + repo.findOne.mockResolvedValue({ + isVerified: false, + isActive: false, + verificationTokenExpiresAt: new Date(Date.now() - 3600000), + deletedAt: null, + }); + + await expect( + provider.confirm({ token: 'expired-token' }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException for soft-deleted subscriber', async () => { + repo.findOne.mockResolvedValue({ + isVerified: false, + isActive: false, + verificationTokenExpiresAt: new Date(Date.now() + 3600000), + deletedAt: new Date(), + }); + + await expect( + provider.confirm({ token: 'deleted-token' }), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/backend/src/notifications/gateway/notifications.gateway.spec.ts b/backend/src/notifications/gateway/notifications.gateway.spec.ts new file mode 100644 index 00000000..a69b720d --- /dev/null +++ b/backend/src/notifications/gateway/notifications.gateway.spec.ts @@ -0,0 +1,105 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationsGateway } from './notifications.gateway'; +import { JwtService } from '@nestjs/jwt'; + +describe('NotificationsGateway', () => { + let gateway: NotificationsGateway; + let jwtService: jest.Mocked>; + + beforeEach(async () => { + jwtService = { + verify: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationsGateway, + { provide: JwtService, useValue: jwtService }, + ], + }).compile(); + + gateway = module.get(NotificationsGateway); + // Mock the server + (gateway as any).server = { + to: jest.fn().mockReturnThis(), + emit: jest.fn(), + }; + }); + + describe('sendToUser', () => { + it('emits event to the users socket room', () => { + const data = { id: 'notif-1', title: 'Test' }; + + gateway.sendToUser('user-1', 'notification', data); + + expect((gateway as any).server.to).toHaveBeenCalledWith('user:user-1'); + expect((gateway as any).server.emit).toHaveBeenCalledWith( + 'notification', + data, + ); + }); + }); + + describe('handleConnection', () => { + let mockClient: any; + + beforeEach(() => { + mockClient = { + id: 'socket-1', + handshake: { auth: {}, headers: {} }, + join: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + }; + }); + + it('disconnects client without a token', async () => { + await gateway.handleConnection(mockClient); + + expect(mockClient.disconnect).toHaveBeenCalled(); + expect(mockClient.join).not.toHaveBeenCalled(); + }); + + it('disconnects client with an invalid token', async () => { + mockClient.handshake.auth = { token: 'bad-token' }; + jwtService.verify.mockImplementation(() => { + throw new Error('Invalid token'); + }); + + await gateway.handleConnection(mockClient); + + expect(mockClient.disconnect).toHaveBeenCalled(); + }); + + it('joins user room with valid token from auth', async () => { + mockClient.handshake.auth = { token: 'valid-token' }; + jwtService.verify.mockReturnValue({ sub: 'user-1' }); + + await gateway.handleConnection(mockClient); + + expect(mockClient.join).toHaveBeenCalledWith('user:user-1'); + expect(mockClient.disconnect).not.toHaveBeenCalled(); + }); + + it('extracts token from authorization header', async () => { + mockClient.handshake.headers = { + authorization: 'Bearer header-token', + }; + jwtService.verify.mockReturnValue({ sub: 'user-2' }); + + await gateway.handleConnection(mockClient); + + expect(jwtService.verify).toHaveBeenCalledWith('header-token', { + secret: process.env.JWT_SECRET, + }); + expect(mockClient.join).toHaveBeenCalledWith('user:user-2'); + }); + }); + + describe('handleDisconnect', () => { + it('logs disconnection', () => { + const mockClient = { id: 'socket-1' }; + // Should not throw + expect(() => gateway.handleDisconnect(mockClient as any)).not.toThrow(); + }); + }); +}); diff --git a/backend/src/notifications/notifications.controller.spec.ts b/backend/src/notifications/notifications.controller.spec.ts new file mode 100644 index 00000000..3b929bc1 --- /dev/null +++ b/backend/src/notifications/notifications.controller.spec.ts @@ -0,0 +1,67 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; + +describe('NotificationsController', () => { + let controller: NotificationsController; + let service: jest.Mocked>; + + beforeEach(async () => { + service = { + findAll: jest.fn(), + markRead: jest.fn(), + markAllRead: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [NotificationsController], + providers: [{ provide: NotificationsService, useValue: service }], + }).compile(); + + controller = module.get(NotificationsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('findAll', () => { + it('returns paginated notifications with message', async () => { + const paginated = { + data: [{ id: 'notif-1', title: 'Test' }], + total: 1, + unreadCount: 0, + page: 1, + limit: 20, + } as any; + service.findAll.mockResolvedValue(paginated); + + const result = await controller.findAll({} as any, 'user-1'); + expect(result).toEqual({ + message: 'Notifications retrieved successfully', + ...paginated, + }); + expect(service.findAll).toHaveBeenCalledWith('user-1', {}); + }); + }); + + describe('markRead', () => { + it('marks notification as read', async () => { + service.markRead.mockResolvedValue(undefined); + + const result = await controller.markRead('notif-1', 'user-1'); + expect(result).toEqual({ message: 'Notification marked as read' }); + expect(service.markRead).toHaveBeenCalledWith('notif-1', 'user-1'); + }); + }); + + describe('markAllRead', () => { + it('marks all notifications as read', async () => { + service.markAllRead.mockResolvedValue(undefined); + + const result = await controller.markAllRead('user-1'); + expect(result).toEqual({ message: 'All notifications marked as read' }); + expect(service.markAllRead).toHaveBeenCalledWith('user-1'); + }); + }); +}); diff --git a/backend/src/notifications/notifications.service.spec.ts b/backend/src/notifications/notifications.service.spec.ts new file mode 100644 index 00000000..7c03e0f7 --- /dev/null +++ b/backend/src/notifications/notifications.service.spec.ts @@ -0,0 +1,83 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationsService } from './notifications.service'; +import { CreateNotificationProvider } from './providers/create-notification.provider'; +import { FindNotificationsProvider } from './providers/find-notifications.provider'; +import { NotificationType } from './enums/notification-type.enum'; + +describe('NotificationsService', () => { + let service: NotificationsService; + let createProvider: jest.Mocked>; + let findProvider: jest.Mocked>; + + beforeEach(async () => { + createProvider = { create: jest.fn() }; + findProvider = { + findAll: jest.fn(), + markRead: jest.fn(), + markAllRead: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationsService, + { provide: CreateNotificationProvider, useValue: createProvider }, + { provide: FindNotificationsProvider, useValue: findProvider }, + ], + }).compile(); + + service = module.get(NotificationsService); + }); + + describe('create', () => { + it('delegates to CreateNotificationProvider', async () => { + const input = { + userId: 'user-1', + type: NotificationType.GENERAL, + title: 'Test', + message: 'Hello', + }; + createProvider.create.mockResolvedValue({ + id: 'notif-1', + ...input, + } as any); + + const result = await service.create(input); + expect(result).toEqual({ id: 'notif-1', ...input }); + expect(createProvider.create).toHaveBeenCalledWith(input); + }); + }); + + describe('findAll', () => { + it('delegates to FindNotificationsProvider', async () => { + findProvider.findAll.mockResolvedValue({ + data: [], + total: 0, + unreadCount: 0, + page: 1, + limit: 20, + }); + + const result = await service.findAll('user-1', {}); + expect(result.total).toBe(0); + expect(findProvider.findAll).toHaveBeenCalledWith('user-1', {}); + }); + }); + + describe('markRead', () => { + it('delegates to FindNotificationsProvider', async () => { + findProvider.markRead.mockResolvedValue(undefined); + + await service.markRead('notif-1', 'user-1'); + expect(findProvider.markRead).toHaveBeenCalledWith('notif-1', 'user-1'); + }); + }); + + describe('markAllRead', () => { + it('delegates to FindNotificationsProvider', async () => { + findProvider.markAllRead.mockResolvedValue(undefined); + + await service.markAllRead('user-1'); + expect(findProvider.markAllRead).toHaveBeenCalledWith('user-1'); + }); + }); +}); diff --git a/backend/src/notifications/providers/create-notification.provider.spec.ts b/backend/src/notifications/providers/create-notification.provider.spec.ts new file mode 100644 index 00000000..3c8c0e0d --- /dev/null +++ b/backend/src/notifications/providers/create-notification.provider.spec.ts @@ -0,0 +1,72 @@ +import { CreateNotificationProvider } from './create-notification.provider'; +import { NotificationType } from '../enums/notification-type.enum'; + +describe('CreateNotificationProvider', () => { + let provider: CreateNotificationProvider; + let notificationsRepository: any; + let gateway: any; + + beforeEach(() => { + notificationsRepository = { + create: jest.fn(), + save: jest.fn(), + }; + gateway = { sendToUser: jest.fn() }; + + provider = new CreateNotificationProvider(notificationsRepository, gateway); + }); + + const input = { + userId: 'user-1', + type: NotificationType.BOOKING_CONFIRMED, + title: 'Booking Confirmed', + message: 'Your booking has been confirmed.', + }; + + it('creates a notification and pushes real-time event', async () => { + const savedNotification = { + id: 'notif-1', + ...input, + createdAt: new Date(), + }; + notificationsRepository.create.mockReturnValue(savedNotification); + notificationsRepository.save.mockResolvedValue(savedNotification); + + const result = await provider.create(input); + + expect(result).toEqual(savedNotification); + expect(notificationsRepository.create).toHaveBeenCalledWith(input); + expect(notificationsRepository.save).toHaveBeenCalledWith( + savedNotification, + ); + expect(gateway.sendToUser).toHaveBeenCalledWith('user-1', 'notification', { + id: 'notif-1', + type: savedNotification.type, + title: savedNotification.title, + message: savedNotification.message, + createdAt: savedNotification.createdAt, + }); + }); + + it('includes metadata when provided', async () => { + const inputWithMeta = { + ...input, + metadata: { bookingId: 'booking-1', amount: 50000 }, + }; + const savedNotification = { + id: 'notif-2', + ...inputWithMeta, + createdAt: new Date(), + }; + notificationsRepository.create.mockReturnValue(savedNotification); + notificationsRepository.save.mockResolvedValue(savedNotification); + + const result = await provider.create(inputWithMeta); + + expect(result.metadata).toEqual({ + bookingId: 'booking-1', + amount: 50000, + }); + expect(notificationsRepository.create).toHaveBeenCalledWith(inputWithMeta); + }); +}); diff --git a/backend/src/notifications/providers/find-notifications.provider.spec.ts b/backend/src/notifications/providers/find-notifications.provider.spec.ts new file mode 100644 index 00000000..982aee02 --- /dev/null +++ b/backend/src/notifications/providers/find-notifications.provider.spec.ts @@ -0,0 +1,120 @@ +import { FindNotificationsProvider } from './find-notifications.provider'; + +describe('FindNotificationsProvider', () => { + let provider: FindNotificationsProvider; + let notificationsRepository: any; + + beforeEach(() => { + notificationsRepository = { + createQueryBuilder: jest.fn(), + count: jest.fn(), + update: jest.fn(), + }; + provider = new FindNotificationsProvider(notificationsRepository); + }); + + function mockQueryBuilder(overrides: any = {}) { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest + .fn() + .mockResolvedValue([overrides.data ?? [], overrides.total ?? 0]), + }; + notificationsRepository.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + describe('findAll', () => { + it('returns paginated notifications for a user', async () => { + mockQueryBuilder({ + total: 2, + data: [ + { id: 'notif-1', title: 'First' }, + { id: 'notif-2', title: 'Second' }, + ], + }); + notificationsRepository.count.mockResolvedValue(1); + + const result = await provider.findAll('user-1', {}); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.unreadCount).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + + it('filters by isRead status when provided', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + notificationsRepository.count.mockResolvedValue(0); + + await provider.findAll('user-1', { isRead: true }); + + expect(qb.andWhere).toHaveBeenCalledWith('n.isRead = :isRead', { + isRead: true, + }); + }); + + it('applies pagination correctly', async () => { + const qb = mockQueryBuilder({ total: 25, data: [] }); + notificationsRepository.count.mockResolvedValue(5); + + const result = await provider.findAll('user-1', { page: 2, limit: 10 }); + + expect(qb.skip).toHaveBeenCalledWith(10); + expect(qb.take).toHaveBeenCalledWith(10); + expect(result.page).toBe(2); + expect(result.limit).toBe(10); + }); + + it('caps limit to 100', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + notificationsRepository.count.mockResolvedValue(0); + + await provider.findAll('user-1', { limit: 500 }); + + expect(qb.take).toHaveBeenCalledWith(100); + }); + + it('returns empty result when no notifications', async () => { + mockQueryBuilder({ total: 0, data: [] }); + notificationsRepository.count.mockResolvedValue(0); + + const result = await provider.findAll('user-1', {}); + + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + expect(result.unreadCount).toBe(0); + }); + }); + + describe('markRead', () => { + it('marks a notification as read with ownership check', async () => { + notificationsRepository.update.mockResolvedValue({ affected: 1 }); + + await provider.markRead('notif-1', 'user-1'); + + expect(notificationsRepository.update).toHaveBeenCalledWith( + { id: 'notif-1', userId: 'user-1' }, + { isRead: true }, + ); + }); + }); + + describe('markAllRead', () => { + it('marks all unread notifications as read for the user', async () => { + notificationsRepository.update.mockResolvedValue({ affected: 5 }); + + await provider.markAllRead('user-1'); + + expect(notificationsRepository.update).toHaveBeenCalledWith( + { userId: 'user-1', isRead: false }, + { isRead: true }, + ); + }); + }); +}); diff --git a/backend/src/payments/payments.controller.spec.ts b/backend/src/payments/payments.controller.spec.ts new file mode 100644 index 00000000..66e82ac6 --- /dev/null +++ b/backend/src/payments/payments.controller.spec.ts @@ -0,0 +1,128 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PaymentsController } from './payments.controller'; +import { PaymentsService } from './payments.service'; +import { UserRole } from '../users/enums/userRoles.enum'; + +describe('PaymentsController', () => { + let controller: PaymentsController; + let paymentsService: jest.Mocked>; + + beforeEach(async () => { + paymentsService = { + initialize: jest.fn(), + handleWebhook: jest.fn(), + refund: jest.fn(), + findAll: jest.fn(), + findById: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [PaymentsController], + providers: [{ provide: PaymentsService, useValue: paymentsService }], + }).compile(); + + controller = module.get(PaymentsController); + }); + + describe('POST /payments/initialize', () => { + it('calls paymentsService.initialize', async () => { + const dto = { bookingId: 'booking-1' }; + paymentsService.initialize.mockResolvedValue({ + paymentId: 'p1', + authorizationUrl: 'https://paystack.com/...', + reference: 'ref-1', + }); + + const result = await controller.initialize(dto, 'user-1'); + expect(paymentsService.initialize).toHaveBeenCalledWith( + 'booking-1', + 'user-1', + ); + expect(result.message).toBe('Payment initialized'); + expect(result.data).toHaveProperty('authorizationUrl'); + }); + }); + + describe('POST /payments/webhook', () => { + it('calls paymentsService.handleWebhook', async () => { + const mockReq = { + headers: { 'x-paystack-signature': 'test-sig' }, + rawBody: Buffer.from('{"event":"charge.success"}'), + } as any; + + paymentsService.handleWebhook.mockResolvedValue(undefined); + const result = await controller.webhook(mockReq); + expect(paymentsService.handleWebhook).toHaveBeenCalled(); + expect(result.received).toBe(true); + }); + + it('handles missing signature', async () => { + const mockReq = { + headers: {}, + rawBody: Buffer.from('{}'), + } as any; + + paymentsService.handleWebhook.mockResolvedValue(undefined); + const result = await controller.webhook(mockReq); + expect(paymentsService.handleWebhook).toHaveBeenCalledWith( + expect.any(Buffer), + '', + ); + expect(result.received).toBe(true); + }); + }); + + describe('POST /payments/:id/refund', () => { + it('calls paymentsService.refund', async () => { + const payment = { id: 'p1', status: 'refunded' } as any; + paymentsService.refund.mockResolvedValue(payment); + + const result = await controller.refund( + 'p1', + 'user-1', + UserRole.SUPER_ADMIN, + ); + expect(paymentsService.refund).toHaveBeenCalledWith( + 'p1', + 'user-1', + UserRole.SUPER_ADMIN, + ); + expect(result.message).toBe('Refund initiated'); + }); + }); + + describe('GET /payments', () => { + it('calls paymentsService.findAll', async () => { + const query = { page: 1, limit: 20 }; + paymentsService.findAll.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 20, + }); + + const result = await controller.findAll(query, 'user-1', UserRole.ADMIN); + expect(paymentsService.findAll).toHaveBeenCalledWith( + query, + 'user-1', + UserRole.ADMIN, + ); + expect(result.message).toBe('Payments retrieved successfully'); + }); + }); + + describe('GET /payments/:id', () => { + it('calls paymentsService.findById', async () => { + const payment = { id: 'p1', amount: 5000 } as any; + paymentsService.findById.mockResolvedValue(payment); + + const result = await controller.findOne('p1', 'user-1', UserRole.USER); + expect(paymentsService.findById).toHaveBeenCalledWith( + 'p1', + 'user-1', + UserRole.USER, + ); + expect(result.data).toEqual(payment); + }); + }); +}); diff --git a/backend/src/payments/payments.service.spec.ts b/backend/src/payments/payments.service.spec.ts new file mode 100644 index 00000000..fd7f1345 --- /dev/null +++ b/backend/src/payments/payments.service.spec.ts @@ -0,0 +1,113 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PaymentsService } from './payments.service'; +import { InitializePaymentProvider } from './providers/initialize-payment.provider'; +import { HandleWebhookProvider } from './providers/handle-webhook.provider'; +import { RefundPaymentProvider } from './providers/refund-payment.provider'; +import { FindPaymentsProvider } from './providers/find-payments.provider'; +import { NotFoundException } from '@nestjs/common'; +import { UserRole } from '../users/enums/userRoles.enum'; + +describe('PaymentsService', () => { + let service: PaymentsService; + let initializeProvider: jest.Mocked>; + let webhookProvider: jest.Mocked>; + let refundProvider: jest.Mocked>; + let findProvider: jest.Mocked>; + + beforeEach(async () => { + initializeProvider = { initialize: jest.fn() }; + webhookProvider = { handle: jest.fn() }; + refundProvider = { refund: jest.fn() }; + findProvider = { + findAll: jest.fn(), + findById: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PaymentsService, + { provide: InitializePaymentProvider, useValue: initializeProvider }, + { provide: HandleWebhookProvider, useValue: webhookProvider }, + { provide: RefundPaymentProvider, useValue: refundProvider }, + { provide: FindPaymentsProvider, useValue: findProvider }, + ], + }).compile(); + + service = module.get(PaymentsService); + }); + + describe('initialize', () => { + it('delegates to InitializePaymentProvider', async () => { + initializeProvider.initialize.mockResolvedValue({ + paymentId: 'p1', + authorizationUrl: 'https://paystack.com/...', + reference: 'ref-1', + }); + const result = await service.initialize('booking-1', 'user-1'); + expect(initializeProvider.initialize).toHaveBeenCalledWith( + 'booking-1', + 'user-1', + ); + expect(result).toHaveProperty('authorizationUrl'); + expect(result.reference).toBe('ref-1'); + }); + }); + + describe('handleWebhook', () => { + it('delegates to HandleWebhookProvider', async () => { + const raw = Buffer.from('{}'); + webhookProvider.handle.mockResolvedValue(undefined); + await service.handleWebhook(raw, 'sig'); + expect(webhookProvider.handle).toHaveBeenCalledWith(raw, 'sig'); + }); + }); + + describe('refund', () => { + it('delegates to RefundPaymentProvider', async () => { + const payment = { id: 'p1', status: 'refunded' } as any; + refundProvider.refund.mockResolvedValue(payment); + const result = await service.refund('p1', 'user-1', UserRole.SUPER_ADMIN); + expect(refundProvider.refund).toHaveBeenCalledWith( + 'p1', + 'user-1', + UserRole.SUPER_ADMIN, + ); + expect(result.status).toBe('refunded'); + }); + }); + + describe('findAll', () => { + it('delegates to FindPaymentsProvider', async () => { + const query = { page: 1, limit: 20 }; + findProvider.findAll.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 20, + }); + const result = await service.findAll(query, 'user-1', UserRole.USER); + expect(findProvider.findAll).toHaveBeenCalledWith( + query, + 'user-1', + UserRole.USER, + ); + expect(result.total).toBe(0); + }); + }); + + describe('findById', () => { + it('returns payment when found', async () => { + const payment = { id: 'p1', amount: 5000 } as any; + findProvider.findById.mockResolvedValue(payment); + const result = await service.findById('p1', 'user-1', UserRole.ADMIN); + expect(result).toEqual(payment); + }); + + it('throws NotFoundException when payment not found', async () => { + findProvider.findById.mockResolvedValue(null); + await expect( + service.findById('p1', 'user-1', UserRole.USER), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/backend/src/payments/providers/find-payments.provider.spec.ts b/backend/src/payments/providers/find-payments.provider.spec.ts new file mode 100644 index 00000000..b8aad5dd --- /dev/null +++ b/backend/src/payments/providers/find-payments.provider.spec.ts @@ -0,0 +1,172 @@ +import { NotFoundException } from '@nestjs/common'; +import { FindPaymentsProvider } from './find-payments.provider'; +import { UserRole } from '../../users/enums/userRoles.enum'; +import { PaymentStatus } from '../enums/payment-status.enum'; +import { PaymentProvider } from '../enums/payment-provider.enum'; + +describe('FindPaymentsProvider', () => { + let provider: FindPaymentsProvider; + let paymentsRepository: any; + + beforeEach(() => { + paymentsRepository = { + createQueryBuilder: jest.fn(), + }; + provider = new FindPaymentsProvider(paymentsRepository); + }); + + function mockQueryBuilder(overrides: any = {}) { + const qb = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest + .fn() + .mockResolvedValue([overrides.data ?? [], overrides.total ?? 0]), + getOne: jest.fn().mockResolvedValue(overrides.payment ?? null), + }; + paymentsRepository.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + describe('findAll', () => { + it('filters by userId for regular users', async () => { + const qb = mockQueryBuilder({ total: 1, data: [{ id: 'pay-1' }] }); + + const result = await provider.findAll({}, 'user-1', UserRole.USER); + + expect(qb.where).toHaveBeenCalledWith('payment.userId = :userId', { + userId: 'user-1', + }); + expect(result.data).toHaveLength(1); + }); + + it('does not filter by userId for admins', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({}, 'admin-1', UserRole.ADMIN); + + expect(qb.where).not.toHaveBeenCalled(); + }); + + it('filters by bookingId', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { bookingId: 'booking-1' }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'payment.bookingId = :bookingId', + { bookingId: 'booking-1' }, + ); + }); + + it('filters by status', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { status: PaymentStatus.SUCCESS }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith('payment.status = :status', { + status: PaymentStatus.SUCCESS, + }); + }); + + it('filters by provider', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { provider: PaymentProvider.PAYSTACK }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith('payment.provider = :provider', { + provider: PaymentProvider.PAYSTACK, + }); + }); + + it('filters by date range', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll( + { from: '2024-01-01', to: '2024-01-31' }, + 'admin-1', + UserRole.ADMIN, + ); + + expect(qb.andWhere).toHaveBeenCalledWith('payment.createdAt >= :from', { + from: '2024-01-01', + }); + expect(qb.andWhere).toHaveBeenCalledWith('payment.createdAt <= :to', { + to: '2024-01-31', + }); + }); + + it('applies pagination with defaults', async () => { + const qb = mockQueryBuilder({ total: 50, data: [] }); + + const result = await provider.findAll({}, 'admin-1', UserRole.ADMIN); + + expect(qb.skip).toHaveBeenCalledWith(0); + expect(qb.take).toHaveBeenCalledWith(20); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + + it('caps limit to 100', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({ limit: 500 }, 'admin-1', UserRole.ADMIN); + + expect(qb.take).toHaveBeenCalledWith(100); + }); + }); + + describe('findById', () => { + it('returns payment when found and owned by user', async () => { + const qb = mockQueryBuilder({ + payment: { id: 'pay-1', userId: 'user-1' }, + }); + + const result = await provider.findById('pay-1', 'user-1', UserRole.USER); + + expect(result).toEqual({ id: 'pay-1', userId: 'user-1' }); + }); + + it('returns payment for admin regardless of ownership', async () => { + const qb = mockQueryBuilder({ + payment: { id: 'pay-1', userId: 'user-2' }, + }); + + const result = await provider.findById( + 'pay-1', + 'admin-1', + UserRole.ADMIN, + ); + + expect(result).toEqual({ id: 'pay-1', userId: 'user-2' }); + }); + + it('returns null when payment not found', async () => { + mockQueryBuilder({ payment: null }); + + const result = await provider.findById( + 'unknown', + 'user-1', + UserRole.USER, + ); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/backend/src/payments/providers/handle-webhook.provider.spec.ts b/backend/src/payments/providers/handle-webhook.provider.spec.ts new file mode 100644 index 00000000..aec387e2 --- /dev/null +++ b/backend/src/payments/providers/handle-webhook.provider.spec.ts @@ -0,0 +1,240 @@ +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HandleWebhookProvider } from './handle-webhook.provider'; +import { PaystackProvider } from './paystack.provider'; +import { SorobanEscrowProvider } from './soroban-escrow.provider'; +import { BookingsService } from '../../bookings/bookings.service'; +import { InvoicesService } from '../../invoices/invoices.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { EmailService } from '../../email/email.service'; +import { PaymentStatus } from '../enums/payment-status.enum'; +import { PlanType } from '../../bookings/enums/plan-type.enum'; + +describe('HandleWebhookProvider', () => { + let provider: HandleWebhookProvider; + let paymentsRepository: any; + let bookingsRepository: any; + let usersRepository: any; + let paystackProvider: jest.Mocked>; + let sorobanEscrowProvider: jest.Mocked>; + let bookingsService: jest.Mocked>; + let invoicesService: jest.Mocked>; + let notificationsService: jest.Mocked>; + let emailService: jest.Mocked>; + let configService: jest.Mocked>; + + beforeEach(async () => { + paymentsRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + bookingsRepository = { + findOne: jest.fn(), + update: jest.fn(), + }; + usersRepository = { + findOne: jest.fn(), + }; + paystackProvider = { + verifyWebhookSignature: jest.fn(), + }; + sorobanEscrowProvider = { + createEscrow: jest.fn(), + }; + bookingsService = { + confirm: jest.fn(), + }; + invoicesService = { + generateForPayment: jest.fn(), + }; + notificationsService = { + create: jest.fn(), + }; + emailService = { + sendPaymentSuccessEmail: jest.fn(), + sendPaymentFailedEmail: jest.fn(), + }; + configService = { + get: jest.fn(), + }; + + provider = new HandleWebhookProvider( + paymentsRepository, + bookingsRepository, + usersRepository, + paystackProvider as any, + sorobanEscrowProvider as any, + bookingsService as any, + invoicesService as any, + notificationsService as any, + emailService as any, + configService as any, + ); + }); + + describe('handle', () => { + it('throws UnauthorizedException when signature is invalid', async () => { + paystackProvider.verifyWebhookSignature.mockReturnValue(false); + + await expect( + provider.handle(Buffer.from('{}'), 'invalid-sig'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws BadRequestException when payload is malformed JSON', async () => { + paystackProvider.verifyWebhookSignature.mockReturnValue(true); + + await expect( + provider.handle(Buffer.from('not-json'), 'valid-sig'), + ).rejects.toThrow(BadRequestException); + }); + + it('handles charge.success event', async () => { + const rawBody = Buffer.from( + JSON.stringify({ + event: 'charge.success', + data: { reference: 'ref-1', amount: 500000 }, + }), + ); + paystackProvider.verifyWebhookSignature.mockReturnValue(true); + paymentsRepository.findOne.mockResolvedValue({ + id: 'pay-1', + bookingId: 'booking-1', + userId: 'user-1', + status: PaymentStatus.PENDING, + amount: 500000, + providerReference: 'ref-1', + }); + bookingsService.confirm.mockResolvedValue({ + id: 'booking-1', + planType: PlanType.DAILY, + workspaceId: 'ws-1', + } as any); + invoicesService.generateForPayment.mockResolvedValue(undefined); + notificationsService.create.mockResolvedValue(undefined); + emailService.sendPaymentSuccessEmail.mockResolvedValue(undefined); + usersRepository.findOne.mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + fullName: 'Test User', + }); + bookingsRepository.findOne.mockResolvedValue({ + id: 'booking-1', + workspaceId: 'ws-1', + }); + + await provider.handle(rawBody, 'valid-sig'); + + expect(paymentsRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: PaymentStatus.SUCCESS, + }), + ); + expect(bookingsService.confirm).toHaveBeenCalledWith('booking-1'); + }); + + it('handles charge.failed event', async () => { + const rawBody = Buffer.from( + JSON.stringify({ + event: 'charge.failed', + data: { reference: 'ref-1' }, + }), + ); + paystackProvider.verifyWebhookSignature.mockReturnValue(true); + paymentsRepository.findOne.mockResolvedValue({ + id: 'pay-1', + userId: 'user-1', + status: PaymentStatus.PENDING, + amount: 500000, + providerReference: 'ref-1', + }); + usersRepository.findOne.mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + fullName: 'Test User', + }); + emailService.sendPaymentFailedEmail.mockResolvedValue(undefined); + notificationsService.create.mockResolvedValue(undefined); + + await provider.handle(rawBody, 'valid-sig'); + + expect(paymentsRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: PaymentStatus.FAILED, + }), + ); + }); + + it('skips charge.success when payment already succeeded (idempotent)', async () => { + const rawBody = Buffer.from( + JSON.stringify({ + event: 'charge.success', + data: { reference: 'ref-1' }, + }), + ); + paystackProvider.verifyWebhookSignature.mockReturnValue(true); + paymentsRepository.findOne.mockResolvedValue({ + id: 'pay-1', + status: PaymentStatus.SUCCESS, + amount: 500000, + providerReference: 'ref-1', + }); + + await provider.handle(rawBody, 'valid-sig'); + + expect(bookingsService.confirm).not.toHaveBeenCalled(); + }); + + it('logs a warning for charge.success with no matching payment', async () => { + const rawBody = Buffer.from( + JSON.stringify({ + event: 'charge.success', + data: { reference: 'unknown-ref' }, + }), + ); + paystackProvider.verifyWebhookSignature.mockReturnValue(true); + paymentsRepository.findOne.mockResolvedValue(null); + const loggerSpy = jest.spyOn(provider['logger'], 'warn'); + + await provider.handle(rawBody, 'valid-sig'); + + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('no payment found for reference unknown-ref'), + ); + }); + + it('logs a warning for events with no reference', async () => { + const rawBody = Buffer.from( + JSON.stringify({ + event: 'charge.success', + data: {}, + }), + ); + paystackProvider.verifyWebhookSignature.mockReturnValue(true); + const loggerSpy = jest.spyOn(provider['logger'], 'warn'); + + await provider.handle(rawBody, 'valid-sig'); + + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('no reference'), + ); + }); + + it('handles unhandled event types gracefully', async () => { + const rawBody = Buffer.from( + JSON.stringify({ + event: 'transfer.success', + data: { reference: 'ref-1' }, + }), + ); + paystackProvider.verifyWebhookSignature.mockReturnValue(true); + const loggerSpy = jest.spyOn(provider['logger'], 'log'); + + await provider.handle(rawBody, 'valid-sig'); + + expect(loggerSpy).toHaveBeenCalledWith( + 'Unhandled Paystack event: transfer.success', + ); + }); + }); +}); diff --git a/backend/src/payments/providers/initialize-payment.provider.spec.ts b/backend/src/payments/providers/initialize-payment.provider.spec.ts new file mode 100644 index 00000000..accdc918 --- /dev/null +++ b/backend/src/payments/providers/initialize-payment.provider.spec.ts @@ -0,0 +1,160 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { InitializePaymentProvider } from './initialize-payment.provider'; +import { PaystackProvider } from './paystack.provider'; +import { Payment } from '../entities/payment.entity'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { User } from '../../users/entities/user.entity'; +import { PaymentProvider as PaymentProviderEnum } from '../enums/payment-provider.enum'; +import { PaymentStatus } from '../enums/payment-status.enum'; +import { BookingStatus } from '../../bookings/enums/booking-status.enum'; +import { PlanType } from '../../bookings/enums/plan-type.enum'; + +describe('InitializePaymentProvider', () => { + let provider: InitializePaymentProvider; + let paymentsRepository: any; + let bookingsRepository: any; + let usersRepository: any; + let paystackProvider: jest.Mocked>; + let configService: jest.Mocked>; + + beforeEach(async () => { + paymentsRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + bookingsRepository = { + findOne: jest.fn(), + }; + usersRepository = { + findOne: jest.fn(), + }; + paystackProvider = { initializeTransaction: jest.fn() }; + configService = { + get: jest.fn().mockReturnValue('https://example.com/callback'), + }; + + provider = new InitializePaymentProvider( + paymentsRepository as any, + bookingsRepository as any, + usersRepository as any, + paystackProvider as any, + configService as any, + ); + }); + + const mockBooking = { + id: 'booking-1', + userId: 'user-1', + status: BookingStatus.PENDING, + totalAmount: '500000', + planType: PlanType.DAILY, + startDate: new Date('2024-06-01'), + endDate: new Date('2024-06-01'), + }; + + const mockUser = { + id: 'user-1', + email: 'user@example.com', + fullName: 'Test User', + }; + + describe('initialize', () => { + it('creates a new payment and initializes via Paystack', async () => { + bookingsRepository.findOne.mockResolvedValue(mockBooking); + paymentsRepository.findOne.mockResolvedValue(null); // no existing payment + usersRepository.findOne.mockResolvedValue(mockUser); + paymentsRepository.create.mockReturnValue({ + id: 'pay-1', + bookingId: 'booking-1', + userId: 'user-1', + amount: 500000, + provider: PaymentProviderEnum.PAYSTACK, + status: PaymentStatus.PENDING, + }); + paymentsRepository.save + .mockResolvedValueOnce({ + id: 'pay-1', + bookingId: 'booking-1', + userId: 'user-1', + amount: 500000, + provider: PaymentProviderEnum.PAYSTACK, + status: PaymentStatus.PENDING, + }) + .mockResolvedValueOnce({ + id: 'pay-1', + providerReference: 'paystack-ref-1', + }); + paystackProvider.initializeTransaction.mockResolvedValue({ + authorization_url: 'https://paystack.com/authorize', + access_code: 'abc123', + reference: 'paystack-ref-1', + }); + + const result = await provider.initialize('booking-1', 'user-1'); + + expect(result.paymentId).toBe('pay-1'); + expect(result.authorizationUrl).toBe('https://paystack.com/authorize'); + expect(result.reference).toBe('paystack-ref-1'); + expect(paymentsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + bookingId: 'booking-1', + userId: 'user-1', + amount: 500000, + provider: PaymentProviderEnum.PAYSTACK, + status: PaymentStatus.PENDING, + }), + ); + }); + + it('reuses existing pending payment for the same booking', async () => { + bookingsRepository.findOne.mockResolvedValue(mockBooking); + const existingPayment = { + id: 'pay-existing', + bookingId: 'booking-1', + status: PaymentStatus.PENDING, + }; + paymentsRepository.findOne.mockResolvedValue(existingPayment); + usersRepository.findOne.mockResolvedValue(mockUser); + paystackProvider.initializeTransaction.mockResolvedValue({ + authorization_url: 'https://paystack.com/authorize', + access_code: 'abc123', + reference: 'paystack-ref-1', + }); + + const result = await provider.initialize('booking-1', 'user-1'); + + expect(result.paymentId).toBe('pay-existing'); + expect(paystackProvider.initializeTransaction).toHaveBeenCalledWith( + 'user@example.com', + 500000, + 'pay-existing', + 'https://example.com/callback', + { bookingId: 'booking-1' }, + ); + }); + + it('throws NotFoundException when booking does not exist', async () => { + bookingsRepository.findOne.mockResolvedValue(null); + + await expect(provider.initialize('unknown', 'user-1')).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws BadRequestException when booking is not PENDING', async () => { + bookingsRepository.findOne.mockResolvedValue({ + ...mockBooking, + status: BookingStatus.CONFIRMED, + }); + + await expect(provider.initialize('booking-1', 'user-1')).rejects.toThrow( + BadRequestException, + ); + }); + }); +}); diff --git a/backend/src/payments/providers/paystack.provider.spec.ts b/backend/src/payments/providers/paystack.provider.spec.ts new file mode 100644 index 00000000..ae37e510 --- /dev/null +++ b/backend/src/payments/providers/paystack.provider.spec.ts @@ -0,0 +1,139 @@ +import { PaystackProvider } from './paystack.provider'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import * as crypto from 'crypto'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('PaystackProvider', () => { + let provider: PaystackProvider; + let configService: jest.Mocked>; + + beforeEach(() => { + configService = { + get: jest.fn().mockReturnValue('sk_test_123'), + }; + provider = new PaystackProvider(configService as any); + }); + + describe('initializeTransaction', () => { + it('makes a POST request to Paystack and returns authorization data', async () => { + const paystackResponse = { + data: { + data: { + authorization_url: 'https://paystack.com/authorize', + access_code: 'abc123', + reference: 'ref-1', + }, + }, + }; + mockedAxios.post.mockResolvedValue(paystackResponse); + + const result = await provider.initializeTransaction( + 'user@example.com', + 500000, + 'ref-1', + 'https://example.com/callback', + { bookingId: 'booking-1' }, + ); + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://api.paystack.co/transaction/initialize', + { + email: 'user@example.com', + amount: 500000, + reference: 'ref-1', + callback_url: 'https://example.com/callback', + metadata: { bookingId: 'booking-1' }, + }, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer sk_test_123', + }), + }), + ); + expect(result.authorization_url).toBe('https://paystack.com/authorize'); + expect(result.reference).toBe('ref-1'); + }); + }); + + describe('verifyTransaction', () => { + it('makes a GET request to Paystack and returns transaction data', async () => { + const paystackResponse = { + data: { + data: { id: 1, status: 'success', reference: 'ref-1' }, + }, + }; + mockedAxios.get.mockResolvedValue(paystackResponse); + + const result = await provider.verifyTransaction('ref-1'); + + expect(mockedAxios.get).toHaveBeenCalledWith( + 'https://api.paystack.co/transaction/verify/ref-1', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer sk_test_123', + }), + }), + ); + expect(result.status).toBe('success'); + }); + }); + + describe('initiateRefund', () => { + it('makes a POST request to refund with full amount by default', async () => { + mockedAxios.post.mockResolvedValue({ + data: { data: { id: 1, status: 'pending' } }, + }); + + const result = await provider.initiateRefund('ref-1'); + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://api.paystack.co/refund', + { transaction: 'ref-1' }, + expect.any(Object), + ); + expect(result.status).toBe('pending'); + }); + + it('includes amount when partial refund is requested', async () => { + mockedAxios.post.mockResolvedValue({ + data: { data: { id: 1 } }, + }); + + await provider.initiateRefund('ref-1', 250000); + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://api.paystack.co/refund', + { transaction: 'ref-1', amount: 250000 }, + expect.any(Object), + ); + }); + }); + + describe('verifyWebhookSignature', () => { + it('returns true when HMAC-SHA512 signature matches', () => { + const rawBody = Buffer.from('{"event":"charge.success"}'); + const expectedHash = crypto + .createHmac('sha512', 'sk_test_123') + .update(rawBody) + .digest('hex'); + + const result = provider.verifyWebhookSignature(rawBody, expectedHash); + + expect(result).toBe(true); + }); + + it('returns false when HMAC-SHA512 signature does not match', () => { + const rawBody = Buffer.from('{"event":"charge.success"}'); + + const result = provider.verifyWebhookSignature( + rawBody, + 'invalid-signature', + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/backend/src/payments/providers/refund-payment.provider.spec.ts b/backend/src/payments/providers/refund-payment.provider.spec.ts new file mode 100644 index 00000000..00628b2f --- /dev/null +++ b/backend/src/payments/providers/refund-payment.provider.spec.ts @@ -0,0 +1,121 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { RefundPaymentProvider } from './refund-payment.provider'; +import { PaystackProvider } from './paystack.provider'; +import { UserRole } from '../../users/enums/userRoles.enum'; +import { PaymentStatus } from '../enums/payment-status.enum'; + +describe('RefundPaymentProvider', () => { + let provider: RefundPaymentProvider; + let paymentsRepository: any; + let paystackProvider: any; + + beforeEach(() => { + paymentsRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + paystackProvider = { initiateRefund: jest.fn() }; + provider = new RefundPaymentProvider(paymentsRepository, paystackProvider); + }); + + describe('refund', () => { + it('refunds a successful payment for admin', async () => { + const payment = { + id: 'pay-1', + userId: 'user-2', + status: PaymentStatus.SUCCESS, + providerReference: 'ref-1', + amount: 500000, + }; + paymentsRepository.findOne.mockResolvedValue(payment); + paystackProvider.initiateRefund.mockResolvedValue({ id: 1 }); + paymentsRepository.save.mockResolvedValue({ + ...payment, + status: PaymentStatus.REFUNDED, + }); + + const result = await provider.refund('pay-1', 'admin-1', UserRole.ADMIN); + + expect(paystackProvider.initiateRefund).toHaveBeenCalledWith('ref-1'); + expect(result.status).toBe(PaymentStatus.REFUNDED); + }); + + it('allows SUPER_ADMIN to refund any payment', async () => { + const payment = { + id: 'pay-1', + userId: 'user-2', + status: PaymentStatus.SUCCESS, + providerReference: 'ref-1', + }; + paymentsRepository.findOne.mockResolvedValue(payment); + paystackProvider.initiateRefund.mockResolvedValue({}); + paymentsRepository.save.mockResolvedValue({ + ...payment, + status: PaymentStatus.REFUNDED, + }); + + const result = await provider.refund( + 'pay-1', + 'admin-1', + UserRole.SUPER_ADMIN, + ); + + expect(result.status).toBe(PaymentStatus.REFUNDED); + }); + + it('allows user to refund their own payment', async () => { + const payment = { + id: 'pay-1', + userId: 'user-1', + status: PaymentStatus.SUCCESS, + providerReference: 'ref-1', + }; + paymentsRepository.findOne.mockResolvedValue(payment); + paystackProvider.initiateRefund.mockResolvedValue({}); + paymentsRepository.save.mockResolvedValue({ + ...payment, + status: PaymentStatus.REFUNDED, + }); + + const result = await provider.refund('pay-1', 'user-1', UserRole.USER); + + expect(result.status).toBe(PaymentStatus.REFUNDED); + }); + + it('throws NotFoundException when payment does not exist', async () => { + paymentsRepository.findOne.mockResolvedValue(null); + + await expect( + provider.refund('unknown', 'user-1', UserRole.USER), + ).rejects.toThrow(NotFoundException); + }); + + it('throws ForbiddenException when non-admin tries to refund another users payment', async () => { + paymentsRepository.findOne.mockResolvedValue({ + id: 'pay-1', + userId: 'user-2', + status: PaymentStatus.SUCCESS, + }); + + await expect( + provider.refund('pay-1', 'user-1', UserRole.USER), + ).rejects.toThrow(ForbiddenException); + }); + + it('throws BadRequestException when payment is not SUCCESS', async () => { + paymentsRepository.findOne.mockResolvedValue({ + id: 'pay-1', + userId: 'user-1', + status: PaymentStatus.PENDING, + }); + + await expect( + provider.refund('pay-1', 'user-1', UserRole.USER), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/backend/src/users/members.controller.spec.ts b/backend/src/users/members.controller.spec.ts new file mode 100644 index 00000000..dd2fd619 --- /dev/null +++ b/backend/src/users/members.controller.spec.ts @@ -0,0 +1,128 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MembersController } from './members.controller'; +import { UsersService } from './providers/users.service'; +import { MembershipStatus } from './enums/membership-status.enum'; + +describe('MembersController', () => { + let controller: MembersController; + let usersService: any; + + const mockMember = { + id: 'member-1', + firstname: 'Alice', + lastname: 'Smith', + email: 'alice@example.com', + membershipStatus: MembershipStatus.ACTIVE, + }; + + beforeEach(async () => { + usersService = { + getMemberStats: jest.fn(), + getMemberProfile: jest.fn(), + getMembers: jest.fn(), + updateMemberStatus: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [MembersController], + providers: [{ provide: UsersService, useValue: usersService }], + }).compile(); + + controller = module.get(MembersController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getStats', () => { + it('returns member stats', async () => { + const stats = { + total: 100, + active: 60, + inactive: 30, + suspended: 10, + verified: 80, + }; + usersService.getMemberStats.mockResolvedValue(stats); + + const result = await controller.getStats(); + + expect(result).toEqual({ + message: 'Member stats retrieved', + data: stats, + }); + }); + }); + + describe('getMyProfile', () => { + it('returns current user profile', async () => { + usersService.getMemberProfile.mockResolvedValue(mockMember); + + const result = await controller.getMyProfile('member-1'); + + expect(result).toEqual({ + message: 'Profile retrieved successfully', + data: mockMember, + }); + }); + }); + + describe('findAll', () => { + it('returns paginated members', async () => { + const paginatedResult = { + data: [mockMember], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + usersService.getMembers.mockResolvedValue(paginatedResult); + + const result = await controller.findAll({}); + + expect(result).toEqual({ + message: 'Members retrieved successfully', + ...paginatedResult, + }); + expect(usersService.getMembers).toHaveBeenCalledWith({}); + }); + }); + + describe('findOne', () => { + it('returns member by id', async () => { + usersService.getMemberProfile.mockResolvedValue(mockMember); + + const result = await controller.findOne('member-1'); + + expect(result).toEqual({ + message: 'Member retrieved successfully', + data: mockMember, + }); + }); + }); + + describe('updateStatus', () => { + it('updates member status', async () => { + usersService.updateMemberStatus.mockResolvedValue({ + ...mockMember, + membershipStatus: MembershipStatus.SUSPENDED, + }); + + const result = await controller.updateStatus('member-1', { + status: MembershipStatus.SUSPENDED, + }); + + expect(result).toEqual({ + message: 'Member status updated successfully', + data: expect.objectContaining({ + membershipStatus: MembershipStatus.SUSPENDED, + }), + }); + expect(usersService.updateMemberStatus).toHaveBeenCalledWith( + 'member-1', + MembershipStatus.SUSPENDED, + ); + }); + }); +}); diff --git a/backend/src/users/providers/createUser.provider.spec.ts b/backend/src/users/providers/createUser.provider.spec.ts new file mode 100644 index 00000000..97f80f3c --- /dev/null +++ b/backend/src/users/providers/createUser.provider.spec.ts @@ -0,0 +1,173 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CreateUserProvider } from './createUser.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; + +import { ConflictException } from '@nestjs/common'; +import { HashingProvider } from '../../auth/providers/hashing.provider'; +import { ConfigService } from '@nestjs/config'; +import { GenerateTokensProvider } from '../../auth/providers/generateTokens.provider'; +import { RefreshTokenRepositoryOperations } from '../../auth/providers/refreshToken.repository'; +import { EmailService } from '../../email/email.service'; +import { UserRole } from '../enums/userRoles.enum'; + +describe('CreateUserProvider', () => { + let provider: CreateUserProvider; + let userRepository: any; + let hashingProvider: any; + let configService: any; + let generateTokensProvider: any; + let refreshTokenRepositoryOperations: any; + let emailService: any; + + const mockResponse: any = { + cookie: jest.fn(), + }; + + const createUserDto = { + firstname: 'Alice', + lastname: 'Smith', + email: 'alice@example.com', + password: 'SecurePass123!', + }; + + const mockUser = { + id: 'user-1', + ...createUserDto, + password: 'hashed-password', + role: UserRole.USER, + isVerified: false, + verificationToken: 'some-verification-token', + verificationTokenExpiry: new Date('2025-01-01'), + }; + + beforeEach(async () => { + userRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + hashingProvider = { + hash: jest.fn(), + }; + configService = { + get: jest.fn(), + }; + generateTokensProvider = { + generateBothTokens: jest.fn(), + }; + refreshTokenRepositoryOperations = { + saveRefreshToken: jest.fn(), + }; + emailService = { + sendVerificationLinkEmail: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CreateUserProvider, + { provide: getRepositoryToken(User), useValue: userRepository }, + { provide: HashingProvider, useValue: hashingProvider }, + { provide: ConfigService, useValue: configService }, + { provide: GenerateTokensProvider, useValue: generateTokensProvider }, + { + provide: RefreshTokenRepositoryOperations, + useValue: refreshTokenRepositoryOperations, + }, + { provide: EmailService, useValue: emailService }, + ], + }).compile(); + + provider = module.get(CreateUserProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('createUser', () => { + it('creates user successfully and returns tokens', async () => { + userRepository.findOne.mockResolvedValue(null); // no existing user + hashingProvider.hash.mockResolvedValue('hashed-password'); + userRepository.create.mockReturnValue(mockUser); + userRepository.save.mockResolvedValue(mockUser); + generateTokensProvider.generateBothTokens.mockResolvedValue({ + accessToken: 'access-token-123', + refreshToken: 'refresh-token-123', + }); + refreshTokenRepositoryOperations.saveRefreshToken.mockResolvedValue( + undefined, + ); + configService.get.mockReturnValue('604800000'); // 7 days in ms + emailService.sendVerificationLinkEmail.mockResolvedValue(true); + + const result = await provider.createUser( + createUserDto as any, + mockResponse, + ); + + expect(result).toEqual({ + user: mockUser, + accessToken: 'access-token-123', + }); + expect(hashingProvider.hash).toHaveBeenCalledWith('SecurePass123!'); + expect(userRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + ...createUserDto, + password: 'hashed-password', + role: UserRole.USER, + isVerified: false, + verificationToken: expect.any(String), + verificationTokenExpiry: expect.any(Date), + }), + ); + expect(mockResponse.cookie).toHaveBeenCalled(); + expect(emailService.sendVerificationLinkEmail).toHaveBeenCalledWith( + 'alice@example.com', + expect.any(String), + 'Alice Smith', + ); + }); + + it('throws ConflictException when user already exists', async () => { + userRepository.findOne.mockResolvedValue(mockUser); + + await expect( + provider.createUser(createUserDto as any, mockResponse), + ).rejects.toThrow(ConflictException); + }); + + it('handles email sending failure gracefully', async () => { + userRepository.findOne.mockResolvedValue(null); + hashingProvider.hash.mockResolvedValue('hashed-password'); + userRepository.create.mockReturnValue(mockUser); + userRepository.save.mockResolvedValue(mockUser); + generateTokensProvider.generateBothTokens.mockResolvedValue({ + accessToken: 'access-token-123', + refreshToken: 'refresh-token-123', + }); + refreshTokenRepositoryOperations.saveRefreshToken.mockResolvedValue( + undefined, + ); + configService.get.mockReturnValue('604800000'); + emailService.sendVerificationLinkEmail.mockResolvedValue(false); + + const result = await provider.createUser( + createUserDto as any, + mockResponse, + ); + + // User should still be created successfully even if email fails + expect(result.user).toBeDefined(); + expect(result.accessToken).toBeDefined(); + }); + + it('throws InternalServerErrorException when repository fails', async () => { + userRepository.findOne.mockRejectedValue(new Error('DB error')); + + await expect( + provider.createUser(createUserDto as any, mockResponse), + ).rejects.toThrow('Failed to create user: Internal server error'); + }); + }); +}); diff --git a/backend/src/users/providers/deleteUser.provider.spec.ts b/backend/src/users/providers/deleteUser.provider.spec.ts new file mode 100644 index 00000000..97789225 --- /dev/null +++ b/backend/src/users/providers/deleteUser.provider.spec.ts @@ -0,0 +1,54 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DeleteUserProvider } from './deleteUser.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { NotFoundException } from '@nestjs/common'; + +describe('DeleteUserProvider', () => { + let provider: DeleteUserProvider; + let usersRepository: any; + + beforeEach(async () => { + usersRepository = { + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DeleteUserProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + provider = module.get(DeleteUserProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('deleteUser', () => { + it('deletes user successfully', async () => { + usersRepository.delete.mockResolvedValue({ affected: 1 }); + + await expect(provider.deleteUser('user-1')).resolves.not.toThrow(); + expect(usersRepository.delete).toHaveBeenCalledWith('user-1'); + }); + + it('throws NotFoundException when user not found', async () => { + usersRepository.delete.mockResolvedValue({ affected: 0 }); + + await expect(provider.deleteUser('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws InternalServerErrorException when repository fails', async () => { + usersRepository.delete.mockRejectedValue(new Error('DB error')); + + await expect(provider.deleteUser('user-1')).rejects.toThrow( + 'Failed to delete user: Internal server error', + ); + }); + }); +}); diff --git a/backend/src/users/providers/findAdminById.provider.spec.ts b/backend/src/users/providers/findAdminById.provider.spec.ts new file mode 100644 index 00000000..00cb6120 --- /dev/null +++ b/backend/src/users/providers/findAdminById.provider.spec.ts @@ -0,0 +1,66 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FindAdminByIdProvider } from './findAdminById.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { NotFoundException } from '@nestjs/common'; +import { UserRole } from '../enums/userRoles.enum'; + +describe('FindAdminByIdProvider', () => { + let provider: FindAdminByIdProvider; + let usersRepository: any; + + const mockAdmin = { + id: 'admin-1', + email: 'admin@example.com', + role: UserRole.ADMIN, + firstname: 'Admin', + }; + + beforeEach(async () => { + usersRepository = { + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FindAdminByIdProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + provider = module.get(FindAdminByIdProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('getAdmin', () => { + it('returns admin when found with ADMIN role', async () => { + usersRepository.findOne.mockResolvedValue(mockAdmin); + + const result = await provider.getAdmin('admin-1'); + + expect(result).toEqual(mockAdmin); + expect(usersRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'admin-1', role: UserRole.ADMIN }, + }); + }); + + it('throws NotFoundException when admin not found', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect(provider.getAdmin('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws InternalServerErrorException when repository fails', async () => { + usersRepository.findOne.mockRejectedValue(new Error('DB error')); + + await expect(provider.getAdmin('admin-1')).rejects.toThrow( + 'Error retrieving admin details: Internal server error', + ); + }); + }); +}); diff --git a/backend/src/users/providers/findAllAdmins.provider.spec.ts b/backend/src/users/providers/findAllAdmins.provider.spec.ts new file mode 100644 index 00000000..b7725aa8 --- /dev/null +++ b/backend/src/users/providers/findAllAdmins.provider.spec.ts @@ -0,0 +1,63 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FindAllAdminsProvider } from './findAllAdmins.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { UserRole } from '../enums/userRoles.enum'; + +describe('FindAllAdminsProvider', () => { + let provider: FindAllAdminsProvider; + let usersRepository: any; + + const mockAdmins = [ + { id: 'admin-1', email: 'admin1@example.com', role: UserRole.ADMIN }, + { id: 'admin-2', email: 'admin2@example.com', role: UserRole.ADMIN }, + ]; + + beforeEach(async () => { + usersRepository = { + find: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FindAllAdminsProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + provider = module.get(FindAllAdminsProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('getAdmins', () => { + it('returns all admin users', async () => { + usersRepository.find.mockResolvedValue(mockAdmins); + + const result = await provider.getAdmins(); + + expect(result).toEqual(mockAdmins); + expect(usersRepository.find).toHaveBeenCalledWith({ + where: { role: UserRole.ADMIN }, + }); + }); + + it('returns empty array when no admins exist', async () => { + usersRepository.find.mockResolvedValue([]); + + const result = await provider.getAdmins(); + + expect(result).toEqual([]); + }); + + it('throws InternalServerErrorException when repository fails', async () => { + usersRepository.find.mockRejectedValue(new Error('DB error')); + + await expect(provider.getAdmins()).rejects.toThrow( + 'Error fetching admins: Internal server error', + ); + }); + }); +}); diff --git a/backend/src/users/providers/findAllUsers.provider.spec.ts b/backend/src/users/providers/findAllUsers.provider.spec.ts new file mode 100644 index 00000000..5e05b983 --- /dev/null +++ b/backend/src/users/providers/findAllUsers.provider.spec.ts @@ -0,0 +1,60 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FindAllUsersProvider } from './findAllUsers.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; + +describe('FindAllUsersProvider', () => { + let provider: FindAllUsersProvider; + let usersRepository: any; + + const mockUsers = [ + { id: 'user-1', email: 'alice@example.com', firstname: 'Alice' }, + { id: 'user-2', email: 'bob@example.com', firstname: 'Bob' }, + ]; + + beforeEach(async () => { + usersRepository = { + find: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FindAllUsersProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + provider = module.get(FindAllUsersProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('getUsers', () => { + it('returns all users', async () => { + usersRepository.find.mockResolvedValue(mockUsers); + + const result = await provider.getUsers(); + + expect(result).toEqual(mockUsers); + expect(usersRepository.find).toHaveBeenCalledTimes(1); + }); + + it('returns empty array when no users exist', async () => { + usersRepository.find.mockResolvedValue([]); + + const result = await provider.getUsers(); + + expect(result).toEqual([]); + }); + + it('throws InternalServerErrorException when repository fails', async () => { + usersRepository.find.mockRejectedValue(new Error('DB error')); + + await expect(provider.getUsers()).rejects.toThrow( + 'Error fetching users: Internal server error', + ); + }); + }); +}); diff --git a/backend/src/users/providers/findOneUserByEmail.provider.spec.ts b/backend/src/users/providers/findOneUserByEmail.provider.spec.ts new file mode 100644 index 00000000..a9164d2c --- /dev/null +++ b/backend/src/users/providers/findOneUserByEmail.provider.spec.ts @@ -0,0 +1,67 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FindOneUserByEmailProvider } from './findOneUserByEmail.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { UnauthorizedException } from '@nestjs/common'; + +describe('FindOneUserByEmailProvider', () => { + let provider: FindOneUserByEmailProvider; + let usersRepository: any; + + const mockUser = { + id: 'user-1', + email: 'alice@example.com', + firstname: 'Alice', + lastname: 'Smith', + }; + + beforeEach(async () => { + usersRepository = { + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FindOneUserByEmailProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + provider = module.get( + FindOneUserByEmailProvider, + ); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('getUser', () => { + it('returns user when found by email', async () => { + usersRepository.findOne.mockResolvedValue(mockUser); + + const result = await provider.getUser('alice@example.com'); + + expect(result).toEqual(mockUser); + expect(usersRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'alice@example.com' }, + }); + }); + + it('throws UnauthorizedException when user not found', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect(provider.getUser('unknown@example.com')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('throws InternalServerErrorException when repository fails', async () => { + usersRepository.findOne.mockRejectedValue(new Error('DB error')); + + await expect(provider.getUser('alice@example.com')).rejects.toThrow( + 'Error retrieving user details: Internal server error', + ); + }); + }); +}); diff --git a/backend/src/users/providers/findOneUserById.provider.spec.ts b/backend/src/users/providers/findOneUserById.provider.spec.ts new file mode 100644 index 00000000..51881684 --- /dev/null +++ b/backend/src/users/providers/findOneUserById.provider.spec.ts @@ -0,0 +1,65 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FindOneUserByIdProvider } from './findOneUserById.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { UnauthorizedException } from '@nestjs/common'; + +describe('FindOneUserByIdProvider', () => { + let provider: FindOneUserByIdProvider; + let usersRepository: any; + + const mockUser = { + id: 'user-1', + email: 'alice@example.com', + firstname: 'Alice', + lastname: 'Smith', + }; + + beforeEach(async () => { + usersRepository = { + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FindOneUserByIdProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + provider = module.get(FindOneUserByIdProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('getUser', () => { + it('returns user when found', async () => { + usersRepository.findOne.mockResolvedValue(mockUser); + + const result = await provider.getUser('user-1'); + + expect(result).toEqual(mockUser); + expect(usersRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + }); + }); + + it('throws UnauthorizedException when user not found', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect(provider.getUser('nonexistent')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('throws InternalServerErrorException when repository fails', async () => { + usersRepository.findOne.mockRejectedValue(new Error('DB error')); + + await expect(provider.getUser('user-1')).rejects.toThrow( + 'Error retrieving user details: Internal server error', + ); + }); + }); +}); diff --git a/backend/src/users/providers/forgotPassword.provider.spec.ts b/backend/src/users/providers/forgotPassword.provider.spec.ts new file mode 100644 index 00000000..e3273cdd --- /dev/null +++ b/backend/src/users/providers/forgotPassword.provider.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ForgotPasswordProvider } from './forgotPassword.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { EmailService } from '../../email/email.service'; +import { ConfigService } from '@nestjs/config'; + +describe('ForgotPasswordProvider', () => { + let provider: ForgotPasswordProvider; + let usersRepository: any; + let emailService: any; + let configService: any; + + const mockUser = { + id: 'user-1', + email: 'alice@example.com', + firstname: 'Alice', + lastname: 'Smith', + passwordResetToken: null, + passwordResetExpiresIn: null, + }; + + beforeEach(async () => { + usersRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + emailService = { + sendPasswordResetLinkEmail: jest.fn(), + }; + configService = { + get: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ForgotPasswordProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + { provide: EmailService, useValue: emailService }, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + provider = module.get(ForgotPasswordProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('execute', () => { + it('sends password reset email successfully', async () => { + usersRepository.findOne.mockResolvedValue(mockUser); + usersRepository.save.mockResolvedValue({ + ...mockUser, + passwordResetToken: expect.any(String), + passwordResetExpiresIn: expect.any(Date), + }); + configService.get.mockReturnValue('300000'); // 5 minutes + emailService.sendPasswordResetLinkEmail.mockResolvedValue(true); + + const result = await provider.execute('alice@example.com'); + + expect(result).toEqual({ + message: 'Password reset instructions sent to email', + }); + expect(usersRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'alice@example.com' }, + }); + expect(usersRepository.save).toHaveBeenCalled(); + expect(emailService.sendPasswordResetLinkEmail).toHaveBeenCalled(); + }); + + it('throws NotFoundException when email is not registered', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect(provider.execute('unknown@example.com')).rejects.toThrow( + NotFoundException, + ); + }); + + it('uses custom frontend reset URL from config', async () => { + usersRepository.findOne.mockResolvedValue(mockUser); + usersRepository.save.mockResolvedValue(mockUser); + configService.get + .mockReturnValueOnce('600000') // PASSWORD_RESET_EXPIRATION_MS + .mockReturnValueOnce('https://custom-app.com/reset-password?token='); // FRONTEND_PASSWORD_RESET_URL + emailService.sendPasswordResetLinkEmail.mockResolvedValue(true); + + await provider.execute('alice@example.com'); + + const resetLink = + emailService.sendPasswordResetLinkEmail.mock.calls[0][2]; + expect(resetLink).toContain( + 'https://custom-app.com/reset-password?token=', + ); + }); + + it('throws BadRequestException when email sending fails', async () => { + usersRepository.findOne.mockResolvedValue(mockUser); + usersRepository.save.mockResolvedValue(mockUser); + configService.get.mockReturnValue('300000'); + emailService.sendPasswordResetLinkEmail.mockResolvedValue(false); + + await expect(provider.execute('alice@example.com')).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws InternalServerErrorException when repository fails', async () => { + usersRepository.findOne.mockRejectedValue(new Error('DB error')); + + await expect(provider.execute('alice@example.com')).rejects.toThrow( + 'Failed to initiate password reset: Internal server error', + ); + }); + }); +}); diff --git a/backend/src/users/providers/get-member-stats.provider.spec.ts b/backend/src/users/providers/get-member-stats.provider.spec.ts new file mode 100644 index 00000000..ea4d13ec --- /dev/null +++ b/backend/src/users/providers/get-member-stats.provider.spec.ts @@ -0,0 +1,99 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GetMemberStatsProvider } from './get-member-stats.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; + +describe('GetMemberStatsProvider', () => { + let provider: GetMemberStatsProvider; + let usersRepository: any; + + beforeEach(async () => { + usersRepository = { + count: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetMemberStatsProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + provider = module.get(GetMemberStatsProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('getStats', () => { + it('returns aggregated member statistics', async () => { + usersRepository.count + .mockResolvedValueOnce(100) // total + .mockResolvedValueOnce(60) // active + .mockResolvedValueOnce(30) // inactive + .mockResolvedValueOnce(10) // suspended + .mockResolvedValueOnce(80); // verified + + const result = await provider.getStats(); + + expect(result).toEqual({ + total: 100, + active: 60, + inactive: 30, + suspended: 10, + verified: 80, + }); + expect(usersRepository.count).toHaveBeenCalledTimes(5); + }); + + it('returns zeroes when no members exist', async () => { + usersRepository.count + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0); + + const result = await provider.getStats(); + + expect(result).toEqual({ + total: 0, + active: 0, + inactive: 0, + suspended: 0, + verified: 0, + }); + }); + + it('calls count with correct filters', async () => { + usersRepository.count + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(60) + .mockResolvedValueOnce(30) + .mockResolvedValueOnce(10) + .mockResolvedValueOnce(80); + + await provider.getStats(); + + // total count + expect(usersRepository.count).toHaveBeenNthCalledWith(1, { + where: { isDeleted: false }, + }); + // active + expect(usersRepository.count).toHaveBeenNthCalledWith(2, { + where: { + membershipStatus: 'active', + isDeleted: false, + }, + }); + // suspended + expect(usersRepository.count).toHaveBeenNthCalledWith(4, { + where: { + membershipStatus: 'suspended', + isDeleted: false, + }, + }); + }); + }); +}); diff --git a/backend/src/users/providers/get-members.provider.spec.ts b/backend/src/users/providers/get-members.provider.spec.ts new file mode 100644 index 00000000..51f878b1 --- /dev/null +++ b/backend/src/users/providers/get-members.provider.spec.ts @@ -0,0 +1,109 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GetMembersProvider } from './get-members.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { MembershipStatus } from '../enums/membership-status.enum'; + +describe('GetMembersProvider', () => { + let provider: GetMembersProvider; + let usersRepository: any; + + beforeEach(async () => { + usersRepository = { + createQueryBuilder: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetMembersProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + provider = module.get(GetMembersProvider); + }); + + function mockQueryBuilder(overrides: any = {}) { + const qb = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(overrides.total ?? 0), + getMany: jest.fn().mockResolvedValue(overrides.data ?? []), + }; + usersRepository.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('getMembers', () => { + it('returns paginated members with default pagination', async () => { + const qb = mockQueryBuilder({ + total: 1, + data: [ + { id: 'member-1', firstname: 'Alice', email: 'alice@example.com' }, + ], + }); + + const result = await provider.getMembers({}); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + expect(result.totalPages).toBe(1); + expect(qb.select).toHaveBeenCalled(); + expect(qb.where).toHaveBeenCalledWith('user.isDeleted = :isDeleted', { + isDeleted: false, + }); + }); + + it('filters by membership status', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.getMembers({ status: MembershipStatus.ACTIVE }); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'user.membershipStatus = :status', + { status: MembershipStatus.ACTIVE }, + ); + }); + + it('filters by search term', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.getMembers({ search: 'alice' }); + + expect(qb.andWhere).toHaveBeenCalledWith( + '(LOWER(user.firstname) LIKE :search OR LOWER(user.lastname) LIKE :search OR LOWER(user.email) LIKE :search)', + { search: '%alice%' }, + ); + }); + + it('applies pagination parameters', async () => { + const qb = mockQueryBuilder({ total: 50, data: [] }); + + const result = await provider.getMembers({ page: 3, limit: 10 }); + + expect(qb.skip).toHaveBeenCalledWith(20); + expect(qb.take).toHaveBeenCalledWith(10); + expect(result.totalPages).toBe(5); + }); + + it('returns empty result when no members match', async () => { + mockQueryBuilder({ total: 0, data: [] }); + + const result = await provider.getMembers({ search: 'nonexistent' }); + + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + expect(result.totalPages).toBe(0); + }); + }); +}); diff --git a/backend/src/users/providers/resetPassword.provider.spec.ts b/backend/src/users/providers/resetPassword.provider.spec.ts new file mode 100644 index 00000000..74f32bde --- /dev/null +++ b/backend/src/users/providers/resetPassword.provider.spec.ts @@ -0,0 +1,137 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ResetPasswordProvider } from './resetPassword.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { HashingProvider } from '../../auth/providers/hashing.provider'; +import { RefreshTokenRepositoryOperations } from '../../auth/providers/refreshToken.repository'; +import { EmailService } from '../../email/email.service'; +import { createHash } from 'crypto'; + +describe('ResetPasswordProvider', () => { + let provider: ResetPasswordProvider; + let usersRepository: any; + let hashingProvider: any; + let refreshTokenRepositoryOperations: any; + let emailService: any; + + const rawToken = 'valid-raw-token-123'; + const hashedToken = createHash('sha256').update(rawToken).digest('hex'); + const newPassword = 'NewSecurePass123!'; + + const mockUser = { + id: 'user-1', + email: 'alice@example.com', + firstname: 'Alice', + lastname: 'Smith', + password: 'old-hashed-password', + passwordResetToken: hashedToken, + passwordResetExpiresIn: new Date(Date.now() + 300000), // 5 minutes in future + }; + + beforeEach(async () => { + usersRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + hashingProvider = { + hash: jest.fn(), + }; + refreshTokenRepositoryOperations = { + revokeAllRefreshTokens: jest.fn(), + }; + emailService = { + sendPasswordResetSuccessEmail: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ResetPasswordProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + { provide: HashingProvider, useValue: hashingProvider }, + { + provide: RefreshTokenRepositoryOperations, + useValue: refreshTokenRepositoryOperations, + }, + { provide: EmailService, useValue: emailService }, + ], + }).compile(); + + provider = module.get(ResetPasswordProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('execute', () => { + it('resets password successfully', async () => { + usersRepository.findOne.mockResolvedValue(mockUser); + hashingProvider.hash.mockResolvedValue('new-hashed-password'); + usersRepository.save.mockResolvedValue({ + ...mockUser, + password: 'new-hashed-password', + passwordResetToken: null, + passwordResetExpiresIn: null, + }); + refreshTokenRepositoryOperations.revokeAllRefreshTokens.mockResolvedValue( + undefined, + ); + emailService.sendPasswordResetSuccessEmail.mockResolvedValue(true); + + const result = await provider.execute(rawToken, newPassword); + + expect(result).toEqual({ message: 'Password reset successful' }); + expect(hashingProvider.hash).toHaveBeenCalledWith(newPassword); + expect( + refreshTokenRepositoryOperations.revokeAllRefreshTokens, + ).toHaveBeenCalledWith('user-1'); + expect(emailService.sendPasswordResetSuccessEmail).toHaveBeenCalledWith( + 'alice@example.com', + 'Alice Smith', + ); + }); + + it('throws UnauthorizedException when token is invalid', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect( + provider.execute('invalid-token', newPassword), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws BadRequestException when token has expired', async () => { + const expiredUser = { + ...mockUser, + passwordResetExpiresIn: new Date(Date.now() - 3600000), // 1 hour ago + }; + usersRepository.findOne.mockResolvedValue(expiredUser); + + await expect(provider.execute(rawToken, newPassword)).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws BadRequestException when success email fails to send', async () => { + usersRepository.findOne.mockResolvedValue(mockUser); + hashingProvider.hash.mockResolvedValue('new-hashed-password'); + usersRepository.save.mockResolvedValue(mockUser); + refreshTokenRepositoryOperations.revokeAllRefreshTokens.mockResolvedValue( + undefined, + ); + emailService.sendPasswordResetSuccessEmail.mockResolvedValue(false); + + await expect(provider.execute(rawToken, newPassword)).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws InternalServerErrorException when repository fails', async () => { + usersRepository.findOne.mockRejectedValue(new Error('DB error')); + + await expect(provider.execute(rawToken, newPassword)).rejects.toThrow( + 'Failed to reset password: Internal server error', + ); + }); + }); +}); diff --git a/backend/src/users/providers/update-member-status.provider.spec.ts b/backend/src/users/providers/update-member-status.provider.spec.ts new file mode 100644 index 00000000..dd79e1f1 --- /dev/null +++ b/backend/src/users/providers/update-member-status.provider.spec.ts @@ -0,0 +1,131 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UpdateMemberStatusProvider } from './update-member-status.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { NotFoundException } from '@nestjs/common'; +import { MembershipStatus } from '../enums/membership-status.enum'; + +describe('UpdateMemberStatusProvider', () => { + let provider: UpdateMemberStatusProvider; + let usersRepository: any; + + const baseUser = { + id: 'member-1', + firstname: 'Alice', + lastname: 'Smith', + membershipStatus: MembershipStatus.INACTIVE, + isSuspended: false, + isActive: true, + memberSince: null, + }; + + beforeEach(async () => { + usersRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UpdateMemberStatusProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + provider = module.get( + UpdateMemberStatusProvider, + ); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('updateStatus', () => { + it('activates a member and sets memberSince when first time', async () => { + usersRepository.findOne.mockResolvedValue({ ...baseUser }); + usersRepository.save.mockImplementation((user) => Promise.resolve(user)); + + const result = await provider.updateStatus( + 'member-1', + MembershipStatus.ACTIVE, + ); + + expect(result.membershipStatus).toBe(MembershipStatus.ACTIVE); + expect(result.isSuspended).toBe(false); + expect(result.isActive).toBe(true); + expect(result.memberSince).toBeInstanceOf(Date); + }); + + it('suspends a member and updates sync flags', async () => { + const activeUser = { + ...baseUser, + membershipStatus: MembershipStatus.ACTIVE, + memberSince: new Date('2024-01-01'), + }; + usersRepository.findOne.mockResolvedValue(activeUser); + usersRepository.save.mockImplementation((user) => Promise.resolve(user)); + + const result = await provider.updateStatus( + 'member-1', + MembershipStatus.SUSPENDED, + ); + + expect(result.membershipStatus).toBe(MembershipStatus.SUSPENDED); + expect(result.isSuspended).toBe(true); + expect(result.isActive).toBe(false); + }); + + it('sets member inactive', async () => { + const activeUser = { + ...baseUser, + membershipStatus: MembershipStatus.ACTIVE, + memberSince: new Date('2024-01-01'), + }; + usersRepository.findOne.mockResolvedValue(activeUser); + usersRepository.save.mockImplementation((user) => Promise.resolve(user)); + + const result = await provider.updateStatus( + 'member-1', + MembershipStatus.INACTIVE, + ); + + expect(result.membershipStatus).toBe(MembershipStatus.INACTIVE); + expect(result.isSuspended).toBe(false); + expect(result.isActive).toBe(true); + }); + + it('does not overwrite existing memberSince on reactivation', async () => { + const existingSince = new Date('2023-06-15'); + const activeUser = { + ...baseUser, + memberSince: existingSince, + }; + usersRepository.findOne.mockResolvedValue(activeUser); + usersRepository.save.mockImplementation((user) => Promise.resolve(user)); + + const result = await provider.updateStatus( + 'member-1', + MembershipStatus.ACTIVE, + ); + + expect(result.memberSince).toEqual(existingSince); + }); + + it('throws NotFoundException when member not found', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect( + provider.updateStatus('nonexistent', MembershipStatus.ACTIVE), + ).rejects.toThrow(NotFoundException); + }); + + it('re-throws error when repository fails', async () => { + usersRepository.findOne.mockRejectedValue(new Error('DB error')); + + await expect( + provider.updateStatus('member-1', MembershipStatus.ACTIVE), + ).rejects.toThrow('DB error'); + }); + }); +}); diff --git a/backend/src/users/providers/updateUser.provider.spec.ts b/backend/src/users/providers/updateUser.provider.spec.ts new file mode 100644 index 00000000..881b1204 --- /dev/null +++ b/backend/src/users/providers/updateUser.provider.spec.ts @@ -0,0 +1,82 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UpdateUserProvider } from './updateUser.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +describe('UpdateUserProvider', () => { + let provider: UpdateUserProvider; + let usersRepository: any; + + const existingUser = { + id: 'user-1', + firstname: 'Alice', + lastname: 'Smith', + email: 'alice@example.com', + }; + + beforeEach(async () => { + usersRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UpdateUserProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + provider = module.get(UpdateUserProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('updateUser', () => { + it('updates user fields successfully', async () => { + usersRepository.findOne.mockResolvedValue(existingUser); + usersRepository.save.mockResolvedValue({ + ...existingUser, + firstname: 'Alice Updated', + }); + + const result = await provider.updateUser('user-1', { + firstname: 'Alice Updated', + }); + + expect(result.firstname).toBe('Alice Updated'); + expect(usersRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + }); + expect(usersRepository.save).toHaveBeenCalled(); + }); + + it('throws NotFoundException when user not found', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect( + provider.updateUser('nonexistent', { firstname: 'Test' }), + ).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when no fields provided', async () => { + usersRepository.findOne.mockResolvedValue(existingUser); + + await expect(provider.updateUser('user-1', {})).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws InternalServerErrorException when repository fails', async () => { + usersRepository.findOne.mockResolvedValue(existingUser); + usersRepository.save.mockRejectedValue(new Error('DB error')); + + await expect( + provider.updateUser('user-1', { firstname: 'Test' }), + ).rejects.toThrow('Failed to update user: Internal server error'); + }); + }); +}); diff --git a/backend/src/users/providers/uploadProfilePicture.provider.spec.ts b/backend/src/users/providers/uploadProfilePicture.provider.spec.ts new file mode 100644 index 00000000..027d134f --- /dev/null +++ b/backend/src/users/providers/uploadProfilePicture.provider.spec.ts @@ -0,0 +1,205 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UploadProfilePictureProvider } from './uploadProfilePicture.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { BadRequestException } from '@nestjs/common'; +import { CloudinaryService } from '../../cloudinary/cloudinary.service'; +import { UserRole } from '../enums/userRoles.enum'; + +describe('UploadProfilePictureProvider', () => { + let provider: UploadProfilePictureProvider; + let usersRepository: any; + let cloudinaryService: any; + + const mockFile = { + buffer: Buffer.from('fake-image-data'), + mimetype: 'image/jpeg', + originalname: 'photo.jpg', + size: 1024, + } as Express.Multer.File; + + const existingUser = { + id: 'user-1', + firstname: 'Alice', + profilePicture: null, + }; + + beforeEach(async () => { + usersRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + cloudinaryService = { + uploadImage: jest.fn(), + extractPublicIdFromUrl: jest.fn(), + deleteImage: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UploadProfilePictureProvider, + { provide: getRepositoryToken(User), useValue: usersRepository }, + { provide: CloudinaryService, useValue: cloudinaryService }, + ], + }).compile(); + + provider = module.get( + UploadProfilePictureProvider, + ); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('uploadProfilePicture', () => { + it('uploads picture for own user', async () => { + usersRepository.findOne.mockResolvedValue(existingUser); + cloudinaryService.uploadImage.mockResolvedValue({ + secure_url: 'https://cloudinary.com/pic.jpg', + }); + usersRepository.save.mockImplementation((user) => + Promise.resolve({ + ...user, + id: 'user-1', + }), + ); + + const result = await provider.uploadProfilePicture( + 'user-1', + mockFile, + 'user-1', + UserRole.USER, + ); + + expect(result).toEqual({ + id: 'user-1', + profilePicture: 'https://cloudinary.com/pic.jpg', + }); + expect(cloudinaryService.uploadImage).toHaveBeenCalledWith( + mockFile, + 'profile-pictures', + ); + }); + + it('allows admin to upload picture for another user', async () => { + usersRepository.findOne.mockResolvedValue(existingUser); + cloudinaryService.uploadImage.mockResolvedValue({ + secure_url: 'https://cloudinary.com/pic.jpg', + }); + usersRepository.save.mockImplementation((user) => + Promise.resolve({ + ...user, + id: 'target-user', + }), + ); + + const result = await provider.uploadProfilePicture( + 'target-user', + mockFile, + 'admin-1', + UserRole.ADMIN, + ); + + expect(result.id).toBe('target-user'); + }); + + it('throws BadRequestException when non-admin user tries to update another user', async () => { + await expect( + provider.uploadProfilePicture( + 'other-user', + mockFile, + 'user-1', + UserRole.USER, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when target user not found', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect( + provider.uploadProfilePicture( + 'nonexistent', + mockFile, + 'user-1', + UserRole.USER, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('deletes old profile picture when replacing', async () => { + const userWithPicture = { + ...existingUser, + profilePicture: 'https://cloudinary.com/old-pic.jpg', + }; + usersRepository.findOne.mockResolvedValue(userWithPicture); + cloudinaryService.uploadImage.mockResolvedValue({ + secure_url: 'https://cloudinary.com/new-pic.jpg', + }); + cloudinaryService.extractPublicIdFromUrl.mockReturnValue( + 'profile-pictures/old-pic', + ); + cloudinaryService.deleteImage.mockResolvedValue({ result: 'ok' }); + usersRepository.save.mockImplementation((user) => Promise.resolve(user)); + + await provider.uploadProfilePicture( + 'user-1', + mockFile, + 'user-1', + UserRole.USER, + ); + + expect(cloudinaryService.extractPublicIdFromUrl).toHaveBeenCalledWith( + 'https://cloudinary.com/old-pic.jpg', + ); + expect(cloudinaryService.deleteImage).toHaveBeenCalledWith( + 'profile-pictures/old-pic', + ); + }); + + it('handles old picture deletion error gracefully', async () => { + const userWithPicture = { + ...existingUser, + profilePicture: 'https://cloudinary.com/old-pic.jpg', + }; + usersRepository.findOne.mockResolvedValue(userWithPicture); + cloudinaryService.uploadImage.mockResolvedValue({ + secure_url: 'https://cloudinary.com/new-pic.jpg', + }); + cloudinaryService.extractPublicIdFromUrl.mockReturnValue( + 'profile-pictures/old-pic', + ); + cloudinaryService.deleteImage.mockRejectedValue( + new Error('Delete failed'), + ); + usersRepository.save.mockImplementation((user) => Promise.resolve(user)); + + const result = await provider.uploadProfilePicture( + 'user-1', + mockFile, + 'user-1', + UserRole.USER, + ); + + // Should still succeed despite old image deletion failure + expect(result.profilePicture).toBe('https://cloudinary.com/new-pic.jpg'); + }); + + it('throws BadRequestException on upload failure', async () => { + usersRepository.findOne.mockResolvedValue(existingUser); + cloudinaryService.uploadImage.mockRejectedValue( + new Error('Upload failed'), + ); + + await expect( + provider.uploadProfilePicture( + 'user-1', + mockFile, + 'user-1', + UserRole.USER, + ), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/backend/src/users/providers/users.service.spec.ts b/backend/src/users/providers/users.service.spec.ts new file mode 100644 index 00000000..a605a4b9 --- /dev/null +++ b/backend/src/users/providers/users.service.spec.ts @@ -0,0 +1,420 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersService } from './users.service'; +import { CreateUserProvider } from './createUser.provider'; +import { FindOneUserByIdProvider } from './findOneUserById.provider'; +import { FindOneUserByEmailProvider } from './findOneUserByEmail.provider'; +import { ValidateUserProvider } from './validateUser.provider'; +import { FindAllUsersProvider } from './findAllUsers.provider'; +import { UpdateUserProvider } from './updateUser.provider'; +import { DeleteUserProvider } from './deleteUser.provider'; +import { UploadProfilePictureProvider } from './uploadProfilePicture.provider'; +import { ForgotPasswordProvider } from './forgotPassword.provider'; +import { ResetPasswordProvider } from './resetPassword.provider'; +import { FindAllAdminsProvider } from './findAllAdmins.provider'; +import { FindAdminByIdProvider } from './findAdminById.provider'; +import { GetMembersProvider } from './get-members.provider'; +import { UpdateMemberStatusProvider } from './update-member-status.provider'; +import { GetMemberStatsProvider } from './get-member-stats.provider'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { NotFoundException } from '@nestjs/common'; +import { UserRole } from '../enums/userRoles.enum'; +import { MembershipStatus } from '../enums/membership-status.enum'; + +describe('UsersService', () => { + let service: UsersService; + let usersRepository: any; + + // Providers + let createUserProvider: any; + let findOneUserByIdProvider: any; + let findOneUserByEmailProvider: any; + let validateUserProvider: any; + let findAllUsersProvider: any; + let updateUserProvider: any; + let deleteUserProvider: any; + let uploadProfilePictureProvider: any; + let forgotPasswordProvider: any; + let resetPasswordProvider: any; + let findAllAdminsProvider: any; + let findAdminByIdProvider: any; + let getMembersProvider: any; + let updateMemberStatusProvider: any; + let getMemberStatsProvider: any; + + const mockUser = { + id: 'user-1', + firstname: 'Alice', + lastname: 'Smith', + email: 'alice@example.com', + password: 'hashed-password', + role: UserRole.USER, + isVerified: true, + profileCompleteness: 50, + membershipStatus: MembershipStatus.ACTIVE, + }; + + beforeEach(async () => { + // Create mocks for all providers + createUserProvider = { createUser: jest.fn() }; + findOneUserByIdProvider = { getUser: jest.fn() }; + findOneUserByEmailProvider = { getUser: jest.fn() }; + validateUserProvider = { validateUser: jest.fn() }; + findAllUsersProvider = { getUsers: jest.fn() }; + updateUserProvider = { updateUser: jest.fn() }; + deleteUserProvider = { deleteUser: jest.fn() }; + uploadProfilePictureProvider = { uploadProfilePicture: jest.fn() }; + forgotPasswordProvider = { execute: jest.fn() }; + resetPasswordProvider = { execute: jest.fn() }; + findAllAdminsProvider = { getAdmins: jest.fn() }; + findAdminByIdProvider = { getAdmin: jest.fn() }; + getMembersProvider = { getMembers: jest.fn() }; + updateMemberStatusProvider = { updateStatus: jest.fn() }; + getMemberStatsProvider = { getStats: jest.fn() }; + + usersRepository = { + findOne: jest.fn(), + save: jest.fn(), + update: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: CreateUserProvider, useValue: createUserProvider }, + { + provide: FindOneUserByIdProvider, + useValue: findOneUserByIdProvider, + }, + { + provide: FindOneUserByEmailProvider, + useValue: findOneUserByEmailProvider, + }, + { provide: ValidateUserProvider, useValue: validateUserProvider }, + { provide: FindAllUsersProvider, useValue: findAllUsersProvider }, + { provide: UpdateUserProvider, useValue: updateUserProvider }, + { provide: DeleteUserProvider, useValue: deleteUserProvider }, + { + provide: UploadProfilePictureProvider, + useValue: uploadProfilePictureProvider, + }, + { provide: ForgotPasswordProvider, useValue: forgotPasswordProvider }, + { provide: ResetPasswordProvider, useValue: resetPasswordProvider }, + { provide: FindAllAdminsProvider, useValue: findAllAdminsProvider }, + { provide: FindAdminByIdProvider, useValue: findAdminByIdProvider }, + { provide: GetMembersProvider, useValue: getMembersProvider }, + { + provide: UpdateMemberStatusProvider, + useValue: updateMemberStatusProvider, + }, + { provide: GetMemberStatsProvider, useValue: getMemberStatsProvider }, + { provide: getRepositoryToken(User), useValue: usersRepository }, + ], + }).compile(); + + service = module.get(UsersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createUser', () => { + it('delegates to CreateUserProvider', async () => { + const dto: any = { email: 'test@example.com', password: 'Password123!' }; + const response: any = {}; + const expected = { user: {}, accessToken: 'token' }; + + createUserProvider.createUser.mockResolvedValue(expected); + + const result = await service.createUser(dto, response); + + expect(result).toEqual(expected); + expect(createUserProvider.createUser).toHaveBeenCalledWith(dto, response); + }); + }); + + describe('findAllUsers', () => { + it('delegates to FindAllUsersProvider', async () => { + findAllUsersProvider.getUsers.mockResolvedValue([mockUser]); + + const result = await service.findAllUsers(); + + expect(result).toEqual([mockUser]); + }); + }); + + describe('findAllAdmins', () => { + it('delegates to FindAllAdminsProvider', async () => { + findAllAdminsProvider.getAdmins.mockResolvedValue([mockUser]); + + const result = await service.findAllAdmins(); + + expect(result).toEqual([mockUser]); + }); + }); + + describe('findUserById', () => { + it('delegates to FindOneUserByIdProvider', async () => { + findOneUserByIdProvider.getUser.mockResolvedValue(mockUser); + + const result = await service.findUserById('user-1'); + + expect(result).toEqual(mockUser); + }); + }); + + describe('findAdminById', () => { + it('delegates to FindAdminByIdProvider', async () => { + findAdminByIdProvider.getAdmin.mockResolvedValue(mockUser); + + const result = await service.findAdminById('admin-1'); + + expect(result).toEqual(mockUser); + }); + }); + + describe('findUserByEmail', () => { + it('delegates to FindOneUserByEmailProvider', async () => { + findOneUserByEmailProvider.getUser.mockResolvedValue(mockUser); + + const result = await service.findUserByEmail('alice@example.com'); + + expect(result).toEqual(mockUser); + }); + }); + + describe('findOnePublicById', () => { + it('returns user without password', async () => { + findOneUserByIdProvider.getUser.mockResolvedValue(mockUser); + + const result = await service.findOnePublicById('user-1'); + + expect(result.id).toBe('user-1'); + expect((result as any).password).toBeUndefined(); + }); + + it('throws NotFoundException when user not found', async () => { + findOneUserByIdProvider.getUser.mockRejectedValue( + new NotFoundException(), + ); + + await expect(service.findOnePublicById('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('updateUser', () => { + it('delegates to UpdateUserProvider', async () => { + updateUserProvider.updateUser.mockResolvedValue(mockUser); + + const result = await service.updateUser('user-1', { firstname: 'Bob' }); + + expect(result).toEqual(mockUser); + }); + }); + + describe('deleteUser', () => { + it('delegates to DeleteUserProvider', async () => { + deleteUserProvider.deleteUser.mockResolvedValue(undefined); + + await expect(service.deleteUser('user-1')).resolves.not.toThrow(); + }); + }); + + describe('validateUser', () => { + it('delegates to ValidateUserProvider', async () => { + validateUserProvider.validateUser.mockResolvedValue(mockUser); + + const result = await service.validateUser( + 'alice@example.com', + 'password', + ); + + expect(result).toEqual(mockUser); + }); + }); + + describe('uploadUserProfilePicture', () => { + it('delegates to UploadProfilePictureProvider', async () => { + const file: any = { buffer: Buffer.from('test') }; + const expected = { id: 'user-1', profilePicture: 'url' }; + uploadProfilePictureProvider.uploadProfilePicture.mockResolvedValue( + expected, + ); + + const result = await service.uploadUserProfilePicture( + 'user-1', + file, + 'user-1', + UserRole.USER, + ); + + expect(result).toEqual(expected); + }); + }); + + describe('forgotPassword', () => { + it('delegates to ForgotPasswordProvider', async () => { + forgotPasswordProvider.execute.mockResolvedValue({ + message: 'Reset instructions sent', + }); + + const result = await service.forgotPassword('alice@example.com'); + + expect(result).toEqual({ message: 'Reset instructions sent' }); + }); + }); + + describe('resetPassword', () => { + it('delegates to ResetPasswordProvider', async () => { + resetPasswordProvider.execute.mockResolvedValue({ + message: 'Password reset successful', + }); + + const result = await service.resetPassword('token', 'newPassword123!'); + + expect(result).toEqual({ message: 'Password reset successful' }); + }); + }); + + describe('getMembers', () => { + it('delegates to GetMembersProvider', async () => { + const paginatedResult = { + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }; + getMembersProvider.getMembers.mockResolvedValue(paginatedResult); + + const result = await service.getMembers({}); + + expect(result).toEqual(paginatedResult); + }); + }); + + describe('updateMemberStatus', () => { + it('delegates to UpdateMemberStatusProvider', async () => { + updateMemberStatusProvider.updateStatus.mockResolvedValue(mockUser); + + const result = await service.updateMemberStatus( + 'member-1', + MembershipStatus.ACTIVE, + ); + + expect(result).toEqual(mockUser); + }); + }); + + describe('getMemberStats', () => { + it('delegates to GetMemberStatsProvider', async () => { + const stats = { + total: 100, + active: 60, + inactive: 30, + suspended: 10, + verified: 80, + }; + getMemberStatsProvider.getStats.mockResolvedValue(stats); + + const result = await service.getMemberStats(); + + expect(result).toEqual(stats); + }); + }); + + describe('updateTwoFactor', () => { + it('enables two-factor auth', async () => { + findOneUserByIdProvider.getUser.mockResolvedValue(mockUser); + usersRepository.save.mockImplementation((user) => Promise.resolve(user)); + + const result = await service.updateTwoFactor('user-1', { + twoFactorEnabled: true, + }); + + expect(result.twoFactorEnabled).toBe(true); + }); + + it('throws NotFoundException when user not found', async () => { + findOneUserByIdProvider.getUser.mockResolvedValue(null); + + await expect( + service.updateTwoFactor('nonexistent', { twoFactorEnabled: true }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('findByVerificationToken', () => { + it('finds user by verification token', async () => { + usersRepository.findOne.mockResolvedValue(mockUser); + + const result = await service.findByVerificationToken('some-token'); + + expect(result).toEqual(mockUser); + expect(usersRepository.findOne).toHaveBeenCalledWith({ + where: { verificationToken: 'some-token' }, + }); + }); + }); + + describe('findByPasswordResetToken', () => { + it('finds user by password reset token', async () => { + usersRepository.findOne.mockResolvedValue(mockUser); + + const result = await service.findByPasswordResetToken('some-token'); + + expect(result).toEqual(mockUser); + expect(usersRepository.findOne).toHaveBeenCalledWith({ + where: { passwordResetToken: 'some-token' }, + }); + }); + }); + + describe('updateProfilePicture', () => { + it('updates profile picture url and returns old picture', async () => { + const userWithPic = { + ...mockUser, + profilePicture: 'https://old-pic.com/old.jpg', + }; + findOneUserByIdProvider.getUser.mockResolvedValue(userWithPic); + usersRepository.save.mockImplementation((user) => Promise.resolve(user)); + + const result = await service.updateProfilePicture( + 'user-1', + 'https://new-pic.com/new.jpg', + ); + + expect(result.profilePicture).toBe('https://new-pic.com/new.jpg'); + expect(result.oldProfilePicture).toBe('https://old-pic.com/old.jpg'); + }); + }); + + describe('getMemberProfile', () => { + it('returns user profile and updates completeness if needed', async () => { + findOneUserByIdProvider.getUser.mockResolvedValue({ + ...mockUser, + profileCompleteness: 0, + }); + usersRepository.update.mockResolvedValue(undefined); + + const result = await service.getMemberProfile('user-1'); + + expect(result).toBeDefined(); + expect(usersRepository.update).toHaveBeenCalledWith('user-1', { + profileCompleteness: expect.any(Number), + }); + }); + + it('does not update completeness if already correct', async () => { + findOneUserByIdProvider.getUser.mockResolvedValue(mockUser); + + const result = await service.getMemberProfile('user-1'); + + expect(result).toBeDefined(); + expect(usersRepository.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/users/providers/validateUser.provider.spec.ts b/backend/src/users/providers/validateUser.provider.spec.ts new file mode 100644 index 00000000..75b2529c --- /dev/null +++ b/backend/src/users/providers/validateUser.provider.spec.ts @@ -0,0 +1,89 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ValidateUserProvider } from './validateUser.provider'; +import { FindOneUserByEmailProvider } from './findOneUserByEmail.provider'; +import { HashingProvider } from '../../auth/providers/hashing.provider'; +import { UnauthorizedException } from '@nestjs/common'; + +describe('ValidateUserProvider', () => { + let provider: ValidateUserProvider; + let findOneUserByEmail: any; + let hashingProvider: any; + + const mockUser = { + id: 'user-1', + email: 'alice@example.com', + password: 'hashed-password', + passwordResetExpiresIn: null, + passwordResetToken: null, + refreshTokens: [], + firstname: 'Alice', + lastname: 'Smith', + role: 'user', + }; + + beforeEach(async () => { + findOneUserByEmail = { getUser: jest.fn() }; + hashingProvider = { compare: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ValidateUserProvider, + { provide: FindOneUserByEmailProvider, useValue: findOneUserByEmail }, + { provide: HashingProvider, useValue: hashingProvider }, + ], + }).compile(); + + provider = module.get(ValidateUserProvider); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('validateUser', () => { + it('returns user without sensitive fields when credentials are valid', async () => { + findOneUserByEmail.getUser.mockResolvedValue(mockUser); + hashingProvider.compare.mockResolvedValue(true); + + const result = await provider.validateUser( + 'alice@example.com', + 'correct-password', + ); + + expect(result).toBeDefined(); + expect(result.id).toBe('user-1'); + expect(result.email).toBe('alice@example.com'); + expect((result as any).password).toBeUndefined(); + expect((result as any).passwordResetToken).toBeUndefined(); + expect((result as any).refreshTokens).toBeUndefined(); + }); + + it('throws UnauthorizedException when password is invalid', async () => { + findOneUserByEmail.getUser.mockResolvedValue(mockUser); + hashingProvider.compare.mockResolvedValue(false); + + await expect( + provider.validateUser('alice@example.com', 'wrong-password'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws when findOneUserByEmail throws', async () => { + findOneUserByEmail.getUser.mockRejectedValue( + new UnauthorizedException('Credentials are not valid'), + ); + + await expect( + provider.validateUser('unknown@example.com', 'any-password'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws InternalServerErrorException when hashing fails', async () => { + findOneUserByEmail.getUser.mockResolvedValue(mockUser); + hashingProvider.compare.mockRejectedValue(new Error('Hash error')); + + await expect( + provider.validateUser('alice@example.com', 'password'), + ).rejects.toThrow('Error validating user: Internal server error'); + }); + }); +}); diff --git a/backend/src/users/users.controller.spec.ts b/backend/src/users/users.controller.spec.ts new file mode 100644 index 00000000..e2a907b6 --- /dev/null +++ b/backend/src/users/users.controller.spec.ts @@ -0,0 +1,138 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersController } from './users.controller'; +import { UsersService } from './providers/users.service'; +import { UserRole } from './enums/userRoles.enum'; + +describe('UsersController', () => { + let controller: UsersController; + let usersService: any; + + const mockUser = { + id: 'user-1', + firstname: 'Alice', + lastname: 'Smith', + email: 'alice@example.com', + role: UserRole.USER, + profilePicture: null, + }; + + beforeEach(async () => { + usersService = { + uploadUserProfilePicture: jest.fn(), + resetPassword: jest.fn(), + findOnePublicById: jest.fn(), + findAllUsers: jest.fn(), + updateUser: jest.fn(), + deleteUser: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [{ provide: UsersService, useValue: usersService }], + }).compile(); + + controller = module.get(UsersController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('uploadProfilePicture', () => { + it('uploads profile picture and returns response', async () => { + const file = { buffer: Buffer.from('test') } as Express.Multer.File; + usersService.uploadUserProfilePicture.mockResolvedValue({ + id: 'user-1', + profilePicture: 'https://cloudinary.com/pic.jpg', + }); + + const result = await controller.uploadProfilePicture( + 'user-1', + file, + 'user-1', + UserRole.USER, + ); + + expect(result).toEqual({ + message: 'Profile picture updated successfully', + data: { + id: 'user-1', + profilePicture: 'https://cloudinary.com/pic.jpg', + }, + }); + expect(usersService.uploadUserProfilePicture).toHaveBeenCalledWith( + 'user-1', + file, + 'user-1', + UserRole.USER, + ); + }); + }); + + describe('resetPassword', () => { + it('resets password and returns result', async () => { + usersService.resetPassword.mockResolvedValue({ + message: 'Password reset successful', + }); + + const result = await controller.resetPassword({ + token: 'reset-token', + newPassword: 'NewPass123!', + }); + + expect(usersService.resetPassword).toHaveBeenCalledWith( + 'reset-token', + 'NewPass123!', + ); + }); + }); + + describe('findOne', () => { + it('returns user by id without password', async () => { + usersService.findOnePublicById.mockResolvedValue(mockUser); + + const result = await controller.findOne('user-1'); + + expect(result).toEqual({ + message: 'User retrieved successfully', + data: mockUser, + }); + }); + }); + + describe('findAll', () => { + it('returns all users', async () => { + usersService.findAllUsers.mockResolvedValue([mockUser]); + + const result = await controller.findAll(); + + expect(result).toEqual({ success: true, data: [mockUser] }); + }); + }); + + describe('update', () => { + it('updates user and returns response', async () => { + usersService.updateUser.mockResolvedValue({ + ...mockUser, + firstname: 'Alice Updated', + }); + + const result = await controller.update('user-1', { + firstname: 'Alice Updated', + } as any); + + expect(result.success).toBe(true); + expect(result.data.firstname).toBe('Alice Updated'); + }); + }); + + describe('remove', () => { + it('deletes user and returns no content', async () => { + usersService.deleteUser.mockResolvedValue(undefined); + + const result = await controller.remove('user-1'); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/backend/src/users/utils/profile-completeness.util.spec.ts b/backend/src/users/utils/profile-completeness.util.spec.ts new file mode 100644 index 00000000..d89ee2bd --- /dev/null +++ b/backend/src/users/utils/profile-completeness.util.spec.ts @@ -0,0 +1,89 @@ +import { computeProfileCompleteness } from './profile-completeness.util'; +import { User } from '../entities/user.entity'; +import { MembershipStatus } from '../enums/membership-status.enum'; +import { UserRole } from '../enums/userRoles.enum'; + +describe('computeProfileCompleteness', () => { + const baseUser: Partial = { + id: 'user-1', + firstname: '', + lastname: '', + isVerified: false, + phone: null, + profilePicture: null, + username: null, + memberSince: null, + membershipStatus: MembershipStatus.INACTIVE, + role: UserRole.USER, + email: 'test@example.com', + password: 'hashed', + }; + + it('returns 0 when no fields are filled', () => { + const score = computeProfileCompleteness(baseUser as User); + expect(score).toBe(0); + }); + + it('adds 10 points for firstname and lastname', () => { + const user = { ...baseUser, firstname: 'John', lastname: 'Doe' }; + expect(computeProfileCompleteness(user as User)).toBe(10); + }); + + it('adds 20 points for being verified', () => { + const user = { ...baseUser, isVerified: true }; + expect(computeProfileCompleteness(user as User)).toBe(20); + }); + + it('adds 15 points for having a phone number', () => { + const user = { ...baseUser, phone: '+234800000000' }; + expect(computeProfileCompleteness(user as User)).toBe(15); + }); + + it('adds 15 points for having a profile picture', () => { + const user = { ...baseUser, profilePicture: 'https://example.com/pic.jpg' }; + expect(computeProfileCompleteness(user as User)).toBe(15); + }); + + it('adds 10 points for having a username', () => { + const user = { ...baseUser, username: 'johndoe' }; + expect(computeProfileCompleteness(user as User)).toBe(10); + }); + + it('adds 10 points for having memberSince date', () => { + const user = { ...baseUser, memberSince: new Date() }; + expect(computeProfileCompleteness(user as User)).toBe(10); + }); + + it('adds 20 points for ACTIVE membership status', () => { + const user = { ...baseUser, membershipStatus: MembershipStatus.ACTIVE }; + expect(computeProfileCompleteness(user as User)).toBe(20); + }); + + it('does not add points for INACTIVE or SUSPENDED status', () => { + const inactive = { + ...baseUser, + membershipStatus: MembershipStatus.INACTIVE, + }; + const suspended = { + ...baseUser, + membershipStatus: MembershipStatus.SUSPENDED, + }; + expect(computeProfileCompleteness(inactive as User)).toBe(0); + expect(computeProfileCompleteness(suspended as User)).toBe(0); + }); + + it('scores 100 when all fields are complete', () => { + const user = { + ...baseUser, + firstname: 'John', + lastname: 'Doe', + isVerified: true, + phone: '+234800000000', + profilePicture: 'https://example.com/pic.jpg', + username: 'johndoe', + memberSince: new Date('2024-01-01'), + membershipStatus: MembershipStatus.ACTIVE, + }; + expect(computeProfileCompleteness(user as User)).toBe(100); + }); +}); diff --git a/backend/src/utils/soroban-types.spec.ts b/backend/src/utils/soroban-types.spec.ts new file mode 100644 index 00000000..560c553d --- /dev/null +++ b/backend/src/utils/soroban-types.spec.ts @@ -0,0 +1,76 @@ +import { scValToNative } from '@stellar/stellar-sdk'; +import { mapScValToescrow, mapScValToescrowStatus } from './soroban-types'; + +jest.mock('@stellar/stellar-sdk', () => ({ + scValToNative: jest.fn(), +})); + +describe('soroban-types', () => { + describe('mapScValToescrow', () => { + it('maps a Soroban ScVal map to a plain JS object', () => { + const mockSymKey = (val: string) => ({ + sym: () => ({ toString: () => val }), + }); + const mockEntry = (key: string, val: any) => ({ + key: () => mockSymKey(key), + val: () => val, + }); + + const mockScVal = { + map: () => [ + mockEntry('amount', '1000'), + mockEntry('status', 'pending'), + mockEntry('depositor', 'GABCDEF123'), + ], + }; + + (scValToNative as jest.Mock) + .mockReturnValueOnce(1000) + .mockReturnValueOnce('pending') + .mockReturnValueOnce('GABCDEF123'); + + const result = mapScValToescrow(mockScVal); + + expect(result).toEqual({ + amount: 1000, + status: 'pending', + depositor: 'GABCDEF123', + }); + expect(scValToNative).toHaveBeenCalledTimes(3); + }); + + it('handles an empty map', () => { + const mockScVal = { map: () => [] }; + + const result = mapScValToescrow(mockScVal); + + expect(result).toEqual({}); + }); + }); + + describe('mapScValToescrowStatus', () => { + const mockSym = (val: string) => ({ + sym: () => ({ toString: () => val }), + }); + + it('maps "pending" to "Pending"', () => { + expect(mapScValToescrowStatus(mockSym('pending'))).toBe('Pending'); + }); + + it('maps "released" to "Released"', () => { + expect(mapScValToescrowStatus(mockSym('released'))).toBe('Released'); + }); + + it('maps "refunded" to "Refunded"', () => { + expect(mapScValToescrowStatus(mockSym('refunded'))).toBe('Refunded'); + }); + + it('maps "disputed" to "Disputed"', () => { + expect(mapScValToescrowStatus(mockSym('disputed'))).toBe('Disputed'); + }); + + it('maps unknown status to "Unknown"', () => { + expect(mapScValToescrowStatus(mockSym('cancelled'))).toBe('Unknown'); + }); + }); +}); diff --git a/backend/src/workspace-tracking/providers/check-in.provider.spec.ts b/backend/src/workspace-tracking/providers/check-in.provider.spec.ts index 8aa9c27f..2bda6703 100644 --- a/backend/src/workspace-tracking/providers/check-in.provider.spec.ts +++ b/backend/src/workspace-tracking/providers/check-in.provider.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { CheckInProvider } from './check-in.provider'; describe('CheckInProvider', () => { @@ -12,7 +12,6 @@ describe('CheckInProvider', () => { save: jest.fn(), findOne: jest.fn(), count: jest.fn(), - createQueryBuilder: jest.fn(), }; workspacesRepository = { findOne: jest.fn(), @@ -21,41 +20,295 @@ describe('CheckInProvider', () => { provider = new CheckInProvider(logsRepository, workspacesRepository); }); - it('rejects raw biometric payloads before any workspace log is persisted', async () => { - workspacesRepository.findOne.mockResolvedValue({ - id: 'ws-1', - isActive: true, + // ───────────────────────────────────── + // validateBiometricPrivacy (indirectly tested via checkIn) + // ───────────────────────────────────── + describe('checkIn - biometric validation', () => { + const baseDto = { workspaceId: 'ws-1' }; + + it('rejects raw biometricTemplate', async () => { + await expect( + provider.checkIn( + { ...baseDto, biometricTemplate: 'raw-data' } as any, + 'user-1', + ), + ).rejects.toThrow(BadRequestException); + expect(logsRepository.save).not.toHaveBeenCalled(); + }); + + it('rejects biometricTemplateData', async () => { + await expect( + provider.checkIn( + { ...baseDto, biometricTemplateData: 'raw-data' } as any, + 'user-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects rawBiometricTemplate', async () => { + await expect( + provider.checkIn( + { ...baseDto, rawBiometricTemplate: 'raw-data' } as any, + 'user-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects biometricSample', async () => { + await expect( + provider.checkIn( + { ...baseDto, biometricSample: 'raw-data' } as any, + 'user-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects biometricPayload', async () => { + await expect( + provider.checkIn( + { ...baseDto, biometricPayload: 'raw-data' } as any, + 'user-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects fingerprintTemplate', async () => { + await expect( + provider.checkIn( + { ...baseDto, fingerprintTemplate: 'raw-data' } as any, + 'user-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects faceTemplate', async () => { + await expect( + provider.checkIn( + { ...baseDto, faceTemplate: 'raw-data' } as any, + 'user-1', + ), + ).rejects.toThrow(BadRequestException); }); - logsRepository.findOne.mockResolvedValue(null); - await expect( - provider.checkIn( + it('rejects when biometricProcessingLocation=vendor but no vendor name', async () => { + await expect( + provider.checkIn( + { + ...baseDto, + biometricProcessingLocation: 'vendor', + biometricVendor: '', + } as any, + 'user-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('allows vendor processing when vendor name supplied', async () => { + workspacesRepository.findOne.mockResolvedValue({ + id: 'ws-1', + isActive: true, + }); + logsRepository.findOne.mockResolvedValue(null); + logsRepository.create.mockReturnValue({ id: 'log-1' }); + logsRepository.save.mockResolvedValue({ id: 'log-1' }); + + const result = await provider.checkIn( { - workspaceId: 'ws-1', - biometricTemplate: 'raw-template-data', + ...baseDto, + biometricProcessingLocation: 'vendor', + biometricVendor: 'AcmeBio', } as any, 'user-1', - ), - ).rejects.toThrow(BadRequestException); + ); + expect(result).toEqual({ id: 'log-1' }); + }); + }); + + // ───────────────────────────────────── + // checkIn — workspace / duplicate checks + // ───────────────────────────────────── + describe('checkIn - workspace and duplicate validation', () => { + const validDto = { workspaceId: 'ws-1' }; + + beforeEach(() => { + workspacesRepository.findOne.mockResolvedValue({ + id: 'ws-1', + isActive: true, + }); + }); + + it('throws NotFoundException when workspace does not exist', async () => { + workspacesRepository.findOne.mockResolvedValue(null); + await expect(provider.checkIn(validDto, 'user-1')).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws BadRequestException when duplicate active check-in exists', async () => { + logsRepository.findOne.mockResolvedValue({ id: 'existing-log' }); + await expect(provider.checkIn(validDto, 'user-1')).rejects.toThrow( + BadRequestException, + ); + }); - expect(logsRepository.save).not.toHaveBeenCalled(); + it('creates a log entry successfully with minimal fields', async () => { + logsRepository.findOne.mockResolvedValue(null); + logsRepository.create.mockReturnValue({ id: 'log-1' }); + logsRepository.save.mockResolvedValue({ id: 'log-1' }); + + const result = await provider.checkIn(validDto, 'user-1'); + expect(result).toEqual({ id: 'log-1' }); + expect(logsRepository.create).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'ws-1', + bookingId: null, + notes: null, + biometricTemplateHash: null, + biometricStorageReference: null, + biometricProcessingLocation: null, + biometricVendor: null, + }); + }); + + it('creates a log entry with all optional fields', async () => { + const fullDto = { + workspaceId: 'ws-1', + bookingId: 'booking-1', + notes: 'Working on project X', + biometricTemplateHash: 'hashed-value', + biometricStorageReference: 'ref-123', + biometricProcessingLocation: 'local' as const, + biometricVendor: 'BuiltIn', + }; + logsRepository.findOne.mockResolvedValue(null); + logsRepository.create.mockReturnValue({ id: 'log-2' }); + logsRepository.save.mockResolvedValue({ id: 'log-2' }); + + const result = await provider.checkIn(fullDto, 'user-1'); + expect(result).toEqual({ id: 'log-2' }); + expect(logsRepository.create).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'ws-1', + bookingId: 'booking-1', + notes: 'Working on project X', + biometricTemplateHash: 'hashed-value', + biometricStorageReference: 'ref-123', + biometricProcessingLocation: 'local', + biometricVendor: 'BuiltIn', + }); + }); + }); + + // ───────────────────────────────────── + // checkOut + // ───────────────────────────────────── + describe('checkOut', () => { + it('throws NotFoundException when no active log found', async () => { + logsRepository.findOne.mockResolvedValue(null); + await expect(provider.checkOut('log-1', 'user-1')).rejects.toThrow( + NotFoundException, + ); + }); + + it('calculates duration and updates the log on successful checkout', async () => { + const now = new Date(); + const checkInTime = new Date(now.getTime() - 90 * 60 * 1000); // 90 min ago + logsRepository.findOne.mockResolvedValue({ + id: 'log-1', + userId: 'user-1', + checkedInAt: checkInTime, + checkedOutAt: null, + durationMinutes: null, + }); + logsRepository.save.mockImplementation((log) => Promise.resolve(log)); + + const result = await provider.checkOut('log-1', 'user-1'); + expect(result.checkedOutAt).toBeDefined(); + expect(result.durationMinutes).toBe(90); + expect(logsRepository.save).toHaveBeenCalled(); + }); + + it('handles checkouts that span exactly 0 minutes', async () => { + const now = new Date(); + logsRepository.findOne.mockResolvedValue({ + id: 'log-1', + userId: 'user-1', + checkedInAt: now, + checkedOutAt: null, + durationMinutes: null, + }); + logsRepository.save.mockImplementation((log) => Promise.resolve(log)); + + const result = await provider.checkOut('log-1', 'user-1'); + expect(result.durationMinutes).toBe(0); + }); }); - it('returns a privacy-focused storage audit summary', async () => { - logsRepository.count - .mockResolvedValueOnce(3) - .mockResolvedValueOnce(1) - .mockResolvedValueOnce(1); - - const summary = await provider.getStorageAuditSummary(); - - expect(summary).toEqual({ - totalLogs: 3, - logsWithHashedTemplates: 1, - logsWithOpaqueStorageRefs: 1, - rawBiometricRows: 0, - storagePolicy: 'hash-only-or-opaque-reference', - recommendedProcessing: 'local-processing-preferred', + // ───────────────────────────────────── + // getActiveCheckIn + // ───────────────────────────────────── + describe('getActiveCheckIn', () => { + it('returns active check-in for user', async () => { + logsRepository.findOne.mockResolvedValue({ id: 'log-1' }); + const result = await provider.getActiveCheckIn('user-1'); + expect(result).toEqual({ id: 'log-1' }); + // checkedOutAt uses IsNull() from typeorm, which is a FindOperator + expect(logsRepository.findOne).toHaveBeenCalledWith({ + where: { userId: 'user-1', checkedOutAt: expect.any(Object) }, + }); + }); + + it('filters by workspaceId when provided', async () => { + logsRepository.findOne.mockResolvedValue({ id: 'log-1' }); + const result = await provider.getActiveCheckIn('user-1', 'ws-1'); + expect(result).toEqual({ id: 'log-1' }); + expect(logsRepository.findOne).toHaveBeenCalledWith({ + where: { + userId: 'user-1', + workspaceId: 'ws-1', + checkedOutAt: expect.any(Object), + }, + }); + }); + + it('returns null when no active check-in exists', async () => { + logsRepository.findOne.mockResolvedValue(null); + const result = await provider.getActiveCheckIn('user-1'); + expect(result).toBeNull(); + }); + }); + + // ───────────────────────────────────── + // getStorageAuditSummary + // ───────────────────────────────────── + describe('getStorageAuditSummary', () => { + it('returns a privacy-focused summary', async () => { + logsRepository.count + .mockResolvedValueOnce(10) // totalLogs + .mockResolvedValueOnce(3) // logsWithHashedTemplates + .mockResolvedValueOnce(2); // logsWithOpaqueStorageRefs + + const summary = await provider.getStorageAuditSummary(); + expect(summary).toEqual({ + totalLogs: 10, + logsWithHashedTemplates: 3, + logsWithOpaqueStorageRefs: 2, + rawBiometricRows: 0, + storagePolicy: 'hash-only-or-opaque-reference', + recommendedProcessing: 'local-processing-preferred', + }); + }); + + it('handles empty dataset gracefully', async () => { + logsRepository.count + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0); + + const summary = await provider.getStorageAuditSummary(); + expect(summary.totalLogs).toBe(0); + expect(summary.logsWithHashedTemplates).toBe(0); + expect(summary.logsWithOpaqueStorageRefs).toBe(0); }); }); }); diff --git a/backend/src/workspace-tracking/providers/occupancy.provider.spec.ts b/backend/src/workspace-tracking/providers/occupancy.provider.spec.ts new file mode 100644 index 00000000..fcc33ec5 --- /dev/null +++ b/backend/src/workspace-tracking/providers/occupancy.provider.spec.ts @@ -0,0 +1,281 @@ +import { OccupancyProvider } from './occupancy.provider'; + +describe('OccupancyProvider', () => { + let provider: OccupancyProvider; + let logsRepository: any; + let workspacesRepository: any; + + beforeEach(() => { + logsRepository = { + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + workspacesRepository = { + createQueryBuilder: jest.fn(), + }; + + provider = new OccupancyProvider(logsRepository, workspacesRepository); + }); + + // ───────────────────────────────────── + // getCurrentOccupancy + // ───────────────────────────────────── + describe('getCurrentOccupancy', () => { + it('returns occupancy for all active workspaces', async () => { + // Mock workspace query builder + const workspaceQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([ + { id: 'ws-1', name: 'Main Hall', totalSeats: 50 }, + { id: 'ws-2', name: 'Quiet Room', totalSeats: 10 }, + ]), + }; + workspacesRepository.createQueryBuilder.mockReturnValue(workspaceQb); + + // Mock occupancy counts + logsRepository.count + .mockResolvedValueOnce(5) // ws-1 occupancy + .mockResolvedValueOnce(2); // ws-2 occupancy + + const result = await provider.getCurrentOccupancy(); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + workspaceId: 'ws-1', + workspaceName: 'Main Hall', + totalSeats: 50, + currentOccupancy: 5, + utilizationPercent: 10, + }); + expect(result[1]).toEqual({ + workspaceId: 'ws-2', + workspaceName: 'Quiet Room', + totalSeats: 10, + currentOccupancy: 2, + utilizationPercent: 20, + }); + }); + + it('filters by workspaceId when provided', async () => { + const workspaceQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest + .fn() + .mockResolvedValue([ + { id: 'ws-1', name: 'Main Hall', totalSeats: 50 }, + ]), + }; + workspacesRepository.createQueryBuilder.mockReturnValue(workspaceQb); + logsRepository.count.mockResolvedValueOnce(5); + + await provider.getCurrentOccupancy('ws-1'); + + expect(workspaceQb.andWhere).toHaveBeenCalledWith( + 'ws.id = :workspaceId', + { workspaceId: 'ws-1' }, + ); + }); + + it('handles workspace with zero totalSeats (utilization 0)', async () => { + const workspaceQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest + .fn() + .mockResolvedValue([ + { id: 'ws-3', name: 'Open Area', totalSeats: 0 }, + ]), + }; + workspacesRepository.createQueryBuilder.mockReturnValue(workspaceQb); + logsRepository.count.mockResolvedValueOnce(0); + + const result = await provider.getCurrentOccupancy(); + expect(result[0].utilizationPercent).toBe(0); + }); + + it('returns empty array when no active workspaces exist', async () => { + const workspaceQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; + workspacesRepository.createQueryBuilder.mockReturnValue(workspaceQb); + + const result = await provider.getCurrentOccupancy(); + expect(result).toEqual([]); + }); + }); + + // ───────────────────────────────────── + // getUtilizationStats + // ───────────────────────────────────── + describe('getUtilizationStats', () => { + function mockQueryBuilder(rawRows: any[]) { + const qb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(rawRows), + }; + logsRepository.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + it('returns empty stats when no completed check-ins exist', async () => { + mockQueryBuilder([]); + + const result = await provider.getUtilizationStats({}); + expect(result).toEqual([]); + }); + + it('aggregates stats across workspaces', async () => { + const qb = mockQueryBuilder([ + { + workspaceId: 'ws-1', + totalVisits: '10', + uniqueUsers: '3', + avgDurationMinutes: '45', + totalHours: '7.5', + }, + { + workspaceId: 'ws-2', + totalVisits: '5', + uniqueUsers: '2', + avgDurationMinutes: '60', + totalHours: '5.0', + }, + ]); + + // Mock workspace name lookup + const workspaceQb = { + whereInIds: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([ + { id: 'ws-1', name: 'Main Hall' }, + { id: 'ws-2', name: 'Quiet Room' }, + ]), + }; + workspacesRepository.createQueryBuilder.mockReturnValue(workspaceQb); + + const result = await provider.getUtilizationStats({}); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + workspaceId: 'ws-1', + workspaceName: 'Main Hall', + totalVisits: 10, + uniqueUsers: 3, + avgDurationMinutes: 45, + totalHours: 7.5, + }); + expect(result[1]).toEqual({ + workspaceId: 'ws-2', + workspaceName: 'Quiet Room', + totalVisits: 5, + uniqueUsers: 2, + avgDurationMinutes: 60, + totalHours: 5, + }); + }); + + it('applies workspaceId filter', async () => { + const qb = mockQueryBuilder([]); + await provider.getUtilizationStats({ workspaceId: 'ws-1' }); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'log.workspaceId = :workspaceId', + { workspaceId: 'ws-1' }, + ); + }); + + it('applies date range filters', async () => { + const qb = mockQueryBuilder([]); + await provider.getUtilizationStats({ + from: '2024-01-01', + to: '2024-01-31', + }); + + expect(qb.andWhere).toHaveBeenCalledWith('log.checkedInAt >= :from', { + from: '2024-01-01', + }); + expect(qb.andWhere).toHaveBeenCalledWith('log.checkedInAt < :to', { + to: expect.stringMatching(/2024-02-0?1/), + }); + }); + + it('sets workspace name to Unknown when workspace not found', async () => { + mockQueryBuilder([ + { + workspaceId: 'ws-999', + totalVisits: '1', + uniqueUsers: '1', + avgDurationMinutes: '30', + totalHours: '0.5', + }, + ]); + + const workspaceQb = { + whereInIds: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; + workspacesRepository.createQueryBuilder.mockReturnValue(workspaceQb); + + const result = await provider.getUtilizationStats({}); + expect(result[0].workspaceName).toBe('Unknown'); + }); + }); + + // ───────────────────────────────────── + // getRecentLogs + // ───────────────────────────────────── + describe('getRecentLogs', () => { + function createQb() { + const qb = { + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + logsRepository.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + it('returns recent logs with default limit of 50', async () => { + const qb = createQb(); + qb.getMany.mockResolvedValue([{ id: 'log-1' }]); + + const result = await provider.getRecentLogs(); + expect(result).toEqual([{ id: 'log-1' }]); + expect(qb.orderBy).toHaveBeenCalledWith('log.checkedInAt', 'DESC'); + expect(qb.take).toHaveBeenCalledWith(50); + }); + + it('accepts a custom limit', async () => { + const qb = createQb(); + qb.getMany.mockResolvedValue([{ id: 'log-1' }]); + + await provider.getRecentLogs(undefined, 10); + expect(qb.take).toHaveBeenCalledWith(10); + }); + + it('filters by workspaceId when provided', async () => { + const qb = createQb(); + qb.getMany.mockResolvedValue([]); + + await provider.getRecentLogs('ws-1', 20); + expect(qb.where).toHaveBeenCalledWith('log.workspaceId = :workspaceId', { + workspaceId: 'ws-1', + }); + }); + + it('returns empty array when no logs exist', async () => { + const qb = createQb(); + qb.getMany.mockResolvedValue([]); + + const result = await provider.getRecentLogs(); + expect(result).toEqual([]); + }); + }); +}); diff --git a/backend/src/workspace-tracking/workspace-tracking.controller.spec.ts b/backend/src/workspace-tracking/workspace-tracking.controller.spec.ts new file mode 100644 index 00000000..f82e500f --- /dev/null +++ b/backend/src/workspace-tracking/workspace-tracking.controller.spec.ts @@ -0,0 +1,176 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WorkspaceTrackingController } from './workspace-tracking.controller'; +import { WorkspaceTrackingService } from './workspace-tracking.service'; + +describe('WorkspaceTrackingController', () => { + let controller: WorkspaceTrackingController; + let service: jest.Mocked>; + + beforeEach(async () => { + service = { + checkIn: jest.fn(), + checkOut: jest.fn(), + getActiveCheckIn: jest.fn(), + getCurrentOccupancy: jest.fn(), + getUtilizationStats: jest.fn(), + getRecentLogs: jest.fn(), + getStorageAuditSummary: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [WorkspaceTrackingController], + providers: [{ provide: WorkspaceTrackingService, useValue: service }], + }).compile(); + + controller = module.get( + WorkspaceTrackingController, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + // ───────────────────────────────────── + // POST /check-in + // ───────────────────────────────────── + describe('checkIn', () => { + it('delegates to service.checkIn and returns wrapped response', async () => { + const dto = { workspaceId: 'ws-1' } as any; + service.checkIn.mockResolvedValue({ id: 'log-1' } as any); + + const result = await controller.checkIn(dto, 'user-1'); + expect(result).toEqual({ + message: 'Checked in successfully', + data: { id: 'log-1' }, + }); + expect(service.checkIn).toHaveBeenCalledWith(dto, 'user-1'); + }); + }); + + // ───────────────────────────────────── + // PATCH /check-out/:logId + // ───────────────────────────────────── + describe('checkOut', () => { + it('delegates to service.checkOut and returns wrapped response', async () => { + service.checkOut.mockResolvedValue({ id: 'log-1' } as any); + + const result = await controller.checkOut('log-1', 'user-1'); + expect(result).toEqual({ + message: 'Checked out successfully', + data: { id: 'log-1' }, + }); + expect(service.checkOut).toHaveBeenCalledWith('log-1', 'user-1'); + }); + }); + + // ───────────────────────────────────── + // GET /active + // ───────────────────────────────────── + describe('getActiveCheckIn', () => { + it('returns active check-in for user without workspaceId', async () => { + service.getActiveCheckIn.mockResolvedValue({ id: 'log-1' } as any); + + const result = await controller.getActiveCheckIn('user-1'); + expect(result).toEqual({ + message: 'Active check-in retrieved', + data: { id: 'log-1' }, + }); + expect(service.getActiveCheckIn).toHaveBeenCalledWith( + 'user-1', + undefined, + ); + }); + + it('filters by workspaceId when provided', async () => { + service.getActiveCheckIn.mockResolvedValue({ id: 'log-1' } as any); + + const result = await controller.getActiveCheckIn('user-1', 'ws-1'); + expect(result).toEqual({ + message: 'Active check-in retrieved', + data: { id: 'log-1' }, + }); + expect(service.getActiveCheckIn).toHaveBeenCalledWith('user-1', 'ws-1'); + }); + }); + + // ───────────────────────────────────── + // GET /occupancy + // ───────────────────────────────────── + describe('getCurrentOccupancy', () => { + it('returns occupancy for all workspaces', async () => { + service.getCurrentOccupancy.mockResolvedValue([]); + + const result = await controller.getCurrentOccupancy(); + expect(result).toEqual({ + message: 'Occupancy retrieved', + data: [], + }); + expect(service.getCurrentOccupancy).toHaveBeenCalledWith(undefined); + }); + + it('filters by workspaceId when provided', async () => { + service.getCurrentOccupancy.mockResolvedValue([]); + + await controller.getCurrentOccupancy('ws-1'); + expect(service.getCurrentOccupancy).toHaveBeenCalledWith('ws-1'); + }); + }); + + // ───────────────────────────────────── + // GET /utilization + // ───────────────────────────────────── + describe('getUtilizationStats', () => { + it('returns utilization stats', async () => { + const query = { from: '2024-01-01' } as any; + service.getUtilizationStats.mockResolvedValue([]); + + const result = await controller.getUtilizationStats(query); + expect(result).toEqual({ + message: 'Utilization stats retrieved', + data: [], + }); + expect(service.getUtilizationStats).toHaveBeenCalledWith(query); + }); + }); + + // ───────────────────────────────────── + // GET /logs + // ───────────────────────────────────── + describe('getRecentLogs', () => { + it('returns recent logs with default limit', async () => { + service.getRecentLogs.mockResolvedValue([]); + + const result = await controller.getRecentLogs(); + expect(result).toEqual({ + message: 'Recent logs retrieved', + data: [], + }); + expect(service.getRecentLogs).toHaveBeenCalledWith(undefined, 50); + }); + + it('passes limit as number when provided as string', async () => { + service.getRecentLogs.mockResolvedValue([]); + + await controller.getRecentLogs('ws-1', '10'); + expect(service.getRecentLogs).toHaveBeenCalledWith('ws-1', 10); + }); + }); + + // ───────────────────────────────────── + // GET /audit/storage-summary + // ───────────────────────────────────── + describe('getStorageAuditSummary', () => { + it('returns storage audit summary', async () => { + const summary = { totalLogs: 5 } as any; + service.getStorageAuditSummary.mockResolvedValue(summary); + + const result = await controller.getStorageAuditSummary(); + expect(result).toEqual({ + message: 'Biometric storage audit summary retrieved', + data: summary, + }); + expect(service.getStorageAuditSummary).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/workspace-tracking/workspace-tracking.service.spec.ts b/backend/src/workspace-tracking/workspace-tracking.service.spec.ts new file mode 100644 index 00000000..c9e8dcac --- /dev/null +++ b/backend/src/workspace-tracking/workspace-tracking.service.spec.ts @@ -0,0 +1,153 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WorkspaceTrackingService } from './workspace-tracking.service'; +import { CheckInProvider } from './providers/check-in.provider'; +import { OccupancyProvider } from './providers/occupancy.provider'; + +describe('WorkspaceTrackingService', () => { + let service: WorkspaceTrackingService; + let checkInProvider: jest.Mocked>; + let occupancyProvider: jest.Mocked>; + + beforeEach(async () => { + checkInProvider = { + checkIn: jest.fn(), + checkOut: jest.fn(), + getActiveCheckIn: jest.fn(), + getStorageAuditSummary: jest.fn(), + }; + + occupancyProvider = { + getCurrentOccupancy: jest.fn(), + getUtilizationStats: jest.fn(), + getRecentLogs: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceTrackingService, + { provide: CheckInProvider, useValue: checkInProvider }, + { provide: OccupancyProvider, useValue: occupancyProvider }, + ], + }).compile(); + + service = module.get(WorkspaceTrackingService); + }); + + // ───────────────────────────────────── + // Delegation to CheckInProvider + // ───────────────────────────────────── + describe('checkIn', () => { + it('delegates to CheckInProvider.checkIn', async () => { + const dto = { workspaceId: 'ws-1' } as any; + checkInProvider.checkIn.mockResolvedValue({ id: 'log-1' } as any); + + const result = await service.checkIn(dto, 'user-1'); + expect(result).toEqual({ id: 'log-1' }); + expect(checkInProvider.checkIn).toHaveBeenCalledWith(dto, 'user-1'); + }); + }); + + describe('checkOut', () => { + it('delegates to CheckInProvider.checkOut', async () => { + checkInProvider.checkOut.mockResolvedValue({ id: 'log-1' } as any); + + const result = await service.checkOut('log-1', 'user-1'); + expect(result).toEqual({ id: 'log-1' }); + expect(checkInProvider.checkOut).toHaveBeenCalledWith('log-1', 'user-1'); + }); + }); + + describe('getActiveCheckIn', () => { + it('delegates to CheckInProvider.getActiveCheckIn', async () => { + checkInProvider.getActiveCheckIn.mockResolvedValue({ + id: 'log-1', + } as any); + + const result = await service.getActiveCheckIn('user-1', 'ws-1'); + expect(result).toEqual({ id: 'log-1' }); + expect(checkInProvider.getActiveCheckIn).toHaveBeenCalledWith( + 'user-1', + 'ws-1', + ); + }); + + it('works without workspaceId', async () => { + checkInProvider.getActiveCheckIn.mockResolvedValue(null); + + const result = await service.getActiveCheckIn('user-1'); + expect(result).toBeNull(); + expect(checkInProvider.getActiveCheckIn).toHaveBeenCalledWith( + 'user-1', + undefined, + ); + }); + }); + + describe('getStorageAuditSummary', () => { + it('delegates to CheckInProvider.getStorageAuditSummary', async () => { + const summary = { totalLogs: 5 } as any; + checkInProvider.getStorageAuditSummary.mockResolvedValue(summary); + + const result = await service.getStorageAuditSummary(); + expect(result).toEqual(summary); + expect(checkInProvider.getStorageAuditSummary).toHaveBeenCalled(); + }); + }); + + // ───────────────────────────────────── + // Delegation to OccupancyProvider + // ───────────────────────────────────── + describe('getCurrentOccupancy', () => { + it('delegates to OccupancyProvider.getCurrentOccupancy', async () => { + occupancyProvider.getCurrentOccupancy.mockResolvedValue([]); + + const result = await service.getCurrentOccupancy(); + expect(result).toEqual([]); + expect(occupancyProvider.getCurrentOccupancy).toHaveBeenCalledWith( + undefined, + ); + }); + + it('passes workspaceId when provided', async () => { + occupancyProvider.getCurrentOccupancy.mockResolvedValue([]); + + await service.getCurrentOccupancy('ws-1'); + expect(occupancyProvider.getCurrentOccupancy).toHaveBeenCalledWith( + 'ws-1', + ); + }); + }); + + describe('getUtilizationStats', () => { + it('delegates to OccupancyProvider.getUtilizationStats', async () => { + const query = { from: '2024-01-01' } as any; + occupancyProvider.getUtilizationStats.mockResolvedValue([]); + + const result = await service.getUtilizationStats(query); + expect(result).toEqual([]); + expect(occupancyProvider.getUtilizationStats).toHaveBeenCalledWith(query); + }); + }); + + describe('getRecentLogs', () => { + it('delegates to OccupancyProvider.getRecentLogs', async () => { + occupancyProvider.getRecentLogs.mockResolvedValue([ + { id: 'log-1' }, + ] as any); + + const result = await service.getRecentLogs('ws-1', 20); + expect(result).toEqual([{ id: 'log-1' }]); + expect(occupancyProvider.getRecentLogs).toHaveBeenCalledWith('ws-1', 20); + }); + + it('passes default limit of undefined', async () => { + occupancyProvider.getRecentLogs.mockResolvedValue([]); + + await service.getRecentLogs(); + expect(occupancyProvider.getRecentLogs).toHaveBeenCalledWith( + undefined, + undefined, + ); + }); + }); +}); diff --git a/backend/src/workspaces/providers/check-workspace-availability.provider.spec.ts b/backend/src/workspaces/providers/check-workspace-availability.provider.spec.ts new file mode 100644 index 00000000..b740130d --- /dev/null +++ b/backend/src/workspaces/providers/check-workspace-availability.provider.spec.ts @@ -0,0 +1,118 @@ +import { CheckWorkspaceAvailabilityProvider } from './check-workspace-availability.provider'; +import { FindWorkspaceByIdProvider } from './find-workspace-by-id.provider'; + +describe('CheckWorkspaceAvailabilityProvider', () => { + let provider: CheckWorkspaceAvailabilityProvider; + let workspacesRepository: any; + let findWorkspaceByIdProvider: jest.Mocked< + Partial + >; + + beforeEach(() => { + workspacesRepository = {}; + findWorkspaceByIdProvider = { findById: jest.fn() }; + + provider = new CheckWorkspaceAvailabilityProvider( + workspacesRepository, + findWorkspaceByIdProvider as any, + ); + }); + + it('returns available when enough seats', async () => { + findWorkspaceByIdProvider.findById.mockResolvedValue({ + id: 'ws-1', + name: 'Test Workspace', + type: 'HotDesk', + isActive: true, + availableSeats: 10, + totalSeats: 20, + hourlyRate: 50000, + description: null, + amenities: null, + images: null, + createdAt: new Date(), + updatedAt: new Date(), + } as any); + + const result = await provider.check('ws-1', 5); + + expect(result.available).toBe(true); + expect(result.availableSeats).toBe(10); + expect(result.totalSeats).toBe(20); + expect(result.message).toBeUndefined(); + }); + + it('returns not available when insufficient seats', async () => { + findWorkspaceByIdProvider.findById.mockResolvedValue({ + id: 'ws-1', + name: 'Test Workspace', + type: 'HotDesk', + isActive: true, + availableSeats: 2, + totalSeats: 20, + hourlyRate: 50000, + description: null, + amenities: null, + images: null, + createdAt: new Date(), + updatedAt: new Date(), + } as any); + + const result = await provider.check('ws-1', 5); + + expect(result.available).toBe(false); + expect(result.message).toBe('Only 2 seats available'); + }); + + it('returns not available when workspace is inactive', async () => { + findWorkspaceByIdProvider.findById.mockResolvedValue({ + id: 'ws-1', + name: 'Test Workspace', + type: 'HotDesk', + isActive: false, + availableSeats: 10, + totalSeats: 20, + hourlyRate: 50000, + description: null, + amenities: null, + images: null, + createdAt: new Date(), + updatedAt: new Date(), + } as any); + + const result = await provider.check('ws-1', 5); + + expect(result.available).toBe(false); + expect(result.availableSeats).toBe(0); + expect(result.message).toBe('Workspace is not active'); + }); + + it('defaults to 1 requested seat when not specified', async () => { + findWorkspaceByIdProvider.findById.mockResolvedValue({ + id: 'ws-1', + name: 'Test Workspace', + type: 'HotDesk', + isActive: true, + availableSeats: 1, + totalSeats: 1, + hourlyRate: 50000, + description: null, + amenities: null, + images: null, + createdAt: new Date(), + updatedAt: new Date(), + } as any); + + const result = await provider.check('ws-1'); + + expect(result.available).toBe(true); + }); + + it('returns not available when workspace not found (delegated)', async () => { + findWorkspaceByIdProvider.findById.mockRejectedValue( + new Error('Not found'), + ); + + await expect(provider.check('unknown', 1)).rejects.toThrow('Not found'); + }); +}); diff --git a/backend/src/workspaces/providers/create-workspace.provider.spec.ts b/backend/src/workspaces/providers/create-workspace.provider.spec.ts new file mode 100644 index 00000000..3cb76948 --- /dev/null +++ b/backend/src/workspaces/providers/create-workspace.provider.spec.ts @@ -0,0 +1,65 @@ +import { ConflictException } from '@nestjs/common'; +import { CreateWorkspaceProvider } from './create-workspace.provider'; +import { WorkspaceType } from '../enums/workspace-type.enum'; + +describe('CreateWorkspaceProvider', () => { + let provider: CreateWorkspaceProvider; + let workspacesRepository: any; + + beforeEach(() => { + workspacesRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + provider = new CreateWorkspaceProvider(workspacesRepository); + }); + + const validDto = { + name: 'Hot Desk A', + type: WorkspaceType.HOT_DESK, + totalSeats: 10, + hourlyRate: 50000, + }; + + it('creates a workspace successfully', async () => { + workspacesRepository.findOne.mockResolvedValue(null); + workspacesRepository.create.mockReturnValue({ id: 'ws-1', ...validDto }); + workspacesRepository.save.mockResolvedValue({ id: 'ws-1', ...validDto }); + + const result = await provider.create(validDto); + + expect(result).toEqual({ id: 'ws-1', ...validDto }); + expect(workspacesRepository.create).toHaveBeenCalledWith({ + ...validDto, + availableSeats: validDto.totalSeats, + }); + }); + + it('throws ConflictException when workspace name already exists and is active', async () => { + workspacesRepository.findOne.mockResolvedValue({ id: 'existing' }); + + await expect(provider.create(validDto)).rejects.toThrow(ConflictException); + expect(workspacesRepository.save).not.toHaveBeenCalled(); + }); + + it('includes optional fields when provided', async () => { + const fullDto = { + ...validDto, + description: 'A great desk', + amenities: ['WiFi', 'Power'], + images: ['https://example.com/img.jpg'], + }; + + workspacesRepository.findOne.mockResolvedValue(null); + workspacesRepository.create.mockReturnValue({ id: 'ws-2', ...fullDto }); + workspacesRepository.save.mockResolvedValue({ id: 'ws-2', ...fullDto }); + + const result = await provider.create(fullDto as any); + expect(result).toEqual({ id: 'ws-2', ...fullDto }); + expect(workspacesRepository.create).toHaveBeenCalledWith({ + ...fullDto, + availableSeats: validDto.totalSeats, + }); + }); +}); diff --git a/backend/src/workspaces/providers/delete-workspace.provider.spec.ts b/backend/src/workspaces/providers/delete-workspace.provider.spec.ts new file mode 100644 index 00000000..d76532d3 --- /dev/null +++ b/backend/src/workspaces/providers/delete-workspace.provider.spec.ts @@ -0,0 +1,40 @@ +import { DeleteWorkspaceProvider } from './delete-workspace.provider'; +import { FindWorkspaceByIdProvider } from './find-workspace-by-id.provider'; + +describe('DeleteWorkspaceProvider', () => { + let provider: DeleteWorkspaceProvider; + let workspacesRepository: any; + let findWorkspaceByIdProvider: jest.Mocked< + Partial + >; + + beforeEach(() => { + workspacesRepository = { save: jest.fn() }; + findWorkspaceByIdProvider = { findById: jest.fn() }; + + provider = new DeleteWorkspaceProvider( + workspacesRepository, + findWorkspaceByIdProvider as any, + ); + }); + + it('soft-deletes a workspace by setting isActive to false', async () => { + const workspace: any = { id: 'ws-1', isActive: true }; + findWorkspaceByIdProvider.findById.mockResolvedValue(workspace); + workspacesRepository.save.mockResolvedValue(workspace); + + await provider.softDelete('ws-1'); + + expect(workspace.isActive).toBe(false); + expect(workspacesRepository.save).toHaveBeenCalledWith(workspace); + }); + + it('throws when workspace not found (delegated to findWorkspaceByIdProvider)', async () => { + findWorkspaceByIdProvider.findById.mockRejectedValue( + new Error('Not found'), + ); + + await expect(provider.softDelete('unknown')).rejects.toThrow('Not found'); + expect(workspacesRepository.save).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/workspaces/providers/find-all-workspaces.provider.spec.ts b/backend/src/workspaces/providers/find-all-workspaces.provider.spec.ts new file mode 100644 index 00000000..50ab4782 --- /dev/null +++ b/backend/src/workspaces/providers/find-all-workspaces.provider.spec.ts @@ -0,0 +1,126 @@ +import { FindAllWorkspacesProvider } from './find-all-workspaces.provider'; +import { WorkspaceType } from '../enums/workspace-type.enum'; + +describe('FindAllWorkspacesProvider', () => { + let provider: FindAllWorkspacesProvider; + let workspacesRepository: any; + + beforeEach(() => { + workspacesRepository = { + createQueryBuilder: jest.fn(), + }; + provider = new FindAllWorkspacesProvider(workspacesRepository); + }); + + function mockQueryBuilder(overrides: any = {}) { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(overrides.total ?? 0), + getMany: jest.fn().mockResolvedValue(overrides.data ?? []), + }; + workspacesRepository.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + it('returns paginated active workspaces by default', async () => { + const fakeWorkspaces = [ + { id: 'ws-1', name: 'Hot Desk A' }, + { id: 'ws-2', name: 'Hot Desk B' }, + ]; + mockQueryBuilder({ total: 2, data: fakeWorkspaces }); + + const result = await provider.findAll({}); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + expect(result.totalPages).toBe(1); + }); + + it('includes inactive workspaces in admin view', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({}, true); + + expect(qb.where).not.toHaveBeenCalledWith( + 'workspace.isActive = :isActive', + { + isActive: true, + }, + ); + }); + + it('applies type filter', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({ type: WorkspaceType.MEETING_ROOM }); + + expect(qb.andWhere).toHaveBeenCalledWith('workspace.type = :type', { + type: WorkspaceType.MEETING_ROOM, + }); + }); + + it('applies minSeats filter', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({ minSeats: 5 }); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'workspace.availableSeats >= :minSeats', + { minSeats: 5 }, + ); + }); + + it('applies maxRate filter', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({ maxRate: 100000 }); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'workspace.hourlyRate <= :maxRate', + { maxRate: 100000 }, + ); + }); + + it('applies search filter', async () => { + const qb = mockQueryBuilder({ total: 0, data: [] }); + + await provider.findAll({ search: 'conference' }); + + expect(qb.andWhere).toHaveBeenCalledWith( + '(LOWER(workspace.name) LIKE :search OR LOWER(workspace.description) LIKE :search)', + { search: '%conference%' }, + ); + }); + + it('applies pagination', async () => { + const qb = mockQueryBuilder({ total: 50, data: [] }); + + await provider.findAll({ page: 3, limit: 10 }); + + expect(qb.skip).toHaveBeenCalledWith(20); + expect(qb.take).toHaveBeenCalledWith(10); + expect(qb.orderBy).toHaveBeenCalledWith('workspace.createdAt', 'DESC'); + }); + + it('calculates totalPages correctly', async () => { + mockQueryBuilder({ total: 45, data: [] }); + + const result = await provider.findAll({ page: 1, limit: 20 }); + expect(result.totalPages).toBe(3); + }); + + it('returns empty result when no workspaces match', async () => { + mockQueryBuilder({ total: 0, data: [] }); + + const result = await provider.findAll({}); + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + expect(result.totalPages).toBe(0); + }); +}); diff --git a/backend/src/workspaces/providers/find-workspace-by-id.provider.spec.ts b/backend/src/workspaces/providers/find-workspace-by-id.provider.spec.ts new file mode 100644 index 00000000..6e68caa7 --- /dev/null +++ b/backend/src/workspaces/providers/find-workspace-by-id.provider.spec.ts @@ -0,0 +1,31 @@ +import { NotFoundException } from '@nestjs/common'; +import { FindWorkspaceByIdProvider } from './find-workspace-by-id.provider'; + +describe('FindWorkspaceByIdProvider', () => { + let provider: FindWorkspaceByIdProvider; + let workspacesRepository: any; + + beforeEach(() => { + workspacesRepository = { findOne: jest.fn() }; + provider = new FindWorkspaceByIdProvider(workspacesRepository); + }); + + it('returns workspace when found', async () => { + const workspace = { id: 'ws-1', name: 'Hot Desk A' }; + workspacesRepository.findOne.mockResolvedValue(workspace); + + const result = await provider.findById('ws-1'); + expect(result).toEqual(workspace); + expect(workspacesRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'ws-1' }, + }); + }); + + it('throws NotFoundException when workspace does not exist', async () => { + workspacesRepository.findOne.mockResolvedValue(null); + + await expect(provider.findById('unknown')).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/backend/src/workspaces/providers/update-workspace.provider.spec.ts b/backend/src/workspaces/providers/update-workspace.provider.spec.ts new file mode 100644 index 00000000..bad3b992 --- /dev/null +++ b/backend/src/workspaces/providers/update-workspace.provider.spec.ts @@ -0,0 +1,85 @@ +import { UpdateWorkspaceProvider } from './update-workspace.provider'; +import { FindWorkspaceByIdProvider } from './find-workspace-by-id.provider'; +import { WorkspaceType } from '../enums/workspace-type.enum'; + +describe('UpdateWorkspaceProvider', () => { + let provider: UpdateWorkspaceProvider; + let workspacesRepository: any; + let findWorkspaceByIdProvider: jest.Mocked< + Partial + >; + + beforeEach(() => { + workspacesRepository = { save: jest.fn() }; + findWorkspaceByIdProvider = { findById: jest.fn() }; + + provider = new UpdateWorkspaceProvider( + workspacesRepository, + findWorkspaceByIdProvider as any, + ); + }); + + const existingWorkspace = { + id: 'ws-1', + name: 'Hot Desk A', + type: WorkspaceType.HOT_DESK, + totalSeats: 10, + availableSeats: 5, + hourlyRate: 50000, + isActive: true, + description: null, + amenities: null, + images: null, + createdAt: new Date(), + updatedAt: new Date(), + } as any; + + it('updates workspace fields', async () => { + findWorkspaceByIdProvider.findById.mockResolvedValue({ + ...existingWorkspace, + }); + workspacesRepository.save.mockImplementation((ws) => Promise.resolve(ws)); + + const result = await provider.update('ws-1', { + name: 'Updated Desk', + } as any); + + expect(result.name).toBe('Updated Desk'); + expect(workspacesRepository.save).toHaveBeenCalled(); + }); + + it('increases availableSeats proportionally when totalSeats increased', async () => { + findWorkspaceByIdProvider.findById.mockResolvedValue({ + ...existingWorkspace, + }); + workspacesRepository.save.mockImplementation((ws) => Promise.resolve(ws)); + + const result = await provider.update('ws-1', { totalSeats: 15 } as any); + + expect(result.availableSeats).toBe(10); // 5 + (15 - 10) + }); + + it('does not change availableSeats when totalSeats unchanged', async () => { + findWorkspaceByIdProvider.findById.mockResolvedValue({ + ...existingWorkspace, + }); + workspacesRepository.save.mockImplementation((ws) => Promise.resolve(ws)); + + const result = await provider.update('ws-1', { + description: 'Updated description', + } as any); + + expect(result.availableSeats).toBe(5); + }); + + it('does not change availableSeats when totalSeats decreased', async () => { + findWorkspaceByIdProvider.findById.mockResolvedValue({ + ...existingWorkspace, + }); + workspacesRepository.save.mockImplementation((ws) => Promise.resolve(ws)); + + const result = await provider.update('ws-1', { totalSeats: 5 } as any); + + expect(result.availableSeats).toBe(5); // unchanged since decreased + }); +}); diff --git a/backend/src/workspaces/workspaces.controller.spec.ts b/backend/src/workspaces/workspaces.controller.spec.ts new file mode 100644 index 00000000..90af3d45 --- /dev/null +++ b/backend/src/workspaces/workspaces.controller.spec.ts @@ -0,0 +1,153 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WorkspacesController } from './workspaces.controller'; +import { WorkspacesService } from './workspaces.service'; +import { WorkspaceType } from './enums/workspace-type.enum'; + +describe('WorkspacesController', () => { + let controller: WorkspacesController; + let service: jest.Mocked>; + + beforeEach(async () => { + service = { + create: jest.fn(), + findAll: jest.fn(), + findById: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + checkAvailability: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [WorkspacesController], + providers: [{ provide: WorkspacesService, useValue: service }], + }).compile(); + + controller = module.get(WorkspacesController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('create', () => { + it('delegates to service and returns wrapped response', async () => { + const dto = { + name: 'Hot Desk A', + type: WorkspaceType.HOT_DESK, + totalSeats: 10, + hourlyRate: 50000, + }; + service.create.mockResolvedValue({ id: 'ws-1', ...dto } as any); + + const result = await controller.create(dto); + expect(result).toEqual({ + message: 'Workspace created successfully', + data: { id: 'ws-1', ...dto }, + }); + expect(service.create).toHaveBeenCalledWith(dto); + }); + }); + + describe('findAll', () => { + it('returns public workspaces with message and result spread', async () => { + const paginated = { + data: [{ id: 'ws-1', name: 'Hot Desk A' }], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + } as any; + service.findAll.mockResolvedValue(paginated); + + const result = await controller.findAll({}); + expect(result).toEqual({ + message: 'Workspaces retrieved successfully', + ...paginated, + }); + expect(service.findAll).toHaveBeenCalledWith({}); + }); + }); + + describe('findAllAdmin', () => { + it('passes adminView=true to service', async () => { + service.findAll.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + } as any); + + const result = await controller.findAllAdmin({}); + expect(result.message).toBe('Workspaces retrieved successfully'); + expect(service.findAll).toHaveBeenCalledWith({}, true); + }); + }); + + describe('findOne', () => { + it('returns workspace by id with wrapped response', async () => { + service.findById.mockResolvedValue({ + id: 'ws-1', + name: 'Hot Desk A', + } as any); + + const result = await controller.findOne('ws-1'); + expect(result).toEqual({ + message: 'Workspace retrieved successfully', + data: { id: 'ws-1', name: 'Hot Desk A' }, + }); + expect(service.findById).toHaveBeenCalledWith('ws-1'); + }); + }); + + describe('checkAvailability', () => { + it('checks availability with default 1 seat', async () => { + service.checkAvailability.mockResolvedValue({ + available: true, + availableSeats: 5, + totalSeats: 10, + }); + + const result = await controller.checkAvailability('ws-1'); + expect(result.message).toBe('Availability checked'); + expect(service.checkAvailability).toHaveBeenCalledWith('ws-1', 1); + }); + + it('parses seats query param as number', async () => { + service.checkAvailability.mockResolvedValue({ + available: true, + availableSeats: 5, + totalSeats: 10, + }); + + await controller.checkAvailability('ws-1', 3 as any); + expect(service.checkAvailability).toHaveBeenCalledWith('ws-1', 3); + }); + }); + + describe('update', () => { + it('updates workspace and returns wrapped response', async () => { + const dto = { name: 'Updated' } as any; + service.update.mockResolvedValue({ id: 'ws-1', name: 'Updated' } as any); + + const result = await controller.update('ws-1', dto); + expect(result).toEqual({ + message: 'Workspace updated successfully', + data: { id: 'ws-1', name: 'Updated' }, + }); + expect(service.update).toHaveBeenCalledWith('ws-1', dto); + }); + }); + + describe('remove', () => { + it('soft-deletes workspace and returns wrapped response', async () => { + service.softDelete.mockResolvedValue(undefined); + + const result = await controller.remove('ws-1'); + expect(result).toEqual({ + message: 'Workspace deactivated successfully', + }); + expect(service.softDelete).toHaveBeenCalledWith('ws-1'); + }); + }); +}); diff --git a/backend/src/workspaces/workspaces.service.spec.ts b/backend/src/workspaces/workspaces.service.spec.ts new file mode 100644 index 00000000..e480a4ae --- /dev/null +++ b/backend/src/workspaces/workspaces.service.spec.ts @@ -0,0 +1,153 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WorkspacesService } from './workspaces.service'; +import { CreateWorkspaceProvider } from './providers/create-workspace.provider'; +import { FindAllWorkspacesProvider } from './providers/find-all-workspaces.provider'; +import { FindWorkspaceByIdProvider } from './providers/find-workspace-by-id.provider'; +import { UpdateWorkspaceProvider } from './providers/update-workspace.provider'; +import { DeleteWorkspaceProvider } from './providers/delete-workspace.provider'; +import { CheckWorkspaceAvailabilityProvider } from './providers/check-workspace-availability.provider'; +import { WorkspaceType } from './enums/workspace-type.enum'; + +describe('WorkspacesService', () => { + let service: WorkspacesService; + let createProvider: jest.Mocked>; + let findAllProvider: jest.Mocked>; + let findByIdProvider: jest.Mocked>; + let updateProvider: jest.Mocked>; + let deleteProvider: jest.Mocked>; + let checkAvailabilityProvider: jest.Mocked< + Partial + >; + + beforeEach(async () => { + createProvider = { create: jest.fn() }; + findAllProvider = { findAll: jest.fn() }; + findByIdProvider = { findById: jest.fn() }; + updateProvider = { update: jest.fn() }; + deleteProvider = { softDelete: jest.fn() }; + checkAvailabilityProvider = { check: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspacesService, + { provide: CreateWorkspaceProvider, useValue: createProvider }, + { provide: FindAllWorkspacesProvider, useValue: findAllProvider }, + { provide: FindWorkspaceByIdProvider, useValue: findByIdProvider }, + { provide: UpdateWorkspaceProvider, useValue: updateProvider }, + { provide: DeleteWorkspaceProvider, useValue: deleteProvider }, + { + provide: CheckWorkspaceAvailabilityProvider, + useValue: checkAvailabilityProvider, + }, + ], + }).compile(); + + service = module.get(WorkspacesService); + }); + + describe('create', () => { + it('delegates to CreateWorkspaceProvider', async () => { + const dto = { + name: 'Hot Desk A', + type: WorkspaceType.HOT_DESK, + totalSeats: 10, + hourlyRate: 50000, + }; + createProvider.create.mockResolvedValue({ id: 'ws-1', ...dto } as any); + + const result = await service.create(dto); + expect(result).toEqual({ id: 'ws-1', ...dto }); + expect(createProvider.create).toHaveBeenCalledWith(dto); + }); + }); + + describe('findAll', () => { + it('delegates to FindAllWorkspacesProvider', async () => { + findAllProvider.findAll.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }); + + const result = await service.findAll({}); + expect(result.total).toBe(0); + expect(findAllProvider.findAll).toHaveBeenCalledWith({}, false); + }); + + it('passes adminView flag when true', async () => { + findAllProvider.findAll.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }); + + await service.findAll({}, true); + expect(findAllProvider.findAll).toHaveBeenCalledWith({}, true); + }); + }); + + describe('findById', () => { + it('delegates to FindWorkspaceByIdProvider', async () => { + findByIdProvider.findById.mockResolvedValue({ id: 'ws-1' } as any); + + const result = await service.findById('ws-1'); + expect(result).toEqual({ id: 'ws-1' }); + expect(findByIdProvider.findById).toHaveBeenCalledWith('ws-1'); + }); + }); + + describe('update', () => { + it('delegates to UpdateWorkspaceProvider', async () => { + const dto = { name: 'Updated' } as any; + updateProvider.update.mockResolvedValue({ + id: 'ws-1', + name: 'Updated', + } as any); + + const result = await service.update('ws-1', dto); + expect(result).toEqual({ id: 'ws-1', name: 'Updated' }); + expect(updateProvider.update).toHaveBeenCalledWith('ws-1', dto); + }); + }); + + describe('softDelete', () => { + it('delegates to DeleteWorkspaceProvider', async () => { + deleteProvider.softDelete.mockResolvedValue(undefined); + + await service.softDelete('ws-1'); + expect(deleteProvider.softDelete).toHaveBeenCalledWith('ws-1'); + }); + }); + + describe('checkAvailability', () => { + it('delegates to CheckWorkspaceAvailabilityProvider', async () => { + checkAvailabilityProvider.check.mockResolvedValue({ + available: true, + availableSeats: 5, + totalSeats: 10, + }); + + const result = await service.checkAvailability('ws-1', 3); + expect(result.available).toBe(true); + expect(checkAvailabilityProvider.check).toHaveBeenCalledWith('ws-1', 3); + }); + + it('defaults to 1 seat when not specified', async () => { + checkAvailabilityProvider.check.mockResolvedValue({ + available: true, + availableSeats: 1, + totalSeats: 1, + }); + + await service.checkAvailability('ws-1'); + expect(checkAvailabilityProvider.check).toHaveBeenCalledWith( + 'ws-1', + undefined, + ); + }); + }); +}); diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..05a8164b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,50 @@ +--- +codecov: + require_ci_to_pass: false + notify: + wait_for_ci: true + +coverage: + status: + project: + default: + target: auto + threshold: 5% + base: auto + contracts: + target: 80% + threshold: 5% + flags: + - contracts + backend: + target: 80% + threshold: 5% + flags: + - backend + frontend: + target: 20% + threshold: 5% + flags: + - frontend + patch: + default: + target: 50% + +flags: + contracts: + paths: + - contracts/ + carryforward: false + backend: + paths: + - backend/src/ + carryforward: false + frontend: + paths: + - frontend/ + carryforward: false + +comment: + layout: "reach, diff, flags, files" + behavior: default + require_changes: false diff --git a/contracts/resource_credits/src/lib.rs b/contracts/resource_credits/src/lib.rs index 53d7ecc6..dc914a40 100644 --- a/contracts/resource_credits/src/lib.rs +++ b/contracts/resource_credits/src/lib.rs @@ -6,6 +6,9 @@ mod errors; mod types; +#[cfg(test)] +mod test; + use errors::Error; use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env}; diff --git a/contracts/resource_credits/src/test.rs b/contracts/resource_credits/src/test.rs new file mode 100644 index 00000000..e093c775 --- /dev/null +++ b/contracts/resource_credits/src/test.rs @@ -0,0 +1,345 @@ +// contracts/resource_credits/src/test.rs +// +// Tests for the `resource_credits` Soroban contract. +// +// The CI coverage gate (`.github/workflows/CI.yaml` :: coverage) requires at +// least 80% line coverage per contract crate. This suite exercises every +// public method and every `Error` variant in `errors.rs` so the crate clears +// the threshold. +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +// ── Initialisation ──────────────────────────────────────────────────────────── + +#[test] +fn test_initialize_success() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + + client.initialize(&admin, &token); + + assert_eq!(client.total_supply(), 0u128); + assert_eq!(client.balance(&Address::generate(&env)), 0u128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] +fn test_initialize_twice_fails() { + // AlreadyInitialized = 2 + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + + client.initialize(&admin, &token); + client.initialize(&admin, &token); +} + +// ── Mint ────────────────────────────────────────────────────────────────────── + +#[test] +fn test_mint_credits_increases_balance_and_supply() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let recipient = Address::generate(&env); + + client.initialize(&admin, &token); + + client.mint_credits(&admin, &recipient, &500u128); + + assert_eq!(client.balance(&recipient), 500u128); + assert_eq!(client.total_supply(), 500u128); +} + +#[test] +fn test_mint_credits_accumulates_balance() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let recipient = Address::generate(&env); + + client.initialize(&admin, &token); + client.mint_credits(&admin, &recipient, &100u128); + client.mint_credits(&admin, &recipient, &250u128); + + assert_eq!(client.balance(&recipient), 350u128); + assert_eq!(client.total_supply(), 350u128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_mint_credits_zero_amount_fails() { + // InvalidAmount = 5 + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let recipient = Address::generate(&env); + + client.initialize(&admin, &token); + client.mint_credits(&admin, &recipient, &0u128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_mint_credits_non_admin_fails() { + // Unauthorized = 3 + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let recipient = Address::generate(&env); + let imposter = Address::generate(&env); + + client.initialize(&admin, &token); + client.mint_credits(&imposter, &recipient, &100u128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] +fn test_mint_credits_before_initialize_fails() { + // AdminNotSet = 1 — `get_admin` returns None because initialize was never called + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + + client.mint_credits(&admin, &recipient, &100u128); +} + +// ── Transfer ───────────────────────────────────────────────────────────────── + +#[test] +fn test_transfer_credits_moves_balance() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let from = Address::generate(&env); + let to = Address::generate(&env); + + client.initialize(&admin, &token); + client.mint_credits(&admin, &from, &1_000u128); + + client.transfer_credits(&from, &to, &400u128); + + assert_eq!(client.balance(&from), 600u128); + assert_eq!(client.balance(&to), 400u128); + // Supply must remain unchanged after a transfer + assert_eq!(client.total_supply(), 1_000u128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_transfer_credits_zero_amount_fails() { + // InvalidAmount = 5 + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let from = Address::generate(&env); + let to = Address::generate(&env); + + client.initialize(&admin, &token); + client.mint_credits(&admin, &from, &1_000u128); + + client.transfer_credits(&from, &to, &0u128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_transfer_credits_insufficient_balance_fails() { + // InsufficientBalance = 4 + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let from = Address::generate(&env); + let to = Address::generate(&env); + + client.initialize(&admin, &token); + client.mint_credits(&admin, &from, &50u128); + + // Try to transfer more than balance + client.transfer_credits(&from, &to, &100u128); +} + +// ── Spend ──────────────────────────────────────────────────────────────────── + +#[test] +fn test_spend_credits_decreases_balance_and_supply() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let member = Address::generate(&env); + + client.initialize(&admin, &token); + client.mint_credits(&admin, &member, &800u128); + + client.spend_credits(&member, &300u128); + + assert_eq!(client.balance(&member), 500u128); + assert_eq!(client.total_supply(), 500u128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_spend_credits_zero_amount_fails() { + // InvalidAmount = 5 + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let member = Address::generate(&env); + + client.initialize(&admin, &token); + client.mint_credits(&admin, &member, &100u128); + + client.spend_credits(&member, &0u128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_spend_credits_insufficient_balance_fails() { + // InsufficientBalance = 4 + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let member = Address::generate(&env); + + client.initialize(&admin, &token); + client.mint_credits(&admin, &member, &10u128); + + client.spend_credits(&member, &50u128); +} + +// ── Queries ────────────────────────────────────────────────────────────────── + +#[test] +fn test_balance_default_zero() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + + client.initialize(&admin, &token); + + // Querying a fresh address returns 0 + let stranger = Address::generate(&env); + assert_eq!(client.balance(&stranger), 0u128); +} + +#[test] +fn test_total_supply_default_zero() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + + client.initialize(&admin, &token); + + assert_eq!(client.total_supply(), 0u128); +} + +// ── End-to-end flow ────────────────────────────────────────────────────────── + +#[test] +fn test_full_mint_transfer_spend_flow() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ResourceCreditsContract, ()); + let client = ResourceCreditsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + client.initialize(&admin, &token); + + // Admin mints to Alice and Bob + client.mint_credits(&admin, &alice, &1_000u128); + client.mint_credits(&admin, &bob, &500u128); + assert_eq!(client.total_supply(), 1_500u128); + + // Alice transfers to Bob + client.transfer_credits(&alice, &bob, &200u128); + assert_eq!(client.balance(&alice), 800u128); + assert_eq!(client.balance(&bob), 700u128); + assert_eq!(client.total_supply(), 1_500u128); // unchanged by transfer + + // Bob spends credits + client.spend_credits(&bob, &100u128); + assert_eq!(client.balance(&bob), 600u128); + assert_eq!(client.total_supply(), 1_400u128); +} \ No newline at end of file diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index 926f2811..8fe02d6c 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -9,17 +9,38 @@ This document covers the biometric check-in flow for workspace attendance. The g - The application should minimize persistence, reduce retention, and avoid storing reusable biometric rows in relational storage. ## Security Controls -1. Raw biometric data is rejected at the API boundary. -2. Only one of the following is accepted: - - a cryptographic hash of a biometric template, or - - an opaque storage reference to a vendor-managed system. -3. Processing should prefer local on-device handling whenever possible. -4. A storage audit report is available for administrators to review the current policy posture. + +1. **Raw biometric data is rejected at the API boundary.** + The `CheckInProvider.validateBiometricPrivacy()` method rejects known raw-biometric field names (`biometricTemplate`, `biometricTemplateData`, `biometricSample`, `rawBiometricTemplate`, `fingerprintTemplate`, `faceTemplate`) with a `400 Bad Request` before any data reaches the database. + +2. **Only privacy-safe values are accepted.** + The API accepts exactly one of: + - `biometricTemplateHash` — a cryptographic hash (max 128 chars) of a biometric template, or + - `biometricStorageReference` — an opaque reference (max 255 chars) to a template stored in an external vendor-managed system. + +3. **Processing location is tracked.** + The `biometricProcessingLocation` field (`'local'` | `'vendor'`) records where template matching occurs. When set to `'vendor'`, the `biometricVendor` field must also be provided. + +4. **Local on-device processing is preferred.** + The `biometricProcessingLocation` default is `'local'`, indicating templates should be processed and matched on the user's device where possible. + +5. **A storage audit report is available for administrators.** + The `CheckInProvider.getStorageAuditSummary()` method returns a structured report with counts of total logs, logs with hashed templates, and logs with opaque storage references. It enforces a `rawBiometricRows: 0` guarantee — any non-zero value would indicate a policy violation. ## Data Handling Policy + - No raw biometric rows are stored in the application database. -- The database stores only privacy-safe markers and metadata needed for attendance workflows. -- If a vendor is used, the application records only the opaque reference and vendor name. +- The `workspace_logs` table stores only these privacy-safe columns: + + | Column | Type | Max length | Purpose | + |--------|------|:----------:|---------| + | `biometricTemplateHash` | `varchar` | 128 | Store a cryptographic hash of the template | + | `biometricStorageReference` | `varchar` | 255 | Opaque reference to a vendor-managed template | + | `biometricProcessingLocation` | `varchar` | 32 | `'local'` or `'vendor'` — where matching occurs | + | `biometricVendor` | `varchar` | 64 | Vendor name when templates are stored externally | + +- If a vendor is used, the application records only the opaque reference (`biometricStorageReference`) and vendor name (`biometricVendor`). The raw template is never transmitted to the application database. +- The `biometricProcessingLocation` field documents which party performs template matching — `'local'` (on-device) or `'vendor'` (external service). This provenance is recorded for audit and compliance purposes. ## References - NIST SP 800-63B: Digital Identity Guidelines: Authentication and Lifecycle Management diff --git a/docs/onboarding-guide.md b/docs/onboarding-guide.md index aadccd0c..563e67dd 100644 --- a/docs/onboarding-guide.md +++ b/docs/onboarding-guide.md @@ -1,20 +1,434 @@ # Developer Onboarding Guide -## First Day Checklist -1. Clone the repo: `git clone https://github.com/NovaCoreLabs1/NovaLabs.git` -2. Install deps: `npm install` -3. Copy env: `cp .env.example .env` -4. Run dev: `npm run dev` -5. Run tests: `npm test` - -## Code Style -- TypeScript strict mode -- ESLint + Prettier -- Conventional commits - -## PR Process -1. Create a branch from `main` -2. Make changes -3. Run `npm run lint` and `npm test` -4. Submit PR with description -5. Wait for review \ No newline at end of file +Welcome to NovaLabs! This guide walks you through setting up the project on your local machine, understanding the monorepo structure, and following our development workflow. + +--- + +## Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [Repository Structure](#2-repository-structure) +3. [First-Time Setup](#3-first-time-setup) +4. [Running the Project](#4-running-the-project) +5. [Running Tests](#5-running-tests) +6. [Common Development Tasks](#6-common-development-tasks) +7. [Git & Commit Convention](#7-git--commit-convention) +8. [Pull Request Process](#8-pull-request-process) +9. [Troubleshooting](#9-troubleshooting) + +--- + +## 1. Prerequisites + +| Tool | Version | Notes | +|------|---------|-------| +| **Node.js** | ≥ 18.x (recommended: 20 LTS) | Required for backend & frontend | +| **npm** | ≥ 9.x | Shipped with Node.js | +| **PostgreSQL** | ≥ 14 | Database for the backend | +| **Rust toolchain** | stable | Required only for `contracts/` | + +Optional but recommended: + +- **Redis** — for Bull job queues and caching (backend uses it for background jobs) +- **Stellar CLI** (`stellar`) — for Soroban contract interaction + +--- + +## 2. Repository Structure + +NovaLabs is a **monorepo** with three independent subprojects: + +``` +NovaLabs/ +├── frontend/ # Next.js 15 + Tailwind CSS v4 + TanStack Query +│ ├── app/ # App Router pages & layouts +│ ├── components/ # Reusable UI components (shadcn-style) +│ ├── lib/ # React Query hooks, store, utilities +│ ├── providers/ # React context providers +│ └── __tests__/ # Vitest test files +│ +├── backend/ # NestJS 10 + TypeORM + PostgreSQL +│ ├── src/ +│ │ ├── auth/ # JWT auth, 2FA, OTP +│ │ ├── users/ # User management & roles +│ │ ├── bookings/ # Workspace booking logic +│ │ ├── invoices/ # Invoice generation & PDF +│ │ ├── payments/ # Payment processing (Paystack, Soroban) +│ │ ├── email/ # Transactional email templates +│ │ └── ... # newsletters, contact, notifications, etc. +│ ├── docs/ # Data model documentation +│ └── test/ # E2E test configuration +│ +├── contracts/ # Rust smart contracts (Soroban / Stellar) +│ ├── access_control/ +│ ├── manage_hub/ +│ ├── workspace_booking/ +│ ├── membership_token/ +│ ├── payment_escrow/ +│ └── resource_credits/ +│ +└── .github/ # CI/CD workflows, PR templates +``` + +Each subproject has its own `package.json` / `Cargo.toml` and dependencies — **there is no root-level `package.json`**. + +--- + +## 3. First-Time Setup + +### 3.1 Clone the Repository + +```bash +git clone https://github.com/NovaCoreLabs1/NovaLabs.git +cd NovaLabs +``` + +### 3.2 Install Dependencies + +Each subproject installs independently: + +```bash +# Backend (NestJS) +cd backend +npm install +cd .. + +# Frontend (Next.js) +cd frontend +npm install +cd .. + +# Contracts (Rust) — only if working on smart contracts +cd contracts +cargo build --all +cd .. +``` + +### 3.3 Configure Environment Variables + +There are no committed `.env.example` files, so create your own by copying the template below. + +**Backend** — create `backend/.env`: + +```bash +touch backend/.env +``` + +Key variables to configure: + +| Variable | Description | Default | +|----------|-------------|---------| +| `PORT` | Backend server port | `6000` | +| `NODE_ENV` | Environment mode | `development` | +| `DATABASE_HOST` | PostgreSQL host | `localhost` | +| `DATABASE_PORT` | PostgreSQL port | `5432` | +| `DATABASE_USERNAME` | PostgreSQL username | `postgres` | +| `DATABASE_PASSWORD` | PostgreSQL password | `password` | +| `DATABASE_NAME` | PostgreSQL database name | `novalabs` | +| `JWT_SECRET` | Secret key for JWT signing | (generate a random string) | +| `JWT_EXPIRATION` | JWT token expiry | `15m` | +| `JWT_REFRESH_EXPIRATION` | Refresh token expiry | `7d` | +| `FRONTEND_URL` | Frontend base URL | `http://localhost:3000` | +| `SMTP_HOST` | SMTP server for email | (optional for dev) | +| `SMTP_PORT` | SMTP port | `587` | +| `SMTP_USER` | SMTP username | | +| `SMTP_PASS` | SMTP password | | +| `CLOUDINARY_CLOUD_NAME` | Cloudinary config | (optional for dev) | +| `CLOUDINARY_API_KEY` | Cloudinary config | | +| `CLOUDINARY_API_SECRET` | Cloudinary config | | +| `REDIS_HOST` | Redis host | `localhost` | +| `REDIS_PORT` | Redis port | `6379` | + +> **Local dev tip:** The backend tests mock external services (email, Cloudinary, Paystack), so you don't need real credentials to run the test suite. + +**Frontend** — create `frontend/.env.local`: + +```bash +touch frontend/.env.local +``` + +Key variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `NEXT_PUBLIC_API_URL` | Backend API base URL | `http://localhost:6000/api` | +| `NEXT_PUBLIC_APP_URL` | Frontend app URL | `http://localhost:3000` | + +### 3.4 Set Up the Database + +Make sure PostgreSQL is running, then run migrations: + +```bash +cd backend + +# Run pending migrations +npm run typeorm:run-migrations + +# (Optional) Seed demo data +npm run demo:seed +``` + +--- + +## 4. Running the Project + +You'll need **two terminal windows** — one for the backend, one for the frontend. + +### Backend (API) + +```bash +cd backend +npm run start:dev # watch mode (auto-restarts on changes) +``` + +The API starts at **http://localhost:6000**. +Swagger docs are available at **http://localhost:6000/swagger**. + +### Frontend (UI) + +```bash +cd frontend +npm run dev # Next.js dev server with Turbopack +``` + +The UI starts at **http://localhost:3000**. + +### Contracts (Rust) — Optional + +```bash +cd contracts +cargo build --all # Build all Soroban contracts +cargo test --all # Run contract tests +``` + +--- + +## 5. Running Tests + +### Backend (Jest) + +```bash +cd backend + +npm test # Run all unit tests +npm run test:cov # Run with coverage report +npm run test:e2e # Run end-to-end tests +``` + +**Coverage target:** ≥ 80% + +### Frontend (Vitest) + +```bash +cd frontend + +npm test # Run all tests +npm run test:watch # Watch mode +npm run test:cov # Run with coverage report +``` + +**Coverage target:** ≥ 20% + +### Contracts (Rust / cargo) + +```bash +cd contracts + +cargo test --all # Run all contract tests +cargo tarpaulin --all # Coverage (requires cargo-tarpaulin) +``` + +**Coverage target:** ≥ 80% per crate + +--- + +## 6. Common Development Tasks + +### Database Migrations + +```bash +cd backend + +# Generate a new migration from entity changes +npm run typeorm:generate-migration --name=MigrationName + +# Create a blank migration +npm run typeorm:create-migration --name=MigrationName + +# Apply pending migrations +npm run typeorm:run-migrations + +# Revert the last migration +npm run typeorm:revert-migration +``` + +### Regenerate ER Diagram + +After changing entities, regenerate the data model documentation: + +```bash +cd backend +npm run db:diagram +``` + +This runs `erdia` and outputs to `backend/docs/erd/`. See [CONTRIBUTING.md](../CONTRIBUTING.md) for the data-model sync rule. + +### Linting & Formatting + +```bash +# Backend +cd backend && npm run lint + +# Frontend +cd frontend && npm run lint +``` + +### Demo Data + +```bash +cd backend + +npm run demo:seed # Seed demo data into the database +npm run demo:clear # Clear all demo data +npm run demo:info # Show info about seeded data +``` + +--- + +## 7. Git & Commit Convention + +This project uses **Conventional Commits** enforced by CommitLint. + +### Allowed Commit Types + +| Type | Usage | +|------|-------| +| `feat` | A new feature | +| `fix` | A bug fix | +| `docs` | Documentation-only changes | +| `style` | Code style / formatting (no logic change) | +| `refactor` | Code restructuring (no feature/bug change) | +| `test` | Adding or updating tests | +| `chore` | Build tooling, CI, maintenance | +| `ci` | CI/CD configuration changes | + +### Commit Format + +``` +type(scope): short description + +Optional longer body explaining the change. +``` + +**Examples:** + +``` +feat(auth): add 2FA setup endpoint +fix(bookings): handle overlapping booking validation +test(users): add unit tests for member queries +chore(deps): upgrade next.js to 15.5.9 +``` + +--- + +## 8. Pull Request Process + +1. **Create a branch** from `main`: + ```bash + git checkout -b feat/your-feature-name + ``` + +2. **Make your changes** and commit using the conventional format. + +3. **Run the PR checklist** before pushing: + ```bash + cd backend && npm run lint && npm run build + cd frontend && npm run lint && npm run build + ``` + +4. **Push your branch** and open a PR: + ```bash + git push origin feat/your-feature-name + ``` + +5. **Fill out the PR template** — include the linked issue, description, and test plan. + +6. **Wait for CI** — the pipeline runs: + - Format check (contracts) + - Clippy lint (contracts) + - Backend lint, build, unit tests, coverage + - Frontend lint, tests, coverage, build + - Contract tests & coverage + - Data model sync check + - ER diagram generation check + +See [`CONTRIBUTING.md`](../CONTRIBUTING.md) for the full contribution checklist and coding conventions. + +--- + +## 9. Troubleshooting + +### "Module not found" errors + +Ensure you installed dependencies in the correct subdirectory: + +```bash +cd backend && npm install +cd frontend && npm install +``` + +### "Connection refused" for PostgreSQL + +Make sure PostgreSQL is running and the credentials in `backend/.env` are correct: + +```bash +# Check if PostgreSQL is listening +pg_isready -h localhost -p 5432 +``` + +### "JWT_SECRET is not set" + +Add a random secret to `backend/.env`: + +``` +JWT_SECRET=your-random-secret-here +``` + +You can generate one with: + +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +### "cache-manager" or "redis" errors + +Redis is optional for basic development, but some background-job features (Bull queues) require it. If Redis isn't available, you can: + +- Disable Bull module registration in the relevant NestJS modules +- Or install and start Redis locally: + ```bash + # macOS + brew install redis && brew services start redis + # Linux + sudo apt install redis-server && sudo systemctl start redis + ``` + +### Tests fail with "SMTP connection failed" + +Email tests expect SMTP. If you don't have an SMTP server configured: + +- The email service gracefully handles this in dev mode (it logs a warning) +- Set `SMTP_HOST` and `SMTP_PORT` to dummy values in `.env` to silence warnings: + ``` + SMTP_HOST=localhost + SMTP_PORT=1025 + ``` + +--- + +## Need Help? + +- Open an issue on [GitHub](https://github.com/NovaCoreLabs1/NovaLabs/issues) +- Check the [README](../README.md) for a project overview +- Review [CONTRIBUTING.md](../CONTRIBUTING.md) for coding conventions +- See the [Data Model](../backend/docs/data-model.md) for PostgreSQL schema documentation diff --git a/frontend/__tests__/components/ui/Alert.test.tsx b/frontend/__tests__/components/ui/Alert.test.tsx new file mode 100644 index 00000000..0b801077 --- /dev/null +++ b/frontend/__tests__/components/ui/Alert.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import Alert from '@/components/ui/Alert'; + +describe('Alert', () => { + it('renders children content', () => { + render(This is an alert); + expect(screen.getByText('This is an alert')).toBeDefined(); + }); + + it('renders title when provided', () => { + render(Something happened); + expect(screen.getByText('Warning')).toBeDefined(); + }); + + it('renders icon when provided', () => { + render(🔔}>With icon); + expect(screen.getByText('🔔')).toBeDefined(); + }); + + it('renders empty h3 when title is not provided', () => { + render(No title); + const heading = screen.queryByRole('heading'); + expect(heading).toBeDefined(); + expect(heading?.textContent).toBe(''); + }); +}); diff --git a/frontend/__tests__/components/ui/BreadCrumb.test.tsx b/frontend/__tests__/components/ui/BreadCrumb.test.tsx new file mode 100644 index 00000000..e466bee8 --- /dev/null +++ b/frontend/__tests__/components/ui/BreadCrumb.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import BreadCrumb from '@/components/ui/BreadCrumb'; + +describe('BreadCrumb', () => { + const mockLinks = [ + { label: 'Home', href: '/' }, + { label: 'Dashboard', href: '/dashboard' }, + { label: 'Settings' }, // no href = current page + ]; + + it('renders all breadcrumb links', () => { + render(); + expect(screen.getByText('Home')).toBeDefined(); + expect(screen.getByText('Dashboard')).toBeDefined(); + expect(screen.getByText('Settings')).toBeDefined(); + }); + + it('renders nav with aria-label=\"Breadcrumb\"', () => { + render(); + const nav = screen.getByLabelText('Breadcrumb'); + expect(nav).toBeDefined(); + }); + + it('renders the last item as a non-link span (current page)', () => { + render(); + const settings = screen.getByText('Settings'); + expect(settings.tagName).toBe('SPAN'); + }); + + it('renders first items as links', () => { + render(); + const home = screen.getByText('Home'); + expect(home.tagName).toBe('A'); + }); +}); diff --git a/frontend/__tests__/components/ui/Checkbox.test.tsx b/frontend/__tests__/components/ui/Checkbox.test.tsx new file mode 100644 index 00000000..9d5b8aa1 --- /dev/null +++ b/frontend/__tests__/components/ui/Checkbox.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Checkbox } from '@/components/ui/Checkbox'; + +describe('Checkbox', () => { + it('renders as a checkbox input', () => { + render(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeDefined(); + }); + + it('can be checked and unchecked', () => { + render(); + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(true); + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(false); + }); + + it('applies custom className', () => { + render(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox.className).toContain('custom-class'); + }); +}); diff --git a/frontend/__tests__/components/ui/CountDownTimer.test.tsx b/frontend/__tests__/components/ui/CountDownTimer.test.tsx new file mode 100644 index 00000000..400957ba --- /dev/null +++ b/frontend/__tests__/components/ui/CountDownTimer.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { CountdownTimer } from '@/components/ui/CountDownTimer'; + +describe('CountdownTimer', () => { + it('renders all four countdown units', () => { + const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days from now + render(); + expect(screen.getByText('Days')).toBeDefined(); + expect(screen.getByText('Hours')).toBeDefined(); + expect(screen.getByText('Minutes')).toBeDefined(); + expect(screen.getByText('Seconds')).toBeDefined(); + }); + + it('renders "Launching In" heading', () => { + const futureDate = new Date(Date.now() + 86400000); + render(); + expect(screen.getByText('Launching In')).toBeDefined(); + }); + + it('handles past dates by showing all zeros', () => { + const pastDate = new Date('2020-01-01'); + render(); + const zeros = screen.getAllByText('00'); + expect(zeros.length).toBe(4); // days, hours, minutes, seconds + }); +}); diff --git a/frontend/__tests__/components/ui/FeatureCard.test.tsx b/frontend/__tests__/components/ui/FeatureCard.test.tsx new file mode 100644 index 00000000..cb8f8991 --- /dev/null +++ b/frontend/__tests__/components/ui/FeatureCard.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Zap } from 'lucide-react'; +import FeatureCard from '@/components/ui/FeatureCard'; + +describe('FeatureCard', () => { + it('renders title and description', () => { + render(); + expect(screen.getByText('Fast')).toBeDefined(); + expect(screen.getByText('Lightning quick performance')).toBeDefined(); + }); + + it('renders with custom className', () => { + const { container } = render( + + ); + const div = container.firstChild as HTMLElement; + expect(div.className).toContain('custom-card'); + }); + + it('renders the icon', () => { + const { container } = render(); + // Lucide icons render SVG elements + const svg = container.querySelector('svg'); + expect(svg).toBeDefined(); + }); +}); diff --git a/frontend/__tests__/components/ui/FeaturesSection.test.tsx b/frontend/__tests__/components/ui/FeaturesSection.test.tsx new file mode 100644 index 00000000..98d362ba --- /dev/null +++ b/frontend/__tests__/components/ui/FeaturesSection.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import FeaturesSection from '@/components/ui/FeaturesSection'; + +describe('FeaturesSection', () => { + it('renders the section heading', () => { + render(); + expect(screen.getByText(/Everything you need/)).toBeDefined(); + }); + + it('renders the subheading', () => { + render(); + expect(screen.getByText(/Built for coworking/)).toBeDefined(); + }); + + it('renders all six feature cards', () => { + render(); + expect(screen.getByText('Team management')).toBeDefined(); + expect(screen.getByText('Real-time analytics')).toBeDefined(); + expect(screen.getByText('Access control')).toBeDefined(); + expect(screen.getByText('Mobile ready')).toBeDefined(); + expect(screen.getByText('Automated billing')).toBeDefined(); + expect(screen.getByText('Multi-location')).toBeDefined(); + }); + + it('renders feature descriptions', () => { + render(); + expect(screen.getByText(/Roles, permissions/)).toBeDefined(); + expect(screen.getByText(/Occupancy, revenue/)).toBeDefined(); + }); + + it('has features id for anchor linking', () => { + const { container } = render(); + const section = container.querySelector('#features'); + expect(section).toBeDefined(); + }); +}); diff --git a/frontend/__tests__/components/ui/Footer.test.tsx b/frontend/__tests__/components/ui/Footer.test.tsx new file mode 100644 index 00000000..68de7139 --- /dev/null +++ b/frontend/__tests__/components/ui/Footer.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import Footer from '@/components/ui/Footer'; + +describe('Footer', () => { + it('renders the brand name', () => { + render(