diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6d4b444..1354316 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,6 +1,6 @@ { "permissions": { - "allow": ["Bash(pnpm run lint*)"], + "allow": ["Bash(pnpm run test:*)", "Bash(pnpm run lint:*)"], "deny": [], "ask": [] } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..825400a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,86 @@ +name: Test Suite + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + permissions: + contents: read + pull-requests: write + + services: + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: catalog_test + ports: + - 3307:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + opensearch: + image: opensearchproject/opensearch:3 + env: + cluster.name: test-opensearch-cluster + node.name: test-opensearch + discovery.type: single-node + bootstrap.memory_lock: true + OPENSEARCH_JAVA_OPTS: -Xms256m -Xmx256m + DISABLE_SECURITY_PLUGIN: true + ports: + - 9201:9200 + options: >- + --health-cmd="curl -f http://localhost:9200/_cluster/health || exit 1" + --health-interval=30s + --health-timeout=10s + --health-retries=3 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm run generate + + - name: Run database migrations + run: pnpm run db:migrate + env: + DATABASE_URL: mysql://root:password@localhost:3307/catalog_test + + - name: Run linting + run: | + pnpm run lint:biome + pnpm run lint:types + + - name: Run unit tests + run: pnpm run test + env: + DATABASE_URL: mysql://root:password@localhost:3307/catalog_test + OPENSEARCH_URL: http://localhost:9201 + NODE_ENV: test + + - name: Report Coverage + if: always() + uses: davelosert/vitest-coverage-report-action@v2 diff --git a/.gitignore b/.gitignore index e1f4658..e6268ba 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ dist # prisma src/generated/prisma/ + +coverage diff --git a/.knip.jsonc b/.knip.jsonc index b97efaa..3db5271 100644 --- a/.knip.jsonc +++ b/.knip.jsonc @@ -1,28 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema-jsonc.json", - "entry": [ - "src/index.ts" - // // CDK - // "bin/paragest.ts", - // "lib/paragest-stack.ts", - // "reset.d.ts", - // - // // Our scripts - // "bin/list-failures.mts", - // "bin/replay-s3-event.mts", - // - // // Lambdas - // "src/tsconfig.json", - // "src/*.ts", - // "src/common/*.ts", - // "src/cron/*.ts", - // "src/audio/*.ts", - // "src/image/*.ts", - // "src/video/*.ts", - // "src/other/*.ts", - // "src/tsconfig.json" - ], - "ignore": ["example/src/index.express.ts", "example/src/index.fastify.ts"], + "ignore": ["example/src/index.express.ts", "example/src/index.fastify.ts", "scripts/loadEntities.ts"], "ignoreDependencies": [ // Used in prisma.schema, not directly in code "prisma-json-types-generator", diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f50be69 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,240 @@ +# AROCAPI Project Guidelines + +This document provides guidance for working with the AROCAPI project using Claude Code. + +## Project Overview + +AROCAPI is a TypeScript/Fastify API for RO-Crate data management with: + +- **Framework**: Fastify with TypeScript +- **Database**: MySQL with Prisma ORM +- **Search**: OpenSearch integration +- **Testing**: Vitest with 95% coverage requirement + +## Code Style + +### General + +- Use 2 spaces for indentation +- Prefer single quotes for strings, double quotes for HTML attributes +- Prefer US spelling (e.g., color, organize) +- Use ES modules (import/export) + +### TypeScript + +- Use strict TypeScript configuration +- Prefer arrow functions for component definitions +- Use Fastify's type providers for route typing +- Prefer forEach/map/filter/reduce over for loops +- Add newlines before return and process.exit + +### Dependencies + +- Use `pnpm` for package management +- Never edit package.json scripts directly, use pnpm commands +- Use zod v4 for validation +- Follow Fastify conventions for plugins and routes + +## Testing Guidelines + +### Testing Strategy + +The project uses a comprehensive testing approach with: + +- **Unit tests**: Fast, isolated tests with mocked dependencies +- **Integration tests**: Full API tests with real database/OpenSearch +- **OpenAPI validation**: Ensures API contract compliance +- **95% coverage requirement**: Enforced in CI pipeline + +### Test Commands + +```bash +pnpm run test # Run all tests +pnpm run test:watch # Run in watch mode +pnpm run test:ui # Interactive UI +pnpm run test:coverage # With coverage report +``` + +### Test Environment + +- **Database**: localhost:3307 (catalog_test) +- **OpenSearch**: localhost:9201 +- **Services**: Use docker-compose.test.yml +- **Setup**: Automated via src/test/integration.setup.ts + +### Writing Tests + +1. **Unit tests**: Place in `src/**/*.test.ts` alongside source +2. **Integration tests**: Use `src/test/integration.test.ts` +3. **Mock external dependencies** in unit tests +4. **Use real services** in integration tests with cleanup +5. **Test both success and error paths** +6. **Use snapshots** for complex response validation + +### Coverage Requirements + +- Minimum 95% for lines, functions, branches, statements +- Excluded: generated code, dist, example, config files +- CI pipeline fails if coverage drops below threshold +- View reports: `open coverage/index.html` + +## Development Workflow + +### Local Development + +1. Install dependencies: `pnpm install` +2. Start services: `docker compose up -d` +3. Generate Prisma client: `pnpm run generate` +4. Start development: `pnpm run dev` + +### Before Committing + +1. Run linting: `pnpm run lint:biome && pnpm run lint:types` +2. Run tests: `pnpm run test` +3. Check coverage: `pnpm run test:coverage` +4. Ensure all checks pass + +### CI/CD Pipeline + +- **Triggers**: Pull requests to main branch +- **Services**: MySQL and OpenSearch via GitHub Actions +- **Checks**: Linting, type checking, tests, coverage +- **Requirements**: All checks must pass, 95% coverage minimum + +## Project Structure + +``` +src/ +├── app.ts # Main Fastify application +├── routes/ # API route handlers +│ ├── entity.ts # Single entity operations +│ ├── entities.ts # Entity listing/filtering +│ └── search.ts # OpenSearch operations +├── utils/ # Utility functions +│ └── errors.ts # Error handling helpers +├── generated/ # Prisma generated files (excluded from tests) +└── test/ # Integration test setup + ├── integration.setup.ts + ├── integration.test.ts + └── openapi.test.ts +``` + +## Key Dependencies + +### Runtime + +- **fastify**: Web framework +- **@prisma/client**: Database ORM +- **@opensearch-project/opensearch**: Search client +- **zod**: Schema validation (v4) +- **fastify-type-provider-zod**: Fastify Zod integration + +### Development + +- **vitest**: Testing framework +- **@vitest/coverage-v8**: Coverage reporting +- **typescript**: Type checking +- **@biomejs/biome**: Linting and formatting +- **openapi-backend**: OpenAPI validation +- **prisma**: Database toolkit + +## API Design + +### Route Structure + +- `GET /entity/:id` - Retrieve single entity +- `GET /entities` - List/filter entities with pagination +- `POST /search` - OpenSearch queries with faceting + +### Error Handling + +- Use consistent error response format +- Implement proper HTTP status codes +- Log errors appropriately +- Use utility functions from `src/utils/errors.ts` + +### Validation + +- Use Zod schemas for request/response validation +- Leverage Fastify's type providers +- Validate against OpenAPI specification +- Handle validation errors gracefully + +## Database Management + +### Prisma Operations + +```bash +pnpm run generate # Generate client +pnpm run db:migrate # Run migrations +pnpm run dbconsole # Access database console +``` + +### Test Database + +- Separate instance on port 3307 +- Automated setup/cleanup in tests +- Uses test-specific environment variables + +## OpenSearch Integration + +### Development + +- Service runs on localhost:9200 +- Test instance on localhost:9201 +- Index management handled in application + +### Search Features + +- Basic and advanced query modes +- Faceted search with aggregations +- Geospatial search capabilities +- Highlighting and scoring + +## Troubleshooting + +### Common Issues + +1. **Test timeouts**: Check service health, increase timeouts +2. **Database errors**: Verify connections, run migrations +3. **Coverage failures**: Check excluded files, add tests +4. **OpenSearch errors**: Verify service status, check mappings + +## Best Practices + +### Code Quality + +1. Follow TypeScript strict mode +2. Use proper error handling +3. Implement comprehensive logging +4. Write self-documenting code +5. Follow established patterns + +### Testing + +1. Maintain high test coverage (95%+) +2. Test both happy and error paths +3. Use appropriate test types (unit vs integration) +4. Keep tests fast and reliable +5. Clean up test data properly + +### Performance + +1. Use appropriate database queries +2. Implement proper caching strategies +3. Monitor OpenSearch performance +4. Optimize for common use cases +5. Profile and measure improvements + +### Security + +1. Validate all inputs +2. Use parameterized queries +3. Implement proper authentication/authorization +4. Log security events +5. Keep dependencies updated + +--- + +This document should be updated as the project evolves and new patterns emerge. + diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..50e331c --- /dev/null +++ b/TESTING.md @@ -0,0 +1,313 @@ +# Testing Guide + +This document provides comprehensive guidance on testing practices, setup, and execution for the AROCAPI project. + +## Table of Contents + +- [Overview](#overview) +- [Test Types](#test-types) +- [Setup](#setup) +- [Running Tests](#running-tests) +- [Writing Tests](#writing-tests) +- [Coverage Requirements](#coverage-requirements) +- [Continuous Integration](#continuous-integration) +- [Troubleshooting](#troubleshooting) + +## Overview + +The AROCAPI project uses **Vitest** as the primary testing framework, providing: + +- **Unit tests** for individual functions and modules +- **Integration tests** for API endpoints with real database connections +- **OpenAPI validation** to ensure API compliance +- **95% coverage requirement** to maintain code quality + +## Test Types + +### Unit Tests + +Test individual components in isolation with mocked dependencies. + +**Location**: `src/**/*.test.ts` +**Purpose**: + +- Validate business logic +- Test error handling +- Verify edge cases +- Fast execution without external dependencies + +**Example**: + +```typescript +// src/utils/errors.test.ts +import { describe, it, expect } from 'vitest'; +import { createNotFoundError } from './errors.js'; + +describe('createNotFoundError', () => { + it('should create error with message and resource', () => { + const error = createNotFoundError('Not found', 'resource-id'); + expect(error).toEqual({ + error: 'Not Found', + message: 'Not found', + resource: 'resource-id', + }); + }); +}); +``` + +### Integration Tests + +Test complete API workflows with real database and OpenSearch instances. + +**Location**: `src/test/integration.test.ts` +**Purpose**: + +- Test full request/response cycles +- Validate database interactions +- Test with real external services +- End-to-end API functionality + +**Example**: + +```typescript +// src/test/integration.test.ts +describe('GET /entities', () => { + it('should return entities from database', async () => { + const app = getTestApp(); + const response = await app.inject({ + method: 'GET', + url: '/entities', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.total).toBeGreaterThan(0); + }); +}); +``` + +## Setup + +### Prerequisites + +1. **Docker** (for test databases) +2. **Node.js LTS** +3. **pnpm** package manager + +### Local Development Setup + +1. **Install dependencies**: + + ```bash + pnpm install + ``` + +1. **Verify setup**: + + ```bash + pnpm run test + ``` + +## Running Tests + +### Available Commands + +```bash +# Run all tests once +pnpm run test + +# Run tests in watch mode +pnpm run test:watch + +# Run tests with UI interface +pnpm run test:ui + +# Run tests with coverage report +pnpm run test:coverage +``` + +### Test Environment + +Tests use separate services from development: + +- **Database**: `localhost:3307` (catalog_test) +- **OpenSearch**: `localhost:9201` +- **Environment**: Configured via `.env.test` + +### Running Specific Tests + +```bash +# Run specific test file +pnpm run test src/routes/entity.test.ts + +# Run tests matching pattern +pnpm run test --grep "entity" + +# Run only unit tests (exclude integration) +pnpm run test --exclude "**/integration.test.ts" +``` + +## Writing Tests + +### Test Structure Guidelines + +1. **Descriptive test names**: + + ```typescript + it('should return 404 when entity not found', async () => { + // Test implementation + }); + ``` + +2. **AAA Pattern**: Arrange, Act, Assert + + ```typescript + it('should filter entities by memberOf', async () => { + // Arrange + await seedTestData(); + + // Act + const response = await app.inject({ + method: 'GET', + url: '/entities?memberOf=http://example.com/collection/1', + }); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.body.entities).toHaveLength(1); + }); + ``` + +3. **Use snapshots for complex objects**: + + ```typescript + expect(response.body).toMatchSnapshot(); + ``` + +### Mocking Guidelines + +**Unit Tests**: Mock all external dependencies + +```typescript +const mockPrisma = { + entity: { + findFirst: vi.fn(), + }, +}; + +fastify.decorate('prisma', mockPrisma); +``` + +**Integration Tests**: Use real services but clean data between tests + +```typescript +beforeEach(async () => { + await seedTestData(); +}); + +afterEach(async () => { + await cleanupTestData(); +}); +``` + +### Error Testing + +Always test both success and error paths: + +```typescript +describe('Entity Route', () => { + it('should return entity when found', async () => { + // Test success case + }); + + it('should return 404 when not found', async () => { + // Test error case + }); + + it('should return 500 on database error', async () => { + // Test system error + }); +}); +``` + +## Coverage Requirements + +### Minimum Thresholds + +- **Lines**: 95% +- **Functions**: 95% +- **Branches**: 95% +- **Statements**: 95% + +### Excluded from Coverage + +- Generated code (`src/generated/**`) +- Build output (`dist/**`) +- Example code (`example/**`) +- Test files (`**/*.test.ts`) +- Configuration files (`*.config.ts`) +- Scripts (`src/scripts/**`) + +### Viewing Coverage + +```bash +# Generate coverage report +pnpm run test:coverage + +# Open HTML report +open coverage/index.html +``` + +### Coverage Enforcement + +The CI pipeline will **fail** if coverage drops below 95%. This ensures: + +- New code is properly tested +- Existing test quality is maintained +- Technical debt is minimized + +## Continuous Integration + +### GitHub Actions Workflow + +Tests run automatically on: + +- **Pull requests** to main branch +- **Manual triggers** via workflow_dispatch + +### CI Pipeline Steps + +1. **Setup**: Install dependencies, start services +2. **Health checks**: Verify database and OpenSearch connectivity +3. **Database setup**: Run migrations +4. **Linting**: Code style and type checking +5. **Testing**: Execute full test suite +6. **Coverage**: Verify coverage thresholds +7. **Reporting**: Post results to PR + +### Pipeline Requirements + +- **All tests must pass** +- **Coverage must be ≥95%** +- **Linting must pass** +- **Type checking must pass** + +### Test Services in CI + +The pipeline uses **GitHub Actions services** to provide: + +- MySQL 8 database (port 3307) +- OpenSearch 3 (port 9201) +- Health checks to ensure service readiness + +### Debugging Tests + +#### Enable Debug Output + +```bash +# Run with debug logging +DEBUG=* pnpm run test + +# Vitest UI for interactive debugging +pnpm run test:ui +``` diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..d98ea80 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,46 @@ +services: + test-db: + image: mysql:8 + restart: always + ports: + - 3307:3306 + volumes: + - test-db-data:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: catalog_test + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + + test-opensearch: + image: opensearchproject/opensearch:3 + container_name: test-opensearch + environment: + - cluster.name=test-opensearch-cluster + - node.name=test-opensearch + - discovery.type=single-node + - bootstrap.memory_lock=true + - OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m + - DISABLE_SECURITY_PLUGIN=true + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + volumes: + - test-opensearch-data:/usr/share/opensearch/data + ports: + - 9201:9200 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + test-db-data: + test-opensearch-data: \ No newline at end of file diff --git a/package.json b/package.json index cf89d0e..5a4d84c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "@semantic-release/git": "^10.0.1", "@types/express": "^5.0.3", "@types/node": "^22.18.6", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", "concurrently": "^9.2.1", "conventional-changelog-conventionalcommits": "^9.1.0", "fastify-tsconfig": "^3.0.0", @@ -41,7 +43,9 @@ "prisma-json-types-generator": "^3.6.1", "semantic-release": "^24.2.9", "tsx": "^4.20.5", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^3.2.4", + "vitest-mock-extended": "^3.1.0" }, "peerDependencies": { "@prisma/client": ">=6" @@ -66,6 +70,10 @@ "load-test-data": "tsx src/scripts/loadEntities.ts", "lint:biome": "biome check", "lint:types": "tsc --noEmit", + "test": "vitest run", + "test:watch": "./scripts/setup-integration.sh && vitest", + "test:ui": "./scripts/setup-integration.sh && vitest --ui", + "test:coverage": "./scripts/setup-integration.sh && vitest run --coverage", "prepublishOnly": "npm run lint:biome && npm run lint:types && npm run build:ts", "generate": "npx prisma generate", "postinstall": "npm run generate", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6165c0..1bbfbba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 3.5.1 '@prisma/config': specifier: ^6.16.2 - version: 6.16.2 + version: 6.16.2(magicast@0.3.5) dotenv: specifier: ^17.2.2 version: 17.2.2 @@ -44,7 +44,7 @@ importers: version: 2.2.4 '@prisma/client': specifier: ^6.16.2 - version: 6.16.2(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2) + version: 6.16.2(prisma@6.16.2(magicast@0.3.5)(typescript@5.9.2))(typescript@5.9.2) '@semantic-release/changelog': specifier: ^6.0.3 version: 6.0.3(semantic-release@24.2.9(typescript@5.9.2)) @@ -57,6 +57,12 @@ importers: '@types/node': specifier: ^22.18.6 version: 22.18.6 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + '@vitest/ui': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -71,10 +77,10 @@ importers: version: 5.64.0(@types/node@22.18.6)(typescript@5.9.2) prisma: specifier: ^6.16.2 - version: 6.16.2(typescript@5.9.2) + version: 6.16.2(magicast@0.3.5)(typescript@5.9.2) prisma-json-types-generator: specifier: ^3.6.1 - version: 3.6.1(@prisma/client@6.16.2(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2))(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2) + version: 3.6.1(@prisma/client@6.16.2(prisma@6.16.2(magicast@0.3.5)(typescript@5.9.2))(typescript@5.9.2))(prisma@6.16.2(magicast@0.3.5)(typescript@5.9.2))(typescript@5.9.2) semantic-release: specifier: ^24.2.9 version: 24.2.9(typescript@5.9.2) @@ -84,17 +90,44 @@ importers: typescript: specifier: ^5.9.2 version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.18.6)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1) + vitest-mock-extended: + specifier: ^3.1.0 + version: 3.1.0(typescript@5.9.2)(vitest@3.2.4) packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.27.1': resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/parser@7.28.4': + resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.2.4': resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==} engines: {node: '>=14.21.3'} @@ -344,6 +377,27 @@ packages: '@fastify/swagger@9.5.1': resolution: {integrity: sha512-EGjYLA7vDmCPK7XViAYMF6y4+K3XUy5soVTVxsyXolNe/Svb4nFQxvtuQvvoQb2Gzc9pxiF3+ZQN/iZDHhKtTg==} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -510,6 +564,10 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -522,6 +580,9 @@ packages: resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} engines: {node: '>=12'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@prisma/client@6.16.2': resolution: {integrity: sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw==} engines: {node: '>=18.18'} @@ -561,6 +622,116 @@ packages: '@prisma/get-platform@6.16.2': resolution: {integrity: sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==} + '@rollup/rollup-android-arm-eabi@4.52.1': + resolution: {integrity: sha512-sifE8uDpDvortUdi3xFevQ9WN5L3orrglg7iO/DhIpSVCwJOxBs9k9JzCC76KEZkLY4UkHWj+KESdFhlsNmDLw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.52.1': + resolution: {integrity: sha512-s83W/rRAPshsyzH9cS0CPKZVLlo2GGRt/1BocbR64DIyr2tMN1f2OZEjbFUnkAA2ewfbd+9waSYS0vbrlsG3qg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.52.1': + resolution: {integrity: sha512-lJkbZBREVUY9Vdw6DrzCysWv9Trcl7SyNxPRQMqvt6V/xmQC140aOcSkyWzwQ9t+s3ojvvWYZMpSazAbSTNfSA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.52.1': + resolution: {integrity: sha512-cw852iGDmvuXeOz2lwpocEL9wkHg3TBZRdAbwmra/YJ5KVxaj7nDdYJ9P0OAVxsbsKa0hFML+dwRHA02kB8Q+g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.52.1': + resolution: {integrity: sha512-nLezpaKL1jY63BunCbeA7B7B/5i4DQifNRBfzZ0+p3BxRejeKdzP7T3rfD5YpNy3+RysFy8Zw3EAnvXyrbZzqQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.52.1': + resolution: {integrity: sha512-USdXZmfo+t4DoUC02UotEf7e6ADsaQ1pvOtOZV2iT2wEmB6y7iMJA0MsIZTbp27enq9v+YK43s3ztYPVy0T2bA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.52.1': + resolution: {integrity: sha512-n3YunK17pY3BuZhLNTcRCT83JkFRfBKnG4R2vROUZvxLJlYkIQXfDGQRVZ7ZZBp1INxXm4fzT4jrd6Tm5DMZ7g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.52.1': + resolution: {integrity: sha512-45geWgFvA+SKw49tRkHI7xBizBZc6bismWIg+zqwK1OZN0hqMXe39BExVu45o768KDoM7XGoZ1pDE9opiHKKag==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.52.1': + resolution: {integrity: sha512-7m2ybyIOd5j/U43JSfMblwiZG69yAfuvg6TXhHvOtoQMjw6Or48FmgUxyAZ4ZzH7isxfMyr8M26m0pBkoAIEdQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.52.1': + resolution: {integrity: sha512-qnmMzRpkKG1T1EzKVtA/8Q0YAYalRN+h+WzWcbyD0SqjVwxmqrPj/TuuH30TwUp6X2UaUhfWSHccMgF+T6jDpw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.52.1': + resolution: {integrity: sha512-5Fc7jWzggy8RXJTew+8FoUXwpvJIuwOcYEMSJxs/9MB+oG/C4NRM23Xg+vW173sQz0H6RSViMmoKJih/hVQQow==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.52.1': + resolution: {integrity: sha512-DxnsniAn/iv23PtQhOU0l+cXAG3IvWkzEOc9t4THzWJs/NKpF955GnbYKo6PwqwlcbxO/ARn4B8IMg4ghW+DOw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.52.1': + resolution: {integrity: sha512-xAlxc3PeGHNpLmisSs8UpFm/A8aPOVeoHhWePEH0rDVFCC4uwWx4W1ecq/oYT2gjkRtVBxD1GjjNYJQrN9fX4A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.52.1': + resolution: {integrity: sha512-b5xbekmUtAkPY3TqrYMvbAltNNmpMApdMDxjYiaUQ8k1ep0iS/900CJEZq/RPd5gXF59Lp+me1wXbkW1xpxw4g==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.52.1': + resolution: {integrity: sha512-CcNQx6CuvJH/SMt3dElyqrCK7BCCAOQtdobJIVhJ7AaA5nrE0RkNHTVzDyXkYqkgoMjuF2p0tEchX7YuOeal4w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.52.1': + resolution: {integrity: sha512-xsKzVShwurM4JjGyMo/n4lb13mzpfDmg0yWiMlO65XSkhIpWnGnE4z66y9leVALb3M7sWiNluCKUv2ZZ0DWy1w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.52.1': + resolution: {integrity: sha512-AtzCeCyU6wYbJq7akOX3oZmc1pcY6yNYYC+HbjAcnjB63hXc22AX6nWtoU9TOJw3EQRxCLIubwGmnSrk66khpQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.52.1': + resolution: {integrity: sha512-pZb5K1hqS6MmdSgNUfWIzemPNNwmg5n7HhZHSyClwGd/IoQCiTjUGs09O/lxOZLHlltqUyVl0Y/4dcd8j90FEw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.52.1': + resolution: {integrity: sha512-A6hkNBmS3yahy06sFIouOjC5MO/ciPSBxdbWdGIk7ue3lhR1wJ9mJ27kZFK/N8ZOLwO1YdymYhhfI3gGHHpliA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.52.1': + resolution: {integrity: sha512-HRNyKIYDpuC7FIVJ8kH1RFGoEp4beASrjKksx3f2Oa82pLxNVhBIM1gC7WEd7z9djZ0OW6o9qhXFo7gAU4QCWw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.52.1': + resolution: {integrity: sha512-rkpnc4BKw8QoP9yynwLJqjVgmkko8yjqEHHYlUPv/xznRb3mQ7iN7fpc5fOqCFtYCeEyilBAun5a4wKLLKYX2g==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.52.1': + resolution: {integrity: sha512-ZzNEDNx/4sWP94UNAc6OfVNJFM2G4vz6IcIhBJv8BYyLeGNQldV5Dn22+i8Y7yn4a7unFjdAX/1nwNBfc7tUcg==} + cpu: [x64] + os: [win32] + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -629,9 +800,18 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@5.0.7': resolution: {integrity: sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==} @@ -644,6 +824,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/node@20.19.17': + resolution: {integrity: sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==} + '@types/node@22.18.6': resolution: {integrity: sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==} @@ -662,6 +845,52 @@ packages: '@types/serve-static@1.15.8': resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/ui@3.2.4': + resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} + peerDependencies: + vitest: 3.2.4 + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -712,6 +941,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -724,6 +957,13 @@ packages: array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@0.3.5: + resolution: {integrity: sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -734,6 +974,9 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -744,6 +987,9 @@ packages: bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -760,6 +1006,10 @@ packages: magicast: optional: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -772,6 +1022,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -788,6 +1042,10 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -925,6 +1183,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -970,6 +1232,9 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -979,6 +1244,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + emojilib@2.4.0: resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} @@ -1013,6 +1281,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1037,6 +1308,9 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -1053,6 +1327,10 @@ packages: resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -1114,6 +1392,18 @@ packages: fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -1146,6 +1436,13 @@ packages: resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==} engines: {node: '>=18'} + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -1220,6 +1517,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + globby@14.1.0: resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} engines: {node: '>=18'} @@ -1239,6 +1540,10 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + happy-dom@18.0.1: + resolution: {integrity: sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==} + engines: {node: '>=20.0.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -1274,6 +1579,9 @@ packages: resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} engines: {node: '>=14'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -1403,6 +1711,25 @@ packages: resolution: {integrity: sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==} engines: {node: ^18.17 || >=20.6.1} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + java-properties@1.0.2: resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==} engines: {node: '>= 0.6.0'} @@ -1414,6 +1741,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -1484,9 +1814,22 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + marked-terminal@7.3.0: resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} engines: {node: '>=16.0.0'} @@ -1558,15 +1901,32 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-postinstall@0.3.3: resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1760,6 +2120,9 @@ packages: resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} engines: {node: '>=4'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1805,6 +2168,10 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.2.0: resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} engines: {node: '>=16'} @@ -1820,6 +2187,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -1855,6 +2226,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + pretty-ms@9.2.0: resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==} engines: {node: '>=18'} @@ -1975,6 +2350,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.52.1: + resolution: {integrity: sha512-/vFSi3I+ya/D75UZh5GxLc/6UQ+KoKPEvL9autr1yGcaeWzXBQr1tTXmNDS4FImFCPwBAvVe7j9YzR8PQ5rfqw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2068,6 +2448,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -2079,6 +2462,10 @@ packages: resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} engines: {node: '>=6'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} @@ -2094,6 +2481,10 @@ packages: sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -2120,10 +2511,16 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} @@ -2131,6 +2528,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -2138,6 +2539,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2162,6 +2567,9 @@ packages: resolution: {integrity: sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g==} engines: {node: '>=14.16'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + super-regex@1.0.0: resolution: {integrity: sha512-CY8u7DtbvucKuquCmOFEKhr9Besln7n9uN8eFbwcoGYWXOMW07u2o8njWaiXt11ylS3qoGF55pILjRmPlbodyg==} engines: {node: '>=18'} @@ -2190,6 +2598,10 @@ packages: resolution: {integrity: sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==} engines: {node: '>=14.16'} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2207,9 +2619,31 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.1: resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2222,6 +2656,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + traverse@0.6.8: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} engines: {node: '>= 0.4'} @@ -2230,6 +2668,14 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-essentials@10.1.1: + resolution: {integrity: sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==} + peerDependencies: + typescript: '>=4.5.0' + peerDependenciesMeta: + typescript: + optional: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2312,15 +2758,103 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.1.7: + resolution: {integrity: sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest-mock-extended@3.1.0: + resolution: {integrity: sha512-vCM0VkuocOUBwwqwV7JB7YStw07pqeKvEIrZnR8l3PtwYi6rAAJAyJACeC1UYNfbQWi85nz7EdiXWBFI5hll2g==} + peerDependencies: + typescript: 3.x || 4.x || 5.x + vitest: '>=3.0.0' + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -2328,6 +2862,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2378,14 +2916,32 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.27.1': {} + '@babel/parser@7.28.4': + dependencies: + '@babel/types': 7.28.4 + + '@babel/types@7.28.4': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.2.4': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.2.4 @@ -2566,6 +3122,31 @@ snapshots: transitivePeerDependencies: - supports-color + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@lukeed/ms@2.0.2': {} '@napi-rs/wasm-runtime@1.0.5': @@ -2716,6 +3297,9 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.8.2': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -2728,14 +3312,16 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@prisma/client@6.16.2(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2)': + '@polka/url@1.0.0-next.29': {} + + '@prisma/client@6.16.2(prisma@6.16.2(magicast@0.3.5)(typescript@5.9.2))(typescript@5.9.2)': optionalDependencies: - prisma: 6.16.2(typescript@5.9.2) + prisma: 6.16.2(magicast@0.3.5)(typescript@5.9.2) typescript: 5.9.2 - '@prisma/config@6.16.2': + '@prisma/config@6.16.2(magicast@0.3.5)': dependencies: - c12: 3.1.0 + c12: 3.1.0(magicast@0.3.5) deepmerge-ts: 7.1.5 effect: 3.16.12 empathic: 2.0.0 @@ -2773,6 +3359,72 @@ snapshots: dependencies: '@prisma/debug': 6.16.2 + '@rollup/rollup-android-arm-eabi@4.52.1': + optional: true + + '@rollup/rollup-android-arm64@4.52.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.52.1': + optional: true + + '@rollup/rollup-darwin-x64@4.52.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.52.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.52.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.52.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.52.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.52.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.52.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.52.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.52.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.52.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.52.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.52.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.52.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.52.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.52.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.52.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.52.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.52.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.52.1': + optional: true + '@sec-ant/readable-stream@0.4.1': {} '@semantic-release/changelog@6.0.3(semantic-release@24.2.9(typescript@5.9.2))': @@ -2888,10 +3540,18 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.18.6 + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + '@types/connect@3.4.38': dependencies: '@types/node': 22.18.6 + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + '@types/express-serve-static-core@5.0.7': dependencies: '@types/node': 22.18.6 @@ -2909,6 +3569,11 @@ snapshots: '@types/mime@1.3.5': {} + '@types/node@20.19.17': + dependencies: + undici-types: 6.21.0 + optional: true + '@types/node@22.18.6': dependencies: undici-types: 6.21.0 @@ -2930,6 +3595,81 @@ snapshots: '@types/node': 22.18.6 '@types/send': 0.17.5 + '@types/whatwg-mimetype@3.0.2': + optional: true + + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.5 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.19 + magicast: 0.3.5 + std-env: 3.9.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.18.6)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.1.7(@types/node@22.18.6)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + vite: 7.1.7(@types/node@22.18.6)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.19 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/ui@3.2.4(vitest@3.2.4)': + dependencies: + '@vitest/utils': 3.2.4 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.18.6)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1) + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + abstract-logging@2.0.1: {} accepts@2.0.0: @@ -2976,6 +3716,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} argparse@2.0.1: {} @@ -2984,6 +3726,14 @@ snapshots: array-ify@1.0.0: {} + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + atomic-sleep@1.0.0: {} avvio@9.1.0: @@ -2993,6 +3743,8 @@ snapshots: aws4@1.13.2: {} + balanced-match@1.0.2: {} + before-after-hook@4.0.0: {} body-parser@2.2.0: @@ -3011,13 +3763,17 @@ snapshots: bottleneck@2.19.5: {} + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 bytes@3.1.2: {} - c12@3.1.0: + c12@3.1.0(magicast@0.3.5): dependencies: chokidar: 4.0.3 confbox: 0.2.2 @@ -3031,6 +3787,10 @@ snapshots: perfect-debounce: 1.0.0 pkg-types: 2.3.0 rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 + + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: dependencies: @@ -3044,6 +3804,14 @@ snapshots: callsites@3.1.0: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -3059,6 +3827,8 @@ snapshots: char-regex@1.0.2: {} + check-error@2.1.1: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -3195,6 +3965,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deepmerge-ts@7.1.5: {} @@ -3229,6 +4001,8 @@ snapshots: dependencies: readable-stream: 2.3.8 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} effect@3.16.12: @@ -3238,6 +4012,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + emojilib@2.4.0: {} empathic@2.0.0: {} @@ -3261,6 +4037,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3302,6 +4080,10 @@ snapshots: escape-string-regexp@5.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + etag@1.8.1: {} execa@5.1.1: @@ -3343,6 +4125,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.2.2: {} + express@5.1.0: dependencies: accepts: 2.0.0 @@ -3450,6 +4234,12 @@ snapshots: dependencies: walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fflate@0.8.2: {} + figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -3490,6 +4280,13 @@ snapshots: semver-regex: 4.0.5 super-regex: 1.0.0 + flatted@3.3.3: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -3573,6 +4370,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globby@14.1.0: dependencies: '@sindresorhus/merge-streams': 2.3.0 @@ -3597,6 +4403,13 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + happy-dom@18.0.1: + dependencies: + '@types/node': 20.19.17 + '@types/whatwg-mimetype': 3.0.2 + whatwg-mimetype: 3.0.0 + optional: true + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -3621,6 +4434,8 @@ snapshots: hpagent@1.2.0: {} + html-escaper@2.0.2: {} + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -3726,12 +4541,41 @@ snapshots: lodash.isstring: 4.0.1 lodash.uniqby: 4.7.0 + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + java-properties@1.0.2: {} jiti@2.5.1: {} js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -3814,8 +4658,24 @@ snapshots: lodash@4.17.21: {} + loupe@3.2.1: {} + lru-cache@10.4.3: {} + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + marked-terminal@7.3.0(marked@15.0.12): dependencies: ansi-escapes: 7.0.0 @@ -3866,8 +4726,16 @@ snapshots: mimic-fn@4.0.0: {} + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@7.1.2: {} + + mrmime@2.0.1: {} + ms@2.1.3: {} mz@2.7.0: @@ -3876,6 +4744,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.11: {} + napi-postinstall@0.3.3: {} negotiator@1.0.0: {} @@ -3998,6 +4868,8 @@ snapshots: p-try@1.0.0: {} + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -4038,6 +4910,11 @@ snapshots: path-key@4.0.0: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-to-regexp@8.2.0: {} path-type@4.0.0: {} @@ -4046,6 +4923,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -4087,22 +4966,28 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + pretty-ms@9.2.0: dependencies: parse-ms: 4.0.0 - prisma-json-types-generator@3.6.1(@prisma/client@6.16.2(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2))(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2): + prisma-json-types-generator@3.6.1(@prisma/client@6.16.2(prisma@6.16.2(magicast@0.3.5)(typescript@5.9.2))(typescript@5.9.2))(prisma@6.16.2(magicast@0.3.5)(typescript@5.9.2))(typescript@5.9.2): dependencies: - '@prisma/client': 6.16.2(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2) + '@prisma/client': 6.16.2(prisma@6.16.2(magicast@0.3.5)(typescript@5.9.2))(typescript@5.9.2) '@prisma/generator-helper': 6.16.2 - prisma: 6.16.2(typescript@5.9.2) + prisma: 6.16.2(magicast@0.3.5)(typescript@5.9.2) semver: 7.7.2 tslib: 2.8.1 typescript: 5.9.2 - prisma@6.16.2(typescript@5.9.2): + prisma@6.16.2(magicast@0.3.5)(typescript@5.9.2): dependencies: - '@prisma/config': 6.16.2 + '@prisma/config': 6.16.2(magicast@0.3.5) '@prisma/engines': 6.16.2 optionalDependencies: typescript: 5.9.2 @@ -4201,6 +5086,34 @@ snapshots: rfdc@1.4.1: {} + rollup@4.52.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.52.1 + '@rollup/rollup-android-arm64': 4.52.1 + '@rollup/rollup-darwin-arm64': 4.52.1 + '@rollup/rollup-darwin-x64': 4.52.1 + '@rollup/rollup-freebsd-arm64': 4.52.1 + '@rollup/rollup-freebsd-x64': 4.52.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.52.1 + '@rollup/rollup-linux-arm-musleabihf': 4.52.1 + '@rollup/rollup-linux-arm64-gnu': 4.52.1 + '@rollup/rollup-linux-arm64-musl': 4.52.1 + '@rollup/rollup-linux-loong64-gnu': 4.52.1 + '@rollup/rollup-linux-ppc64-gnu': 4.52.1 + '@rollup/rollup-linux-riscv64-gnu': 4.52.1 + '@rollup/rollup-linux-riscv64-musl': 4.52.1 + '@rollup/rollup-linux-s390x-gnu': 4.52.1 + '@rollup/rollup-linux-x64-gnu': 4.52.1 + '@rollup/rollup-linux-x64-musl': 4.52.1 + '@rollup/rollup-openharmony-arm64': 4.52.1 + '@rollup/rollup-win32-arm64-msvc': 4.52.1 + '@rollup/rollup-win32-ia32-msvc': 4.52.1 + '@rollup/rollup-win32-x64-gnu': 4.52.1 + '@rollup/rollup-win32-x64-msvc': 4.52.1 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -4343,6 +5256,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -4353,6 +5268,12 @@ snapshots: figures: 2.0.0 pkg-conf: 2.1.0 + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + skin-tone@2.0.0: dependencies: unicode-emoji-modifier-base: 1.0.0 @@ -4365,6 +5286,8 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + source-map@0.6.1: {} spawn-error-forwarder@1.0.0: {} @@ -4389,8 +5312,12 @@ snapshots: split2@4.2.0: {} + stackback@0.0.2: {} + statuses@2.0.1: {} + std-env@3.9.0: {} + stream-combiner2@1.1.1: dependencies: duplexer2: 0.1.4 @@ -4402,6 +5329,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -4410,6 +5343,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.0 + strip-bom@3.0.0: {} strip-final-newline@2.0.0: {} @@ -4422,6 +5359,10 @@ snapshots: strip-json-comments@5.0.2: {} + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + super-regex@1.0.0: dependencies: function-timeout: 1.0.2 @@ -4453,6 +5394,12 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -4474,8 +5421,23 @@ snapshots: dependencies: convert-hrtime: 5.0.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyexec@1.0.1: {} + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4484,10 +5446,16 @@ snapshots: toidentifier@1.0.1: {} + totalist@3.0.1: {} + traverse@0.6.8: {} tree-kill@1.2.2: {} + ts-essentials@10.1.1(typescript@5.9.2): + optionalDependencies: + typescript: 5.9.2 + tslib@2.8.1: {} tsx@4.20.5: @@ -4548,12 +5516,105 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.4(@types/node@22.18.6)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.1.7(@types/node@22.18.6)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.1.7(@types/node@22.18.6)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1): + dependencies: + esbuild: 0.25.8 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.18.6 + fsevents: 2.3.3 + jiti: 2.5.1 + tsx: 4.20.5 + yaml: 2.8.1 + + vitest-mock-extended@3.1.0(typescript@5.9.2)(vitest@3.2.4): + dependencies: + ts-essentials: 10.1.1(typescript@5.9.2) + typescript: 5.9.2 + vitest: 3.2.4(@types/node@22.18.6)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1) + + vitest@3.2.4(@types/node@22.18.6)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.1.7(@types/node@22.18.6)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.19 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.1.7(@types/node@22.18.6)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.18.6)(jiti@2.5.1)(tsx@4.20.5)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.18.6 + '@vitest/ui': 3.2.4(vitest@3.2.4) + happy-dom: 18.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + walk-up-path@4.0.0: {} + whatwg-mimetype@3.0.0: + optional: true + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wordwrap@1.0.0: {} wrap-ansi@7.0.0: @@ -4562,6 +5623,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} xtend@4.0.2: {} diff --git a/prisma/models/entity.prisma b/prisma/models/entity.prisma index 6066d0c..ba3111f 100644 --- a/prisma/models/entity.prisma +++ b/prisma/models/entity.prisma @@ -9,8 +9,8 @@ model Entity { entityType String @db.VarChar(1024) memberOf String? @db.VarChar(2048) rootCollection String? @db.VarChar(2048) - metadataLicenseId String @default("FIXME") @db.VarChar(2048) - contentLicenseId String @default("FIXME") @db.VarChar(2048) + metadataLicenseId String @db.VarChar(2048) + contentLicenseId String @db.VarChar(2048) rocrate Json diff --git a/src/scripts/loadEntities.ts b/scripts/loadEntities.ts similarity index 100% rename from src/scripts/loadEntities.ts rename to scripts/loadEntities.ts diff --git a/scripts/setup-integration.sh b/scripts/setup-integration.sh new file mode 100755 index 0000000..feff453 --- /dev/null +++ b/scripts/setup-integration.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -e + +docker compose -f docker-compose.test.yml up --wait --detach + +export DATABASE_URL='mysql://root:password@localhost:3307/catalog_test' +prisma migrate reset --force +npx prisma migrate dev --name init diff --git a/src/__snapshots__/app.test.ts.snap b/src/__snapshots__/app.test.ts.snap new file mode 100644 index 0000000..48cff60 --- /dev/null +++ b/src/__snapshots__/app.test.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Entity Route > App Registration > should handle random errors 1`] = ` +{ + "error": "Internal Server Error", + "message": "Random", +} +`; diff --git a/src/app.test.ts b/src/app.test.ts new file mode 100644 index 0000000..259f813 --- /dev/null +++ b/src/app.test.ts @@ -0,0 +1,77 @@ +import Fastify from 'fastify'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { mockReset } from 'vitest-mock-extended'; + +import app from './app.js'; + +import { opensearch, prisma } from './test/helpers/fastify.js'; + +describe('Entity Route', () => { + beforeEach(() => { + mockReset(prisma); + mockReset(opensearch); + }); + + describe('App Registration', () => { + it('should handle missing prisma', async () => { + const fastify = Fastify({ logger: false }); + + // @ts-expect-error we are testing missing options + fastify.register(app, { + opensearch, + disableCors: false, + }); + + await expect(() => fastify.ready()).rejects.toThrowError('Prisma client is required'); + }); + + it('should handle missing opensearch', async () => { + const fastify = Fastify({ logger: false }); + + // @ts-expect-error we are testing missing options + fastify.register(app, { + prisma, + disableCors: false, + }); + + await expect(() => fastify.ready()).rejects.toThrowError('OpenSearch client is required'); + }); + + it('should handle broken opensearch', async () => { + opensearch.ping.mockRejectedValue(new Error('Connection failed')); + + const fastify = Fastify({ logger: false }); + + fastify.register(app, { + prisma, + opensearch, + disableCors: false, + }); + + await expect(() => fastify.ready()).rejects.toThrowError('Connection failed'); + }); + + it('should handle random errors', async () => { + const fastify = Fastify({ logger: false }); + await fastify.register(app, { + prisma, + opensearch, + disableCors: false, + }); + fastify.get('/error', async () => { + throw new Error('Random'); + }); + + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/error', + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(500); + expect(body).toMatchSnapshot(); + }); + }); +}); diff --git a/src/app.ts b/src/app.ts index a2e6325..1014c80 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,43 +4,19 @@ import type { Client } from '@opensearch-project/opensearch'; import type { PrismaClient } from '@prisma/client/extension'; import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; import fp from 'fastify-plugin'; -import { - hasZodFastifySchemaValidationErrors, - isResponseSerializationError, - serializerCompiler, - validatorCompiler, -} from 'fastify-type-provider-zod'; +import { hasZodFastifySchemaValidationErrors, serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; import entities from './routes/entities.js'; import entity from './routes/entity.js'; import search from './routes/search.js'; +import { createValidationError } from './utils/errors.js'; const setupValidation = (fastify: FastifyInstance) => { fastify.setValidatorCompiler(validatorCompiler); fastify.setSerializerCompiler(serializerCompiler); - fastify.setErrorHandler((err, req, reply) => { + fastify.setErrorHandler((err, _req, reply) => { if (hasZodFastifySchemaValidationErrors(err)) { - return reply.code(400).send({ - error: 'Response Validation Error', - message: "Request doesn't match the schema", - details: { - issues: err.validation, - method: req.method, - url: req.url, - }, - }); - } - - if (isResponseSerializationError(err)) { - return reply.code(500).send({ - error: 'Internal Server Error', - message: "Response doesn't match the schema", - details: { - issues: err.cause.issues, - method: err.method, - url: err.url, - }, - }); + return reply.code(400).send(createValidationError('The request parameters are invalid', err.validation)); } // NOTE: We are exposing the error message here for development purposes. @@ -80,7 +56,15 @@ export type Options = { disableCors?: boolean; }; const app: FastifyPluginAsync = async (fastify, options) => { - const { prisma, opensearch, disableCors = false } = options || {}; + const { prisma, opensearch, disableCors = false } = options; + + if (!prisma) { + throw new Error('Prisma client is required'); + } + + if (!opensearch) { + throw new Error('OpenSearch client is required'); + } fastify.register(sensible); if (!disableCors) { diff --git a/src/routes/__snapshots__/entities.test.ts.snap b/src/routes/__snapshots__/entities.test.ts.snap new file mode 100644 index 0000000..b989350 --- /dev/null +++ b/src/routes/__snapshots__/entities.test.ts.snap @@ -0,0 +1,38 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Entities Route > GET /entities > should return 500 when database error occurs 1`] = ` +{ + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error", + }, +} +`; + +exports[`Entities Route > GET /entities > should return entities with default pagination 1`] = ` +{ + "entities": [ + { + "contentLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "description": "First test entity", + "entityType": "http://pcdm.org/models#Collection", + "id": "http://example.com/entity/1", + "memberOf": null, + "metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "name": "Test Entity 1", + "rootCollection": null, + }, + { + "contentLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "description": "Second test entity", + "entityType": "http://pcdm.org/models#Object", + "id": "http://example.com/entity/2", + "memberOf": "http://example.com/entity/1", + "metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "name": "Test Entity 2", + "rootCollection": "http://example.com/entity/1", + }, + ], + "total": 2, +} +`; diff --git a/src/routes/__snapshots__/entity.test.ts.snap b/src/routes/__snapshots__/entity.test.ts.snap new file mode 100644 index 0000000..887e934 --- /dev/null +++ b/src/routes/__snapshots__/entity.test.ts.snap @@ -0,0 +1,35 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Entity Route > GET /entity/:id > should return 404 when entity not found 1`] = ` +{ + "error": { + "code": "NOT_FOUND", + "details": { + "entityId": "http://example.com/entity/nonexistent", + }, + "message": "The requested entity was not found", + }, +} +`; + +exports[`Entity Route > GET /entity/:id > should return 500 when database error occurs 1`] = ` +{ + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error", + }, +} +`; + +exports[`Entity Route > GET /entity/:id > should return entity when found 1`] = ` +{ + "contentLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "description": "A test entity", + "entityType": "http://schema.org/Person", + "id": "http://example.com/entity/123", + "memberOf": null, + "metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "name": "Test Entity", + "rootCollection": null, +} +`; diff --git a/src/routes/__snapshots__/search.test.ts.snap b/src/routes/__snapshots__/search.test.ts.snap new file mode 100644 index 0000000..f2bd5ed --- /dev/null +++ b/src/routes/__snapshots__/search.test.ts.snap @@ -0,0 +1,67 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Search Route > POST /search > should handle opensearch errors 1`] = ` +{ + "error": { + "code": "INTERNAL_ERROR", + "message": "Search failed", + }, +} +`; + +exports[`Search Route > POST /search > should perform basic search successfully 1`] = ` +{ + "entities": [ + { + "contentLicenseId": null, + "description": "A test entity", + "entityType": "http://pcdm.org/models#Collection", + "id": "http://example.com/entity/1", + "memberOf": null, + "metadataLicenseId": null, + "name": "Test Entity 1", + "rootCollection": null, + "searchExtra": { + "highlight": { + "name": [ + "Test Entity 1", + ], + }, + "score": 1.5, + }, + }, + { + "contentLicenseId": null, + "description": "Another test entity", + "entityType": "http://pcdm.org/models#Object", + "id": "http://example.com/entity/2", + "memberOf": null, + "metadataLicenseId": null, + "name": "Test Entity 2", + "rootCollection": null, + "searchExtra": { + "highlight": { + "description": [ + "Another test entity", + ], + }, + "score": 1.2, + }, + }, + ], + "facets": { + "entityType": [ + { + "count": 1, + "name": "http://pcdm.org/models#Collection", + }, + { + "count": 1, + "name": "http://pcdm.org/models#Object", + }, + ], + }, + "searchTime": 10, + "total": 2, +} +`; diff --git a/src/routes/entities.test.ts b/src/routes/entities.test.ts new file mode 100644 index 0000000..6e45c67 --- /dev/null +++ b/src/routes/entities.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { fastify, fastifyAfter, fastifyBefore, prisma } from '../test/helpers/fastify.js'; + +import entitiesRoute from './entities.js'; + +describe('Entities Route', () => { + beforeEach(async () => { + await fastifyBefore(); + await fastify.register(entitiesRoute); + }); + + afterEach(async () => { + await fastifyAfter(); + }); + + describe('GET /entities', () => { + it('should return entities with default pagination', async () => { + const mockEntities = [ + { + id: 1, + rocrateId: 'http://example.com/entity/1', + name: 'Test Entity 1', + description: 'First test entity', + entityType: 'http://pcdm.org/models#Collection', + memberOf: null, + rootCollection: null, + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + createdAt: new Date(), + updatedAt: new Date(), + rocrate: {}, + }, + { + id: 2, + rocrateId: 'http://example.com/entity/2', + name: 'Test Entity 2', + description: 'Second test entity', + entityType: 'http://pcdm.org/models#Object', + memberOf: 'http://example.com/entity/1', + rootCollection: 'http://example.com/entity/1', + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + createdAt: new Date(), + updatedAt: new Date(), + rocrate: {}, + }, + ]; + + prisma.entity.findMany.mockResolvedValue(mockEntities); + prisma.entity.count.mockResolvedValue(2); + + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body).toMatchSnapshot(); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { rocrateId: 'asc' }, + skip: 0, + take: 100, + }); + }); + + it('should filter by memberOf', async () => { + prisma.entity.findMany.mockResolvedValue([]); + prisma.entity.count.mockResolvedValue(0); + + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + query: { + memberOf: 'http://example.com/collection/1', + }, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: { memberOf: 'http://example.com/collection/1' }, + orderBy: { rocrateId: 'asc' }, + skip: 0, + take: 100, + }); + }); + + it('should filter by single entity type', async () => { + prisma.entity.findMany.mockResolvedValue([]); + prisma.entity.count.mockResolvedValue(0); + + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + query: { + entityType: 'http://pcdm.org/models#Collection', + }, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: { entityType: { in: ['http://pcdm.org/models#Collection'] } }, + orderBy: { rocrateId: 'asc' }, + skip: 0, + take: 100, + }); + }); + + it('should filter by multiple entity types', async () => { + prisma.entity.findMany.mockResolvedValue([]); + prisma.entity.count.mockResolvedValue(0); + + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + query: { + entityType: 'http://pcdm.org/models#Collection,http://pcdm.org/models#Object', + }, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: { + entityType: { + in: ['http://pcdm.org/models#Collection', 'http://pcdm.org/models#Object'], + }, + }, + orderBy: { rocrateId: 'asc' }, + skip: 0, + take: 100, + }); + }); + + it('should handle pagination parameters', async () => { + prisma.entity.findMany.mockResolvedValue([]); + prisma.entity.count.mockResolvedValue(0); + + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + query: { + limit: '50', + offset: '10', + }, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { rocrateId: 'asc' }, + skip: 10, + take: 50, + }); + }); + + it('should handle sorting by name descending', async () => { + prisma.entity.findMany.mockResolvedValue([]); + prisma.entity.count.mockResolvedValue(0); + + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + query: { + sort: 'name', + order: 'desc', + }, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { name: 'desc' }, + skip: 0, + take: 100, + }); + }); + + it('should map id sort to rocrateId field', async () => { + prisma.entity.findMany.mockResolvedValue([]); + prisma.entity.count.mockResolvedValue(0); + + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + query: { + sort: 'id', + }, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { rocrateId: 'asc' }, + skip: 0, + take: 100, + }); + }); + + it('should return 500 when database error occurs', async () => { + prisma.entity.findMany.mockRejectedValue(new Error('Database connection failed')); + + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + }); + + expect(response.statusCode).toBe(500); + const body = JSON.parse(response.body); + expect(body).toMatchSnapshot(); + }); + + it('should validate limit parameter bounds', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + query: { + limit: '0', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should validate negative offset', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + query: { + offset: '-1', + }, + }); + + expect(response.statusCode).toBe(400); + }); + }); +}); diff --git a/src/routes/entities.ts b/src/routes/entities.ts index f39d6a0..85d81b9 100644 --- a/src/routes/entities.ts +++ b/src/routes/entities.ts @@ -30,7 +30,6 @@ const entities: FastifyPluginAsync = async (fastify, _opts) => { }, async (request, reply) => { const { memberOf, entityType, limit, offset, sort, order } = request.query; - console.log('🪚 request.query:', JSON.stringify(request.query, null, 2)); try { const where: NonNullable[0]>['where'] = {}; diff --git a/src/routes/entity.test.ts b/src/routes/entity.test.ts new file mode 100644 index 0000000..99767ea --- /dev/null +++ b/src/routes/entity.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { fastify, fastifyAfter, fastifyBefore, prisma } from '../test/helpers/fastify.js'; + +import entityRoute from './entity.js'; + +describe('Entity Route', () => { + beforeEach(async () => { + await fastifyBefore(); + await fastify.register(entityRoute); + }); + + afterEach(async () => { + await fastifyAfter(); + }); + + describe('GET /entity/:id', () => { + it('should return entity when found', async () => { + const mockEntity = { + id: 1, + rocrateId: 'http://example.com/entity/123', + name: 'Test Entity', + description: 'A test entity', + entityType: 'http://schema.org/Person', + memberOf: null, + rootCollection: null, + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + createdAt: new Date(), + updatedAt: new Date(), + rocrate: {}, + }; + + prisma.entity.findFirst.mockResolvedValue(mockEntity); + + const response = await fastify.inject({ + method: 'GET', + url: `/entity/${encodeURIComponent('http://example.com/entity/123')}`, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body).toMatchSnapshot(); + expect(prisma.entity.findFirst).toHaveBeenCalledWith({ + where: { + rocrateId: 'http://example.com/entity/123', + }, + }); + }); + + it('should return 404 when entity not found', async () => { + console.log('🪚 ♊'); + prisma.entity.findFirst.mockResolvedValue(null); + + const response = await fastify.inject({ + method: 'GET', + url: `/entity/${encodeURIComponent('http://example.com/entity/nonexistent')}`, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(404); + expect(body).toMatchSnapshot(); + }); + + it('should return 500 when database error occurs', async () => { + prisma.entity.findFirst.mockRejectedValue(new Error('Database connection failed')); + + const response = await fastify.inject({ + method: 'GET', + url: `/entity/${encodeURIComponent('http://example.com/entity/123')}`, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(500); + expect(body).toMatchSnapshot(); + }); + + it('should validate ID parameter format', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/entity/invalid-id', + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(400); + expect(body.error).toBe('Bad Request'); + }); + }); +}); diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 8247e75..2c9f0c5 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -17,7 +17,6 @@ const entity: FastifyPluginAsync = async (fastify, _opts) => { }, async (request, reply) => { const { id } = request.params; - console.log('🪚 id:', JSON.stringify(id, null, 2)); try { const entity = await fastify.prisma.entity.findFirst({ diff --git a/src/routes/search.test.ts b/src/routes/search.test.ts new file mode 100644 index 0000000..c9c0bc4 --- /dev/null +++ b/src/routes/search.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { fastify, fastifyAfter, fastifyBefore, opensearch } from '../test/helpers/fastify.js'; + +import searchRoute from './search.js'; + +describe('Search Route', () => { + beforeEach(async () => { + await fastifyBefore(); + await fastify.register(searchRoute); + }); + + afterEach(async () => { + await fastifyAfter(); + }); + + describe('POST /search', () => { + it('should perform basic search successfully', async () => { + const mockSearchResponse = { + body: { + took: 10, + hits: { + total: { value: 2 }, + hits: [ + { + _score: 1.5, + _source: { + rocrateId: 'http://example.com/entity/1', + name: 'Test Entity 1', + description: 'A test entity', + entityType: 'http://pcdm.org/models#Collection', + memberOf: null, + rootCollection: null, + metadataLicenseId: null, + contentLicenseId: null, + }, + highlight: { + name: ['Test Entity 1'], + }, + }, + { + _score: 1.2, + _source: { + rocrateId: 'http://example.com/entity/2', + name: 'Test Entity 2', + description: 'Another test entity', + entityType: 'http://pcdm.org/models#Object', + memberOf: null, + rootCollection: null, + metadataLicenseId: null, + contentLicenseId: null, + }, + highlight: { + description: ['Another test entity'], + }, + }, + ], + }, + aggregations: { + entityType: { + buckets: [ + { key: 'http://pcdm.org/models#Collection', doc_count: 1 }, + { key: 'http://pcdm.org/models#Object', doc_count: 1 }, + ], + }, + }, + }, + }; + + // @ts-expect-error TS is looking at the wronf function signature + opensearch.search.mockResolvedValue(mockSearchResponse); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + searchType: 'basic', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toMatchSnapshot(); + expect(opensearch.search).toHaveBeenCalledWith({ + index: 'entities', + body: { + query: { + bool: { + must: [ + { + multi_match: { + query: 'test', + fields: ['name^2', 'description'], + type: 'best_fields', + fuzziness: 'AUTO', + }, + }, + ], + filter: [], + }, + }, + aggs: { + inLanguage: { terms: { field: 'inLanguage', size: 20 } }, + mediaType: { terms: { field: 'mediaType', size: 20 } }, + communicationMode: { terms: { field: 'communicationMode', size: 20 } }, + entityType: { terms: { field: 'entityType', size: 20 } }, + }, + highlight: { + fields: { + name: {}, + description: {}, + }, + }, + sort: undefined, + from: 0, + size: 100, + }, + }); + }); + + it('should perform advanced search with query string', async () => { + const mockSearchResponse = { + body: { + took: 5, + hits: { + total: { value: 0 }, + hits: [], + }, + aggregations: {}, + }, + }; + + // @ts-expect-error TS is looking at the wronf function signature + opensearch.search.mockResolvedValue(mockSearchResponse); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'name:test AND description:entity', + searchType: 'advanced', + }, + }); + + expect(response.statusCode).toBe(200); + expect(opensearch.search).toHaveBeenCalledWith({ + index: 'entities', + body: { + query: { + bool: { + must: [ + { + query_string: { + query: 'name:test AND description:entity', + fields: ['name^2', 'description'], + default_operator: 'AND', + }, + }, + ], + filter: [], + }, + }, + aggs: { + inLanguage: { terms: { field: 'inLanguage', size: 20 } }, + mediaType: { terms: { field: 'mediaType', size: 20 } }, + communicationMode: { terms: { field: 'communicationMode', size: 20 } }, + entityType: { terms: { field: 'entityType', size: 20 } }, + }, + highlight: { + fields: { + name: {}, + description: {}, + }, + }, + sort: undefined, + from: 0, + size: 100, + }, + }); + }); + + it('should apply filters correctly', async () => { + const mockSearchResponse = { + body: { + took: 8, + hits: { + total: { value: 0 }, + hits: [], + }, + aggregations: {}, + }, + }; + + // @ts-expect-error TS is looking at the wronf function signature + opensearch.search.mockResolvedValue(mockSearchResponse); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + filters: { + entityType: ['http://pcdm.org/models#Collection'], + inLanguage: ['en', 'fr'], + }, + }, + }); + + expect(response.statusCode).toBe(200); + const expectedFilters = [ + { + terms: { + entityType: ['http://pcdm.org/models#Collection'], + }, + }, + { + terms: { + inLanguage: ['en', 'fr'], + }, + }, + ]; + + expect(opensearch.search).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + query: expect.objectContaining({ + bool: expect.objectContaining({ + filter: expectedFilters, + }), + }), + }), + }), + ); + }); + + it('should handle geospatial search with bounding box', async () => { + const mockSearchResponse = { + body: { + took: 12, + hits: { + total: { value: 0 }, + hits: [], + }, + aggregations: { + geohash_grid: { + buckets: [ + { key: 'gbsuv', doc_count: 3 }, + { key: 'gbsvb', doc_count: 1 }, + ], + }, + }, + }, + }; + + // @ts-expect-error TS is looking at the wronf function signature + opensearch.search.mockResolvedValue(mockSearchResponse); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + boundingBox: { + topRight: { lat: 51.5, lng: 0.1 }, + bottomLeft: { lat: 51.4, lng: 0.0 }, + }, + geohashPrecision: 5, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.geohashGrid).toEqual({ + gbsuv: 3, + gbsvb: 1, + }); + + expect(opensearch.search).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + query: expect.objectContaining({ + bool: expect.objectContaining({ + filter: [ + { + geo_bounding_box: { + location: { + top_left: { lat: 51.5, lon: 0.0 }, + bottom_right: { lat: 51.4, lon: 0.1 }, + }, + }, + }, + ], + }), + }), + aggs: expect.objectContaining({ + geohash_grid: { + geohash_grid: { + field: 'location', + precision: 5, + bounds: { + top_left: { lat: 51.5, lon: 0.0 }, + bottom_right: { lat: 51.4, lon: 0.1 }, + }, + }, + }, + }), + }), + }), + ); + }); + + it('should handle pagination and sorting', async () => { + const mockSearchResponse = { + body: { + took: 6, + hits: { + total: { value: 0 }, + hits: [], + }, + aggregations: {}, + }, + }; + + // @ts-expect-error TS is looking at the wronf function signature + opensearch.search.mockResolvedValue(mockSearchResponse); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + limit: 50, + offset: 20, + sort: 'name', + order: 'desc', + }, + }); + + expect(response.statusCode).toBe(200); + expect(opensearch.search).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + from: 20, + size: 50, + sort: [{ 'name.keyword': 'desc' }], + }), + }), + ); + }); + + it('should handle opensearch errors', async () => { + opensearch.search.mockRejectedValue(new Error('OpenSearch connection failed')); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + }, + }); + + expect(response.statusCode).toBe(500); + const body = JSON.parse(response.body); + expect(body).toMatchSnapshot(); + }); + + it('should validate required query parameter', async () => { + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: {}, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should handle missing _source in search hit', async () => { + const mockSearchResponse = { + body: { + took: 5, + hits: { + total: { value: 1 }, + hits: [ + { + _score: 1.5, + // Missing _source + }, + ], + }, + aggregations: {}, + }, + }; + + // @ts-expect-error TS is looking at the wronf function signature + opensearch.search.mockResolvedValue(mockSearchResponse); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + }, + }); + + expect(response.statusCode).toBe(500); + }); + }); +}); diff --git a/src/routes/search.ts b/src/routes/search.ts index 4907658..17f2ae3 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -241,6 +241,7 @@ const search: FastifyPluginAsync = async (fastify, _opts) => { } } + /* v8 ignore next 3 -- Not sure how to force opensearch to hit the other path */ const total = typeof response.body.hits.total === 'number' ? response.body.hits.total diff --git a/src/test/helpers/fastify.ts b/src/test/helpers/fastify.ts new file mode 100644 index 0000000..a081518 --- /dev/null +++ b/src/test/helpers/fastify.ts @@ -0,0 +1,39 @@ +import type { Client } from '@opensearch-project/opensearch'; +import type { FastifyInstance } from 'fastify'; +import Fastify from 'fastify'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { mockDeep, mockReset } from 'vitest-mock-extended'; + +import type { PrismaClient } from '../../generated/prisma/client.js'; + +export let fastify: FastifyInstance; +export const prisma = mockDeep(); +export const opensearch = mockDeep(); + +// @ts-expect-error Mock sets these and fastify decorate breaks as it looks for them +prisma.getter = undefined; +// @ts-expect-error Mock sets these and fastify decorate breaks as it looks for them +prisma.setter = undefined; + +// @ts-expect-error Mock sets these and fastify decorate breaks as it looks for them +opensearch.getter = undefined; +// @ts-expect-error Mock sets these and fastify decorate breaks as it looks for them +opensearch.setter = undefined; + +export const fastifyBefore = async () => { + mockReset(prisma); + mockReset(opensearch); + + fastify = Fastify({ logger: false }); + fastify.decorate('prisma', prisma); + fastify.decorate('opensearch', opensearch); + + fastify.setValidatorCompiler(validatorCompiler); + fastify.setSerializerCompiler(serializerCompiler); + + return fastify; +}; + +export const fastifyAfter = async () => { + await fastify.close(); +}; diff --git a/src/test/integration.setup.ts b/src/test/integration.setup.ts new file mode 100644 index 0000000..b9f4413 --- /dev/null +++ b/src/test/integration.setup.ts @@ -0,0 +1,146 @@ +import { Client } from '@opensearch-project/opensearch'; +import type { FastifyInstance } from 'fastify'; +import Fastify from 'fastify'; +import app from '../app.js'; +import { PrismaClient } from '../generated/prisma/client.js'; + +let fastify: FastifyInstance; +let prisma: PrismaClient; +let opensearch: Client; + +export const getTestApp = () => fastify; + +export async function setupIntegrationTests() { + process.env.DATABASE_URL = 'mysql://root:password@localhost:3307/catalog_test'; + process.env.OPENSEARCH_URL = 'http://localhost:9201'; + process.env.NODE_ENV = 'test'; + + prisma = new PrismaClient({ + datasources: { + db: { + url: process.env.DATABASE_URL, + }, + }, + }); + + opensearch = new Client({ + node: process.env.OPENSEARCH_URL, + }); + + fastify = Fastify({ logger: false }); + + await fastify.register(app, { + prisma, + opensearch, + disableCors: true, + }); + + await fastify.ready(); +} + +export async function teardownIntegrationTests() { + await cleanupTestData(); + + await fastify.close(); + await prisma.$disconnect(); + opensearch.close(); +} + +export async function cleanupTestData() { + await prisma.entity.deleteMany({}); + + await opensearch.indices.delete({ + index: 'entities', + ignore_unavailable: true, + }); +} + +export async function seedTestData() { + await cleanupTestData(); + + const testEntities = [ + { + id: 1, + rocrateId: 'http://example.com/entity/1', + name: 'Test Collection', + description: 'First test entity', + entityType: 'http://pcdm.org/models#Collection', + memberOf: null, + rootCollection: null, + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + createdAt: new Date(), + updatedAt: new Date(), + rocrate: {}, + }, + { + id: 2, + rocrateId: 'http://example.com/entity/2', + name: 'Test Object', + description: 'Second test entity', + entityType: 'http://pcdm.org/models#Object', + memberOf: 'http://example.com/entity/1', + rootCollection: 'http://example.com/entity/1', + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + createdAt: new Date(), + updatedAt: new Date(), + rocrate: {}, + }, + { + id: 3, + rocrateId: 'http://example.com/entity/3', + name: 'Test Person', + description: 'Third test entity', + entityType: 'http://schema.org/Person', + memberOf: 'http://example.com/entity/1', + rootCollection: 'http://example.com/entity/1', + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + createdAt: new Date(), + updatedAt: new Date(), + rocrate: {}, + }, + ]; + + await prisma.entity.createMany({ + data: testEntities, + }); + + await opensearch.indices.create({ + index: 'entities', + body: { + mappings: { + properties: { + rocrateId: { type: 'keyword' }, + name: { + type: 'text', + fields: { + keyword: { type: 'keyword' }, + }, + }, + description: { type: 'text' }, + entityType: { type: 'keyword' }, + memberOf: { type: 'keyword' }, + rootCollection: { type: 'keyword' }, + location: { type: 'geo_point' }, + inLanguage: { type: 'keyword' }, + mediaType: { type: 'keyword' }, + communicationMode: { type: 'keyword' }, + }, + }, + }, + }); + + const testDocs = testEntities.flatMap((entity, index) => [ + { index: { _index: 'entities', _id: `${index + 1}` } }, + entity, + ]); + + await opensearch.bulk({ + body: testDocs, + refresh: true, + }); + + return testEntities; +} diff --git a/src/test/integration.test.ts b/src/test/integration.test.ts new file mode 100644 index 0000000..fbd805b --- /dev/null +++ b/src/test/integration.test.ts @@ -0,0 +1,297 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + cleanupTestData, + getTestApp, + seedTestData, + setupIntegrationTests, + teardownIntegrationTests, +} from './integration.setup.js'; + +describe('Integration Tests', () => { + beforeAll(async () => { + await setupIntegrationTests(); + }); + + afterAll(async () => { + await teardownIntegrationTests(); + }); + + beforeEach(async () => { + await seedTestData(); + }); + + afterEach(async () => { + await cleanupTestData(); + }); + + describe('GET /entity/:id', () => { + it('should return entity from database', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'GET', + url: `/entity/${encodeURIComponent('http://example.com/entity/1')}`, + }); + + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body).toEqual({ + id: 'http://example.com/entity/1', + name: 'Test Collection', + description: 'First test entity', + entityType: 'http://pcdm.org/models#Collection', + memberOf: null, + rootCollection: null, + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + }); + }); + + it('should return 404 for non-existent entity', async () => { + const app = getTestApp(); + const response = await app.inject({ + method: 'GET', + url: '/entity/http://example.com/entity/nonexistent', + }); + + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(404); + expect(body.error).toBe('Not Found'); + }); + }); + + describe('GET /entities', () => { + it('should return all entities with pagination', async () => { + const app = getTestApp(); + const response = await app.inject({ + method: 'GET', + url: '/entities', + }); + + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body.total).toBe(3); + expect(body.entities).toHaveLength(3); + expect(body.entities[0]).toEqual({ + id: 'http://example.com/entity/1', + name: 'Test Collection', + description: 'First test entity', + entityType: 'http://pcdm.org/models#Collection', + memberOf: null, + rootCollection: null, + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + }); + }); + + it('should filter entities by memberOf', async () => { + const app = getTestApp(); + const response = await app.inject({ + method: 'GET', + url: '/entities', + query: { + memberOf: 'http://example.com/entity/1', + }, + }); + + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body.total).toBe(2); + expect(body.entities).toHaveLength(2); + expect(body.entities[0].id).toBe('http://example.com/entity/2'); + }); + + it('should filter entities by entityType', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/entities', + query: { + entityType: 'http://schema.org/Person', + }, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body.total).toBe(1); + expect(body.entities).toHaveLength(1); + expect(body.entities[0].id).toBe('http://example.com/entity/3'); + }); + + it('should handle pagination', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/entities', + query: { + limit: '2', + offset: '1', + }, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body.total).toBe(3); + expect(body.entities).toHaveLength(2); + }); + + it('should sort entities by name', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/entities', + query: { + sort: 'name', + order: 'desc', + }, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body.entities[0].name).toBe('Test Person'); + expect(body.entities[1].name).toBe('Test Object'); + expect(body.entities[2].name).toBe('Test Collection'); + }); + }); + + describe('POST /search', () => { + it('should perform basic search', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + searchType: 'basic', + }, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body.total).toBeGreaterThan(0); + expect(body.entities).toBeDefined(); + expect(Array.isArray(body.entities)).toBe(true); + }); + + it('should handle search with no results', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'nonexistentterm', + searchType: 'basic', + }, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body.total).toBe(0); + expect(body.entities).toHaveLength(0); + }); + + it('should return aggregations', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + searchType: 'basic', + }, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body.facets).toBeDefined(); + }); + + it('should handle search pagination', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + limit: 1, + offset: 0, + }, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body.entities.length).toBeLessThanOrEqual(1); + }); + + it('should sort entities by id', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + sort: 'id', + }, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body.entities[0].name).toBe('Test Collection'); + expect(body.entities[1].name).toBe('Test Object'); + expect(body.entities[2].name).toBe('Test Person'); + }); + }); + + describe('Error Handling', () => { + it('should handle invalid entity ID format', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/entity/invalid-id', + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(400); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('should handle invalid query parameters for entities', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/entities', + query: { + limit: '-1', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should handle missing search query', async () => { + const app = getTestApp(); + + const response = await app.inject({ + method: 'POST', + url: '/search', + payload: {}, + }); + + expect(response.statusCode).toBe(400); + }); + }); +}); diff --git a/src/utils/errors.test.ts b/src/utils/errors.test.ts new file mode 100644 index 0000000..d518722 --- /dev/null +++ b/src/utils/errors.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { createInternalError, createNotFoundError } from './errors.js'; + +describe('Error Utilities', () => { + describe('createInternalError', () => { + it('should create a default internal error', () => { + const error = createInternalError(); + + expect(error).toEqual({ + error: { + code: 'INTERNAL_ERROR', + message: 'Internal server error', + details: undefined, + }, + }); + }); + + it('should create an internal error with custom message', () => { + const customMessage = 'Database connection failed'; + const error = createInternalError(customMessage); + + expect(error).toEqual({ + error: { + code: 'INTERNAL_ERROR', + message: customMessage, + details: undefined, + }, + }); + }); + }); + + describe('createNotFoundError', () => { + it('should create a not found error with message', () => { + const message = 'Entity not found'; + const error = createNotFoundError(message); + + expect(error).toEqual({ + error: { + code: 'NOT_FOUND', + message, + details: undefined, + }, + }); + }); + + it('should create a not found error with message and entityId', () => { + const message = 'Entity not found'; + const entityId = 'http://example.com/entity/123'; + const error = createNotFoundError(message, entityId); + + expect(error).toEqual({ + error: { + code: 'NOT_FOUND', + message, + details: { + entityId, + }, + }, + }); + }); + + it('should handle undefined entityId', () => { + const message = 'Entity not found'; + const error = createNotFoundError(message, undefined); + + expect(error).toEqual({ + error: { + code: 'NOT_FOUND', + message, + details: undefined, + }, + }); + }); + }); +}); diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 0a4ffd1..d4c635f 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -8,24 +8,18 @@ const ERROR_CODES = { type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; -interface ErrorDetails { +type ErrorDetails = { [key: string]: unknown; -} - -// interface ValidationViolation { -// field: string; -// message: string; -// value?: unknown; -// } +}; -interface StandardErrorResponse { +type StandardErrorResponse = { error: { code: ErrorCode; message: string; details?: ErrorDetails; requestId?: string; }; -} +}; const createErrorResponse = (code: ErrorCode, message: string, details?: ErrorDetails): StandardErrorResponse => ({ error: { @@ -35,10 +29,8 @@ const createErrorResponse = (code: ErrorCode, message: string, details?: ErrorDe }, }); -// export const createValidationError = (message: string, violations: ValidationViolation[]): StandardErrorResponse => -// createErrorResponse(ERROR_CODES.VALIDATION_ERROR, message, { -// violations, -// }); +export const createValidationError = (message: string, issues: string[]): StandardErrorResponse => + createErrorResponse(ERROR_CODES.VALIDATION_ERROR, message, { issues }); export const createNotFoundError = (message: string, entityId?: string): StandardErrorResponse => createErrorResponse(ERROR_CODES.NOT_FOUND, message, entityId ? { entityId } : undefined); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4fc892a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + coverage: { + enabled: true, + reportOnFailure: true, + reporter: ['text', 'html', 'json-summary', 'json'], + thresholds: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + }, + exclude: [ + // Not code + 'scripts/**', + 'src/generated/**', + 'example/**', + 'dist/**', + '*.config.*', + + // Not part of library + 'src/index.ts', + + // TODO + 'src/express.ts', + ], + }, + include: ['src/**/*.test.ts'], + }, +});