diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..377e9fd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,79 @@ +# Philosophy: correctness and foot-gun rules are build errors; pure style is a +# suggestion so it never blocks a build. EnforceCodeStyleInBuild is on and +# warnings are errors (Directory.Build.props), so a "warning" here fails CI. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml,slnx,csproj,props,targets}] +indent_size = 2 + +[*.{js,ts,vue,css,html,cjs,mjs}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.cs] +# --- Naming ------------------------------------------------------------ +# Private fields are camelCase with no underscore prefix. +dotnet_naming_rule.private_fields_are_camel_case.severity = warning +dotnet_naming_rule.private_fields_are_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_are_camel_case.style = camel_case_style + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# --- Style (suggestions — never block the build) ----------------------- +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = when_on_single_line:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_primary_constructors = true:suggestion +dotnet_style_prefer_collection_expression = true:suggestion + +# --- Correctness: promoted to error ------------------------------------ +# File-scoped namespaces everywhere. +csharp_style_namespace_declarations = file_scoped:error +dotnet_diagnostic.IDE0161.severity = error + +# Exhaustive switch expressions: no missing enum members, and never a +# discard arm to hide them. A new enum value must break the build at every +# switch that ignores it (CS8524 stays quiet — unnamed values are cast noise). +dotnet_diagnostic.CS8509.severity = error +dotnet_diagnostic.CS8524.severity = none + +# Null-safety escapes are bugs, not warnings. +dotnet_diagnostic.CS8600.severity = error +dotnet_diagnostic.CS8602.severity = error +dotnet_diagnostic.CS8603.severity = error +dotnet_diagnostic.CS8604.severity = error +dotnet_diagnostic.CS8618.severity = error +dotnet_diagnostic.CS8625.severity = error + +# Fire-and-forget tasks. +dotnet_diagnostic.CS4014.severity = error +dotnet_diagnostic.VSTHRD110.severity = error +# Don't force the Async suffix. +dotnet_diagnostic.VSTHRD200.severity = none + +# Pass CancellationToken when one is available. +dotnet_diagnostic.CA2016.severity = error + +# Multiple enumeration of IEnumerable. +dotnet_diagnostic.CA1851.severity = error + +# Hygiene. +dotnet_diagnostic.IDE0005.severity = warning +dotnet_diagnostic.IDE0051.severity = warning diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..dd44d01 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @KaliCZ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..569e291 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +version: 2 +updates: + # GitHub Actions — grouped weekly bumps. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: + - "*" + + # Backend — NuGet versions all live in Directory.Packages.props (central + # package management); repo root is the entry point. + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + groups: + nuget: + patterns: + - "*" + + # Frontend — npm packages from frontend/package.json. + - package-ecosystem: npm + directory: /frontend + schedule: + interval: weekly + groups: + npm: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c7263eb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,143 @@ +# Quality gates only — this repo is a demo/template with no deploy stage. +# The backend "linter" is the build itself: analyzers run in-build and +# warnings are errors (Directory.Build.props + .editorconfig). +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', '**/*.csproj') }} + restore-keys: nuget-${{ runner.os }}- + + - name: Restore + run: dotnet restore ProductReviews.slnx + + - name: Build + run: dotnet build ProductReviews.slnx --no-restore -c Release + + # Domain unit + property tests, then wire-level integration tests against + # a real PostgreSQL via Testcontainers (Docker is preinstalled on the runner). + - name: Test + run: dotnet test --no-build -c Release + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Typecheck + run: npm run typecheck + + - name: Unit tests + run: npm run test:unit + + - name: Build + run: npm run build + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Cache NuGet packages + uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', '**/*.csproj') }} + restore-keys: nuget-${{ runner.os }}- + + # Pre-build so the Playwright webServer step spends its timeout on the + # stack (container pulls, Zitadel init), not on compilation. + - name: Build backend + run: dotnet build ProductReviews.slnx + + - name: Install frontend dependencies + run: npm ci + working-directory: frontend + + - name: Get Playwright version + id: playwright-version + run: echo "version=$(node -e "console.log(require('./package-lock.json').packages['node_modules/@playwright/test'].version)")" >> $GITHUB_OUTPUT + working-directory: frontend + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: ./node_modules/.bin/playwright install --with-deps chromium + working-directory: frontend + + # Boots the whole Aspire stack (Postgres, Zitadel + hosted login, API, + # frontend preview) and signs in through the real login form; also fails + # if the committed openapi.json drifted from the running API. + - name: Run E2E tests + run: npm run test:e2e + working-directory: frontend + + - name: Upload Playwright traces + if: "!cancelled()" + uses: actions/upload-artifact@v7 + with: + name: playwright-traces + path: frontend/test-results/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 7282dbf..9d3727a 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,7 @@ CodeCoverage/ *.VisualState.xml TestResult.xml nunit-*.xml + +# Zitadel dev PATs written by the AppHost bind mount +.zitadel/ + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0311e12 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# CLAUDE.md + +Agent instructions for this repository — and the conventions to copy when this +repo is used as a template for a new project. + +## Read the design first + +The docs are the spec; code follows them. Before changing an area, read what +governs it: + +| Changing… | Read first | +| ---------------------------------- | ---------- | +| Anything at all | [docs/technical-requirements.md](docs/technical-requirements.md) | +| User-facing behavior | [docs/business-requirements.md](docs/business-requirements.md) | +| A decision that feels reversible | The matching ADR in [docs/adr/](docs/adr/README.md) — supersede, never edit | +| Validation, DTOs, domain types | [ADR-0002](docs/adr/0002-validation-lives-in-the-type-system.md), [ADR-0003](docs/adr/0003-business-errors-are-result-enums.md) | +| API surface / frontend types | [ADR-0004](docs/adr/0004-openapi-is-the-frontend-contract.md) | +| Auth | [ADR-0005](docs/adr/0005-auth-zitadel-oidc-pkce.md) | +| Tests | [ADR-0007](docs/adr/0007-tests-use-real-dependencies.md) | + +If a change contradicts a doc, change the doc in the same PR — or don't make +the change. + +## Commands + +```shell +dotnet run --project src/ProductReviews.AppHost # the whole stack (Docker required) +dotnet build ProductReviews.slnx # warnings are errors +dotnet test # unit + property + integration (Docker required) +npm --prefix frontend run test:unit # Vitest +npm --prefix frontend run test:e2e # Playwright against the full stack +npm --prefix frontend run typecheck # vue-tsc +npm --prefix frontend run refresh:api # refresh openapi.json + regenerate schema.d.ts (stack must be running) +dotnet ef migrations add --project src/ProductReviews.Domain --output-dir Persistence/Migrations +``` + +Demo sign-in: `demo@productreviews.local` / `ProductReviews123!` (second user: +`critic@productreviews.local`). If Zitadel misbehaves after config changes, +reset it: remove the `productreviews-zitadel` container, the +`productreviews-postgres-data` volume, and the `.zitadel/` directory, then +restart the AppHost. + +## Architecture rules (the non-negotiables) + +1. **Vertical slices.** A feature lives in `Features//` (API) and + `/` (Domain) — one folder each, everything it owns inside. This + applies to infrastructure too: one concern per file + (`Infrastructure/RateLimits.cs`, `Observability.cs`, …) with + `Configure(...)`/`Use(...)` statics. No grab-bag `ServiceExtensions`. +2. **DDD for data and logic.** Entities own their behavior + (`Review.ApplyEdit`, `Product.RefreshRatingSummary`); handlers orchestrate, + never property-spray. One handler file per operation, containing its error + enum. +3. **Controllers, not minimal APIs.** Endpoints are controller actions with + explicit `ProducesResponseType` metadata. +4. **Strong types everywhere; never lose information.** Constrained values use + `Kalicz.StrongTypes` wrappers (or a custom `[NumericWrapper]` like + `Rating`) in DTOs, handler signatures, entities, and responses. Data + annotations and guard clauses re-checking a type's rule are defects. + External input parses with `As…`/`TryCreate` (null = reject); internal + values use `To…`/`Create` (throw = bug). +5. **DTOs are an API-layer contract.** Domain objects are never serialized. + Handlers return domain models + error enums; the controller maps success to + a response DTO and each error enum value to HTTP in an exhaustive `switch` + (no `default` arm — a new enum value must break the build). +6. **Optionality is nullability, and it round-trips.** Required C# property ⇒ + `required` non-nullable in OpenAPI ⇒ required in TypeScript. Three-state + updates (keep / clear / set) use `Maybe?`, never a magic value. +7. **Proof-of-loading reads.** Multi-entity reads project into records whose + constructors demand the loaded data (`ProductWithReviews`, + `ReviewWithVotes` via `CompleteQueries`) — never pass entities around and + hope the navigation was included. +8. **Denormalized aggregates are recomputed, never incremented** — always from + source rows, in the same `SaveChanges`. +9. **Seeding happens at startup** (idempotent, through domain entities), never + in migrations. Migrations are schema-only and tool-generated — never edit + them by hand. +10. **Tests use real dependencies.** No mocking libraries. Integration tests + assert raw JSON from anonymous payloads; property tests generate + strong-typed values (`Kalicz.StrongTypes.FsCheck`). E2E signs in through + the real Zitadel form. +11. **The OpenAPI document is the frontend contract.** Frontend code only + calls the API through the generated `openapi-fetch` client. After changing + any DTO/route: run the stack, `npm --prefix frontend run refresh:api`, + commit `openapi.json` + `schema.d.ts` together with the API change — the + E2E contract test fails otherwise. +12. **Nothing blanket-global.** `[Authorize]` per action (no + `RequireAuthorization()` on the route table), ServiceDefaults in its own + namespace, no global route middleware in the frontend. + +## Naming and style + +- UTC instants end in `Utc` (`CreatedAtUtc`). Identifiers are spelled out — + no abbreviations. +- Private fields are camelCase without an underscore prefix. +- Comments only for a non-obvious *why*, one sentence; when in doubt, none. +- Correctness analyzer rules are build errors (see [.editorconfig](.editorconfig)); + style rules are suggestions. `TreatWarningsAsErrors` is on. +- Central package management: versions live in + [Directory.Packages.props](Directory.Packages.props) only. + +## Where code goes + +| New… | Goes to | +| -------------------------- | ------- | +| Endpoint + DTOs | `src/ProductReviews.Api/Features//` | +| Business operation | `src/ProductReviews.Domain//.cs` (handler + error enum) | +| Entity / value type | `src/ProductReviews.Domain//` | +| EF configuration | `src/ProductReviews.Domain/Persistence/Configurations/` | +| Cross-cutting API concern | `src/ProductReviews.Api/Infrastructure/.cs` | +| Orchestration resource | `src/ProductReviews.AppHost/` | +| Page / component | `frontend/src/pages/` / `frontend/src/components/` | +| Domain test | `tests/ProductReviews.Domain.Tests/` (prefer a property test) | +| API behavior test | `tests/ProductReviews.Api.IntegrationTests/` (wire-level) | diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9803147 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,21 @@ + + + net10.0 + 14.0 + enable + enable + true + true + + true + $(NoWarn);CS1591 + true + + + + + all + runtime; build; native; contentfiles; analyzers + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..8125300 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,45 @@ + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ProductReviews.slnx b/ProductReviews.slnx new file mode 100644 index 0000000..4b02a2a --- /dev/null +++ b/ProductReviews.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/README.md b/README.md index b116f8f..67a44d8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,101 @@ -# StrongTypes-Example -Just example API that uses StrongTypes to showcase how nice and easy it can be. +# StrongTypes Example — Product Reviews + +A complete, runnable showcase for +[Kalicz.StrongTypes](https://github.com/KaliCZ/StrongTypes): a product-reviews +platform where every constrained value is a strong type — from the request DTO +through the domain and the database, out into the OpenAPI document, and all the +way into the generated TypeScript client. No data annotations, no guard +clauses, no hand-written frontend types. + +It doubles as a **starter template** for full-stack .NET web apps: +ASP.NET Core (controllers) + EF Core/PostgreSQL + .NET Aspire + Zitadel auth + +Vue 3, with spec-driven docs, ADRs, and a no-mocks test pyramid. The +conventions are documented in [CLAUDE.md](CLAUDE.md) and +[docs/](docs/technical-requirements.md). + +## Quickstart + +Prerequisites: + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- [Node.js 20+](https://nodejs.org/) +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (running) + +One command: + +```shell +dotnet run --project src/ProductReviews.AppHost +``` + +(or `aspire run` if you have the [Aspire CLI](https://learn.microsoft.com/dotnet/aspire/cli/overview)). + +The AppHost starts PostgreSQL, Zitadel (identity provider + hosted login), the +API, and the frontend; installs frontend dependencies on first run; applies +migrations; and seeds a browsable catalog. First start pulls container images — +give it a few minutes; subsequent starts are fast. + +Where to click: + +- **The app: ** — browse the catalog, open a product, + read reviews. +- **Sign in** to write reviews and vote: `demo@productreviews.local` / + `ProductReviews123!` (a second account, `critic@productreviews.local`, same + password, lets you vote on the first account's review — you can never vote + on your own). +- **Swagger UI: ** — look at + `SubmitReviewRequest`: `rating` carries `minimum: 1, maximum: 5`, `title` + carries `minLength: 1`, `email` on the profile carries `format: email`. + Nothing in the codebase states those rules except the types themselves. +- **Aspire dashboard** — the URL is printed on startup; logs, traces, and + resource states live there. + +## What to look at (the StrongTypes tour) + +| The point | Where | +| --- | --- | +| DTOs with strong types, zero annotations | [ReviewContracts.cs](src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs) | +| `Maybe` three-state PATCH (omit = keep, `{}` = clear, `{"Value": …}` = set) | [EditReviewRequest](src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs), [Review.ApplyEdit](src/ProductReviews.Domain/Reviews/Review.cs), [EditReview.cs](src/ProductReviews.Domain/Reviews/EditReview.cs) | +| `Result` + error enums instead of exceptions | [SubmitReview.cs](src/ProductReviews.Domain/Reviews/SubmitReview.cs) → mapped exhaustively in [ReviewsController.cs](src/ProductReviews.Api/Features/Reviews/ReviewsController.cs) | +| Declaring your **own** strong type (`[NumericWrapper]`) | [Rating.cs](src/ProductReviews.Domain/Reviews/Rating.cs) | +| EF Core storing strong types directly (`UseStrongTypes()`) | [Persistence.cs](src/ProductReviews.Api/Infrastructure/Persistence.cs), [ReviewsDbContext.cs](src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs), the [migration](src/ProductReviews.Domain/Persistence/Migrations/) (plain `varchar`/`int` columns) | +| OpenAPI carrying the real constraints (`AddStrongTypes()`) | [OpenApi.cs](src/ProductReviews.Api/Infrastructure/OpenApi.cs), snapshot in [frontend/openapi.json](frontend/openapi.json) | +| Constraints flowing into TypeScript (generated client) | [schema.d.ts](frontend/src/api/schema.d.ts) (generated), [client.ts](frontend/src/api/client.ts), [editReviewBody.ts](frontend/src/api/editReviewBody.ts) | +| Property-based tests with generated strong-typed values | [RatingTests.cs](tests/ProductReviews.Domain.Tests/RatingTests.cs), [ReviewEditTests.cs](tests/ProductReviews.Domain.Tests/ReviewEditTests.cs) | +| The OpenAPI claims verified against the running API | [OpenApiDocumentTests.cs](tests/ProductReviews.Api.IntegrationTests/OpenApiDocumentTests.cs) | +| Parse-don't-validate at the query boundary (`Positive` paging, `Rating[]` filters) | [ReviewsController.cs](src/ProductReviews.Api/Features/Reviews/ReviewsController.cs), [GetReviewsPage.cs](src/ProductReviews.Domain/Reviews/GetReviewsPage.cs) | + +## Tests + +```shell +# Backend: domain unit + property tests, then wire-level integration tests +# (Testcontainers spins up a real PostgreSQL — Docker must be running) +dotnet test + +# Frontend unit tests (Vitest) +npm --prefix frontend run test:unit + +# End-to-end: builds the frontend, boots the WHOLE stack in E2E mode, and +# drives real browsing, sign-in, reviewing, and voting (Playwright) +npm --prefix frontend run test:e2e +``` + +Nothing is mocked anywhere — see +[ADR-0007](docs/adr/0007-tests-use-real-dependencies.md). + +## The spec + +The implementation follows the docs, not the other way around: + +- [Business requirements](docs/business-requirements.md) — what the app does, + in product terms. +- [Technical requirements](docs/technical-requirements.md) — architecture, + stack, conventions. +- [ADRs](docs/adr/README.md) — the pivotal decisions, one aspect each. +- [Assumptions to validate](docs/assumptions.md) — where the brief allowed + more than one reading. + +## Links + +- The library: +- The idea, explained: [Zero-Code Validations in Your .NET API](https://www.kalandra.tech/blog/zero-code-validations-in-your-dotnet-api/) +- NuGet: [Kalicz.StrongTypes](https://www.nuget.org/packages/Kalicz.StrongTypes) diff --git a/docs/adr/0001-vertical-slices-and-project-layout.md b/docs/adr/0001-vertical-slices-and-project-layout.md new file mode 100644 index 0000000..906f856 --- /dev/null +++ b/docs/adr/0001-vertical-slices-and-project-layout.md @@ -0,0 +1,72 @@ +# ADR-0001 — Vertical feature slices in two projects; controllers, no MediatR + +**Status:** Accepted (2026-07-15) + +## Context + +The codebase has two jobs: demonstrate StrongTypes end to end, and serve as a +starter template. Both jobs punish accidental complexity. A classic onion +(`Api` → `Application` → `Domain` → `Infrastructure` + repositories + +MediatR) scatters one feature across four projects and buries the +StrongTypes story under ceremony; a single flat project loses the +compiler-enforced rule that HTTP concerns never leak into business code. + +## Decision + +Two application projects with one compiler-enforced dependency edge, both +organized **by feature, not by layer**: + +- **`ProductReviews.Api`** — the HTTP boundary. `Features//` holds + the controller, its request/response DTOs, and the mapping between DTOs and + domain models. `Infrastructure/` holds one file per cross-cutting concern + (`Authentication.cs`, `OpenApi.cs`, `RateLimits.cs`, …), each a static class + with `Configure(...)`/`Use(...)` — `Program.cs` is a thin orchestrator and + there is no grab-bag `ServiceCollectionExtensions`. +- **`ProductReviews.Domain`** — everything else. `/` folders hold the + entities and one file per operation (`SubmitReview.cs` = handler class + + its error enum). `Persistence/` holds the `DbContext`, entity + configurations, migrations, and the seeder. EF Core is used directly as a + library — no repository interfaces wrapping it. +- **Controllers, not minimal APIs** — controller classes group a feature's + endpoints, give Swashbuckle first-class metadata, and keep binding, + auth attributes, and `ProducesResponseType` declarations in one reviewable + place per feature. +- **No MediatR / no dispatch layer.** Controllers inject concrete handler + classes. A handler is a class with one public method; DI registration is a + per-project `AddDomain()` extension. +- **DTOs are a dedicated API-layer contract.** Domain entities and read + models are never serialized. Handlers return domain models + (`Result`); the controller maps success models + to response DTOs and error enums to HTTP — both directions live in the API + slice. +- **Read paths return proof-of-loading models, not entities.** A query + handler owns a single query method with its `Include` chain and projects + into an immutable record whose constructor requires every loaded + relationship (e.g. `ReviewWithViewerContext`). If it isn't loaded, the + model cannot be constructed — no lazy-loading surprises, no + possibly-null navigations downstream. + +## Consequences + +- A feature is one folder in each of two projects; deleting a feature is + deleting two folders. Review diffs stay feature-local. +- The `Api → Domain` project reference makes "domain never sees HTTP" + structural; the missing reverse reference makes "HTTP never bypasses the + domain" reviewable at a glance. +- No runtime dispatch magic: navigation is F12, stack traces are honest, and + the template stays approachable for readers new to the stack. +- The cost of not abstracting EF: swapping the persistence technology means + touching domain handlers. Accepted — the demo's point is EF storing strong + types directly. + +## Alternatives considered + +- **Onion/Clean Architecture with repositories** — rejected: doubles the file + count per feature and hides the `UseStrongTypes()` EF integration behind + interfaces, which is the opposite of a showcase. +- **Minimal APIs** — rejected: endpoint metadata and per-route filters get + re-invented per endpoint, and the controller + `ModelState` + + `ValidationProblem` pipeline is exactly what the StrongTypes ASP.NET + integration targets. +- **MediatR** — rejected: indirection without benefit at this scale, and an + extra dependency for a template that wants to stay legible. diff --git a/docs/adr/0002-validation-lives-in-the-type-system.md b/docs/adr/0002-validation-lives-in-the-type-system.md new file mode 100644 index 0000000..730eac0 --- /dev/null +++ b/docs/adr/0002-validation-lives-in-the-type-system.md @@ -0,0 +1,51 @@ +# ADR-0002 — Validation lives in the type system, not in annotations or guards + +**Status:** Accepted (2026-07-15) + +## Context + +The same input rule ("rating is 1–5", "title is not blank") tends to get +written three times: as a data annotation on the DTO, as a guard clause in the +controller, and again in the business layer because it can be called from +elsewhere. The three copies drift, and none of them changes the *type* — after +the check passes, the value is still a bare `string` or `int` and every layer +below has to trust or re-check it. + +This application exists to demonstrate the alternative: +[Kalicz.StrongTypes](https://github.com/KaliCZ/StrongTypes) types that make +the invalid state unrepresentable ("parse, don't validate"). + +## Decision + +Every constrained value is a strong type from the boundary inward, and the +constraint is expressed **nowhere else**: + +- Request and response DTOs use `Email`, `NonEmptyString`, `Positive`, + `NonNegative`, `Rating` (our own `[NumericWrapper]` type), … — never a + raw primitive with a data annotation. Invalid JSON fails deserialization and + becomes an automatic 400 `ValidationProblemDetails` before the action runs. +- **No data annotations, no guard clauses.** A `[Range]`, `[Required]`, + `[EmailAddress]`, `ArgumentException.ThrowIfNullOrEmpty`, or + `if (x <= 0) throw` re-checking a rule a strong type already carries is a + defect. +- Business methods and entities take and hold strong types. EF Core stores + them directly (`.UseStrongTypes()` on the options builder) so a loaded + entity carries the same guarantees as a parsed request. +- Construction follows the library's intent split: `input.AsNonEmpty()` + (nullable result) for external input where invalid means "reject", and + `input.ToNonEmpty()` (throws) for internal values where invalid means "bug". +- Rules the type system cannot carry (uniqueness, ownership, cross-entity + rules) are business logic and follow ADR-0003. + +## Consequences + +- A rule exists in exactly one place — the type — and holds in the API, + the domain, the database, and (via ADR-0004) the generated frontend client. +- Method signatures are the documentation: `Task> + Handle(ProductId, AuthorId, Rating, NonEmptyString title, …)` states every + precondition. +- Unit tests for "what if the title is empty" disappear; the case does not + type-check. Tests that remain are about behavior. +- The cost: contributors must learn the small StrongTypes API surface + (`Create` throws / `TryCreate` returns null / `As…`–`To…` extensions), and + LINQ-to-SQL occasionally needs an explicit `.Unwrap()`. diff --git a/docs/adr/0003-business-errors-are-result-enums.md b/docs/adr/0003-business-errors-are-result-enums.md new file mode 100644 index 0000000..f47c488 --- /dev/null +++ b/docs/adr/0003-business-errors-are-result-enums.md @@ -0,0 +1,38 @@ +# ADR-0003 — Business failures are enum values in `Result`, not exceptions + +**Status:** Accepted (2026-07-15) + +## Context + +Domain operations can fail for legitimate business reasons: the product does +not exist, the reviewer already reviewed it, you cannot vote on your own +review. Modeling those as exceptions hides them from the compiler — a caller +cannot see from the signature which failures exist, and controllers end up +with `try/catch` ladders that must be kept in sync with the service by hand. + +## Decision + +- Every domain handler that can fail returns + `Result` where `TError` is a **per-operation enum** + (`SubmitReviewError`, `CastVoteError`, …). Success and error are returned by + implicit conversion (`return review;` / `return SubmitReviewError.ProductNotFound;`). +- Exceptions are reserved for bugs and infrastructure faults — never for + business outcomes. +- **Controllers own the HTTP translation.** A controller consumes the result + with `result.Error is { } error` and maps each enum value in an exhaustive + `switch` to the proper response: `ValidationProblem` with a field-scoped + `ModelState` error for input-shaped failures, `NotFound`/`Forbid`/`Conflict` + where those semantics fit. The domain never sees HTTP. +- Handlers never return domain entities to the controller for serialization; + they return a success model, and the controller maps it to a response DTO + (see ADR-0001 for the DTO layer). + +## Consequences + +- The set of possible failures of an operation is part of its signature; a new + enum value fails compilation at every non-exhaustive `switch` that maps it. +- No `try/catch` in controllers; no custom exception types in the domain. +- Each error enum value appears in exactly one HTTP mapping, so the API's + error behavior is reviewable in one file per feature. +- Minor duplication: each feature carries its own small enum instead of a + shared error catalogue — deliberate, so slices stay independent. diff --git a/docs/adr/0004-openapi-is-the-frontend-contract.md b/docs/adr/0004-openapi-is-the-frontend-contract.md new file mode 100644 index 0000000..0742e13 --- /dev/null +++ b/docs/adr/0004-openapi-is-the-frontend-contract.md @@ -0,0 +1,40 @@ +# ADR-0004 — The OpenAPI document is the frontend contract + +**Status:** Accepted (2026-07-15) + +## Context + +The frontend needs types for every request and response. Hand-written +TypeScript interfaces drift silently from the API, and they cannot carry the +constraints the backend enforces. The backend already knows its exact wire +shape — including the strong-type constraints — because Swashbuckle renders it +into an OpenAPI document. + +## Decision + +- The API exposes its OpenAPI document via **Swashbuckle** with + `options.AddStrongTypes()` from `Kalicz.StrongTypes.OpenApi.Swashbuckle`, so + the schema carries the real constraints: `Email` → `format: email` + + `maxLength: 254`, `NonEmptyString` → `minLength: 1`, `Positive` → + `minimum: 0, exclusiveMinimum: true`, our `Rating` → `minimum: 1, maximum: 5`. +- **Nullability is truth.** A property is `nullable`/optional in the schema if + and only if it is optional in the C# DTO. Required DTO properties are + non-nullable and `required` in the schema; no information is lost between + C# and the schema. +- The frontend's client types are **generated** from that document with + `openapi-typescript` and consumed through `openapi-fetch` — no hand-written + request/response types anywhere in the frontend. +- The generated file is committed. A test regenerates it against the running + API and fails on any diff, so contract drift breaks the build instead of + breaking users. + +## Consequences + +- One source of truth: change a DTO and the compiler + the drift test walk the + change all the way into the frontend. +- Frontend code gets compile-time errors for wrong paths, wrong verbs, missing + parameters, and mis-typed bodies. +- Constraints are visible to API consumers in Swagger UI — the demo's payoff. +- The OpenAPI document must stay accurate; anything that would degrade it + (untyped `IActionResult`, missing `ProducesResponseType`) is a review + blocker. diff --git a/docs/adr/0005-auth-zitadel-oidc-pkce.md b/docs/adr/0005-auth-zitadel-oidc-pkce.md new file mode 100644 index 0000000..f64028b --- /dev/null +++ b/docs/adr/0005-auth-zitadel-oidc-pkce.md @@ -0,0 +1,43 @@ +# ADR-0005 — Auth is Zitadel OIDC + PKCE; author identity is a hash of `sub` + +**Status:** Accepted (2026-07-15) + +## Context + +Writing reviews and voting require a signed-in user (business requirement +"Access"), so the demo needs a real identity provider that runs locally with +zero manual setup. The frontend is a browser SPA (ADR-0008) with no backend of +its own, so it cannot keep a client secret or host a BFF token exchange. + +## Decision + +- **Zitadel** runs as a container orchestrated by the Aspire AppHost, using + its own logical database on the shared Postgres server. On first start the + AppHost provisions — idempotently, via Zitadel's API — an organization, + a project, a **public PKCE SPA client** (authorization code + PKCE, no + secret, JWT access tokens), and a demo human user whose credentials the + README documents. +- The SPA signs in with `oidc-client-ts` using the standard redirect flow to + Zitadel's hosted login page, and sends the JWT access token as a Bearer + header on write requests. +- The API validates tokens with plain `JwtBearer` against Zitadel's discovery + document and JWKS — issuer and audience validated, no shared secrets. + Authorization is applied **per endpoint** with `[Authorize]` on write + actions; reads stay anonymous. There is no blanket + `RequireAuthorization()`. +- **Author identity** is `AuthorId`: a Guid derived from the SHA-256 hash of + the token's `sub` claim (first 16 bytes). The database stays Guid-keyed + regardless of the identity provider's id format, and no identity-provider + key leaks into domain tables. The display name is snapshotted from the + `name` claim at write time. + +## Consequences + +- `aspire run` gives a fully working login with no manual Zitadel clicking; + the demo user works immediately. +- Tokens live in the browser (in-memory via `oidc-client-ts`) — acceptable + for a demo/template; a production system wanting cookie sessions would add a + BFF, which this decision deliberately leaves out. +- Swapping the identity provider later only touches the AppHost provisioning + and the authority URL; domain data is unaffected because of the `sub` hash. +- E2E tests exercise the real login form — auth is never bypassed or faked. diff --git a/docs/adr/0006-seeding-at-startup-not-migrations.md b/docs/adr/0006-seeding-at-startup-not-migrations.md new file mode 100644 index 0000000..0aa5aba --- /dev/null +++ b/docs/adr/0006-seeding-at-startup-not-migrations.md @@ -0,0 +1,36 @@ +# ADR-0006 — Seed data runs at startup, never inside migrations + +**Status:** Accepted (2026-07-15) + +## Context + +The demo must be browsable the moment the stack is up, which means the +database needs products and reviews without anyone clicking. EF Core offers +two tempting places for that data: `HasData` inside the model (which turns +content edits into schema migrations) or hand-written `INSERT`s inside a +migration (which welds demo content to schema history and re-runs it nowhere). +Both make seed data a schema concern, which it is not. + +## Decision + +- Migrations contain **schema only**. +- Seeding is an explicit, **idempotent** step that runs at API startup in + Development, after `MigrateAsync()`: if any product exists, the seeder does + nothing; otherwise it inserts the demo catalog (products, reviews from a + cast of fictional authors, and votes) through the domain entities — so seed + data passes the same strong-type invariants as user input. +- Seed reviews are back-dated and pre-voted so sorting by "most helpful" and + "newest" is meaningfully demonstrable on first run. +- Production (not applicable to this demo, but the template stance): the API + neither migrates nor seeds at startup; migrations are applied by the deploy + pipeline. + +## Consequences + +- `aspire run` on a fresh machine ends in a populated, clickable catalog. +- Changing demo content is a normal code change — no migration churn, no + model snapshot noise. +- Because seeding constructs real domain objects, it cannot silently insert + data that violates an invariant the app enforces. +- Integration tests get a clean database per run and call the same seeder when + a test needs the catalog. diff --git a/docs/adr/0007-tests-use-real-dependencies.md b/docs/adr/0007-tests-use-real-dependencies.md new file mode 100644 index 0000000..0ec4f7b --- /dev/null +++ b/docs/adr/0007-tests-use-real-dependencies.md @@ -0,0 +1,42 @@ +# ADR-0007 — Tests exercise real dependencies; nothing is mocked + +**Status:** Accepted (2026-07-15) + +## Context + +Mock-heavy test suites verify that code calls its collaborators, not that the +system works: they encode today's implementation into the tests and go green +while real queries, mappings, converters, and constraints break. This codebase +leans on exactly those integration points — EF value converters for strong +types, LINQ translation, unique indexes, JSON converters — none of which a +mock exercises. + +## Decision + +- **No mocking libraries.** No Moq, NSubstitute, or hand-rolled fakes of our + own interfaces. +- **Unit tests** cover code that has no dependencies to fake in the first + place: entity behavior, invariants, pure logic. Property-based tests via + FsCheck + `Kalicz.StrongTypes.FsCheck` generate valid strong-typed inputs so + invariants are checked across hundreds of values, not three examples. +- **Integration tests** boot the real API with `WebApplicationFactory` + against a real **PostgreSQL in Testcontainers** — real migrations, real + seeder, real JSON pipeline, real EF translation. Auth uses locally minted + JWTs against the API's real JwtBearer validation (test-owned signing key), + because starting Zitadel per test run buys nothing the E2E suite doesn't + already cover. +- Integration tests assert at the **wire level**: they post anonymous JSON + objects (not the API's DTO classes) and read raw JSON, so an accidental + contract rename fails a test instead of silently tracking. +- **Frontend**: Vitest component tests for view logic; Playwright E2E drives + the full Aspire-orchestrated stack — real Zitadel login included. + +## Consequences + +- A green suite means the wire format, the SQL, and the constraints actually + work — the strong-type value converters and OpenAPI claims are tested, not + assumed. +- Tests need Docker; CI and contributors must have it. Accepted. +- Integration tests are slower than mock tests; the suite stays fast enough by + sharing one Postgres container per test run and isolating via unique data + per test, not per-test containers. diff --git a/docs/adr/0008-frontend-vue-spa.md b/docs/adr/0008-frontend-vue-spa.md new file mode 100644 index 0000000..1935a2b --- /dev/null +++ b/docs/adr/0008-frontend-vue-spa.md @@ -0,0 +1,37 @@ +# ADR-0008 — The frontend is a Vue 3 + Vite SPA, not a server-rendered app + +**Status:** Accepted (2026-07-15) + +## Context + +The frontend's only job is to demonstrate that strong-type constraints flow +from the C# DTOs through OpenAPI into TypeScript, and to give the demo a +pleasant UI. All logic lives in the API. A server-rendered frontend (Nuxt) +would add a second server runtime, a BFF layer, and SSR concerns — none of +which showcase anything about StrongTypes — while React is simply not the +house preference (Vue reads closer to an MVC view layer). + +## Decision + +- **Vue 3 + Vite + TypeScript**, single-page app, ` + + diff --git a/frontend/openapi.json b/frontend/openapi.json new file mode 100644 index 0000000..a47753a --- /dev/null +++ b/frontend/openapi.json @@ -0,0 +1,1127 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "ProductReviews.Api", + "version": "1.0" + }, + "paths": { + "/api/products": { + "get": { + "tags": [ + "Catalog" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductSummaryResponse" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductSummaryResponse" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductSummaryResponse" + } + } + } + } + } + } + } + }, + "/api/products/{slug}": { + "get": { + "tags": [ + "Catalog" + ], + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProductDetailResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductDetailResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProductDetailResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/me": { + "get": { + "tags": [ + "Profile" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProfileResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProfileResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/products/{slug}/reviews": { + "get": { + "tags": [ + "Reviews" + ], + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "$ref": "#/components/schemas/ReviewSortOption" + } + }, + { + "name": "ratings", + "in": "query", + "schema": { + "type": "array", + "items": { + "maximum": 5, + "minimum": 1, + "type": "integer", + "format": "int32" + } + } + }, + { + "name": "page", + "in": "query", + "schema": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "integer", + "format": "int32" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ReviewsPageResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewsPageResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReviewsPageResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": [ + "Reviews" + ], + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitReviewRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SubmitReviewRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/SubmitReviewRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/reviews/{id}": { + "patch": { + "tags": [ + "Reviews" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditReviewRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/EditReviewRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/EditReviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Reviews" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "403": { + "description": "Forbidden", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/reviews/{id}/vote": { + "put": { + "tags": [ + "Votes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CastVoteRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CastVoteRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CastVoteRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/VoteResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoteResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/VoteResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Votes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/VoteResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoteResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/VoteResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CastVoteRequest": { + "required": [ + "isUpvote" + ], + "type": "object", + "properties": { + "isUpvote": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "EditReviewRequest": { + "type": "object", + "properties": { + "rating": { + "maximum": 5, + "minimum": 1, + "type": "integer", + "format": "int32", + "nullable": true + }, + "title": { + "minLength": 1, + "type": "string", + "nullable": true + }, + "body": { + "minLength": 1, + "type": "string", + "nullable": true + }, + "pros": { + "type": "object", + "properties": { + "Value": { + "minLength": 1, + "type": "string" + } + } + }, + "cons": { + "type": "object", + "properties": { + "Value": { + "minLength": 1, + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": { } + }, + "ProductDetailResponse": { + "required": [ + "description", + "id", + "name", + "reviewCount", + "slug" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "description": { + "minLength": 1, + "type": "string" + }, + "imageUrl": { + "minLength": 1, + "type": "string", + "nullable": true + }, + "reviewCount": { + "minimum": 0, + "exclusiveMinimum": false, + "type": "integer", + "format": "int32" + }, + "averageRating": { + "type": "number", + "format": "double", + "nullable": true + }, + "myReviewId": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, + "ProductSummaryResponse": { + "required": [ + "id", + "name", + "reviewCount", + "slug" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "imageUrl": { + "minLength": 1, + "type": "string", + "nullable": true + }, + "reviewCount": { + "minimum": 0, + "exclusiveMinimum": false, + "type": "integer", + "format": "int32" + }, + "averageRating": { + "type": "number", + "format": "double", + "nullable": true + } + }, + "additionalProperties": false + }, + "ProfileResponse": { + "required": [ + "authorId", + "displayName" + ], + "type": "object", + "properties": { + "displayName": { + "minLength": 1, + "type": "string" + }, + "email": { + "maxLength": 254, + "minLength": 1, + "type": "string", + "format": "email", + "nullable": true + }, + "authorId": { + "type": "string", + "format": "uuid" + } + }, + "additionalProperties": false + }, + "ReviewResponse": { + "required": [ + "authorName", + "body", + "createdAtUtc", + "id", + "mine", + "rating", + "score", + "title" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "rating": { + "maximum": 5, + "minimum": 1, + "type": "integer", + "format": "int32" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "body": { + "minLength": 1, + "type": "string" + }, + "pros": { + "minLength": 1, + "type": "string", + "nullable": true + }, + "cons": { + "minLength": 1, + "type": "string", + "nullable": true + }, + "authorName": { + "minLength": 1, + "type": "string" + }, + "score": { + "type": "integer", + "format": "int32" + }, + "mine": { + "type": "boolean" + }, + "myVote": { + "type": "boolean", + "nullable": true + }, + "createdAtUtc": { + "type": "string", + "format": "date-time" + }, + "updatedAtUtc": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "additionalProperties": false + }, + "ReviewSortOption": { + "enum": [ + "MostHelpful", + "Newest", + "HighestRating", + "LowestRating" + ], + "type": "string" + }, + "ReviewsPageResponse": { + "required": [ + "items", + "page", + "pageSize", + "totalCount" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReviewResponse" + } + }, + "totalCount": { + "minimum": 0, + "exclusiveMinimum": false, + "type": "integer", + "format": "int32" + }, + "page": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "integer", + "format": "int32" + }, + "pageSize": { + "minimum": 0, + "exclusiveMinimum": true, + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "SubmitReviewRequest": { + "required": [ + "body", + "rating", + "title" + ], + "type": "object", + "properties": { + "rating": { + "maximum": 5, + "minimum": 1, + "type": "integer", + "format": "int32" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "body": { + "minLength": 1, + "type": "string" + }, + "pros": { + "minLength": 1, + "type": "string", + "nullable": true + }, + "cons": { + "minLength": 1, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ValidationProblemDetails": { + "required": [ + "errors" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "additionalProperties": { } + }, + "VoteResponse": { + "required": [ + "score" + ], + "type": "object", + "properties": { + "score": { + "type": "integer", + "format": "int32" + }, + "myVote": { + "type": "boolean", + "nullable": true + } + }, + "additionalProperties": false + } + } + }, + "tags": [ + { + "name": "Catalog" + }, + { + "name": "Profile" + }, + { + "name": "Reviews" + }, + { + "name": "Votes" + } + ] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..13de6c2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3443 @@ +{ + "name": "productreviews-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "productreviews-frontend", + "dependencies": { + "oidc-client-ts": "^3.5.0", + "openapi-fetch": "^0.17.0", + "pinia": "^3.0.4", + "vue": "^3.5.39", + "vue-router": "^5.1.0" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.8", + "@vue/test-utils": "^2.4.11", + "happy-dom": "^20.10.6", + "openapi-typescript": "^7.13.0", + "prettier": "3.9.5", + "typescript": "~5.9.3", + "vite": "^8.1.4", + "vitest": "^4.1.10", + "vue-tsc": "^3.3.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.17", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz", + "integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.2.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.3.tgz", + "integrity": "sha512-pphnexn8CDKugcA4TYSKlg1XanBYPbILST+eZK9ZGqG8sVbNR5L0kXEpRqs8+iSznosHt/Jo2k1FGl0tnWIpyg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.7.tgz", + "integrity": "sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", + "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "ast-kit": "^2.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/happy-dom": { + "version": "20.10.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.6.tgz", + "integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.7.tgz", + "integrity": "sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz", + "integrity": "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^8.0.0-rc.4", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.1.2", + "ast-walker-scope": "^0.9.0", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.16", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34", + "pinia": "^3.0.4", + "vite": "^7.0.0 || ^8.0.0", + "vue": "^3.5.34" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz", + "integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.5" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-kit": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz", + "integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.5", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-shared": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz", + "integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==", + "license": "MIT" + }, + "node_modules/vue-router/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.7.tgz", + "integrity": "sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.7" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7f7c267 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "productreviews-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "serve:e2e": "vite build && vite preview", + "typecheck": "vue-tsc --noEmit", + "format": "prettier --write .", + "format:check": "prettier --check .", + "generate:api": "openapi-typescript openapi.json -o src/api/schema.d.ts", + "refresh:api": "node scripts/refresh-openapi.mjs && npm run generate:api", + "test": "vitest run", + "test:unit": "vitest run", + "test:e2e": "playwright test" + }, + "dependencies": { + "oidc-client-ts": "^3.5.0", + "openapi-fetch": "^0.17.0", + "pinia": "^3.0.4", + "vue": "^3.5.39", + "vue-router": "^5.1.0" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.8", + "@vue/test-utils": "^2.4.11", + "happy-dom": "^20.10.6", + "openapi-typescript": "^7.13.0", + "prettier": "3.9.5", + "typescript": "~5.9.3", + "vite": "^8.1.4", + "vitest": "^4.1.10", + "vue-tsc": "^3.3.7" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..3dbac2e --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "@playwright/test"; + +// The E2E stack is the real thing: the Aspire AppHost boots Postgres, Zitadel (+ its +// hosted login), the API, and this frontend as a production preview build on :4173. +// Sign-in happens through the real Zitadel form — no bypass (ADR-0007). +const webBaseUrl = "http://localhost:4173"; + +export default defineConfig({ + testDir: "tests/e2e", + fullyParallel: false, + workers: 1, + timeout: 120_000, + expect: { timeout: 15_000 }, + use: { + baseURL: webBaseUrl, + trace: "retain-on-failure", + }, + webServer: { + // The AppHost builds the frontend itself (serve:e2e) so the Aspire-injected + // VITE_OIDC_* env is present at build time — do not pre-build here. + command: "dotnet run --project ../src/ProductReviews.AppHost", + url: `${webBaseUrl}/api/products`, + reuseExistingServer: true, + timeout: 600_000, + env: { ProductReviews__E2E: "true" }, + }, +}); diff --git a/frontend/scripts/refresh-openapi.mjs b/frontend/scripts/refresh-openapi.mjs new file mode 100644 index 0000000..90dbfc0 --- /dev/null +++ b/frontend/scripts/refresh-openapi.mjs @@ -0,0 +1,16 @@ +// Refreshes the committed OpenAPI snapshot from a running API (default: the dev +// stack's frontend origin, which proxies /swagger). Follow with generate:api — +// `npm run refresh:api` does both. +import { writeFile } from "node:fs/promises"; + +const sourceUrl = process.env.OPENAPI_SOURCE_URL ?? "http://localhost:5173/swagger/v1/swagger.json"; + +const response = await fetch(sourceUrl); +if (!response.ok) { + console.error(`Could not fetch ${sourceUrl} (${response.status}). Is the stack running (aspire run)?`); + process.exit(1); +} + +const document = await response.text(); +await writeFile(new URL("../openapi.json", import.meta.url), document); +console.log(`openapi.json refreshed from ${sourceUrl}`); diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..e7fe27e --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..3f45ab5 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,33 @@ +import createClient, { type Middleware } from "openapi-fetch"; +import type { components, paths } from "./schema"; + +// Convenience aliases over the generated schema — the ONLY source of API types (ADR-0004). +export type ProductSummary = components["schemas"]["ProductSummaryResponse"]; +export type ProductDetail = components["schemas"]["ProductDetailResponse"]; +export type Review = components["schemas"]["ReviewResponse"]; +export type ReviewsPage = components["schemas"]["ReviewsPageResponse"]; +export type SubmitReviewRequest = components["schemas"]["SubmitReviewRequest"]; +export type EditReviewRequest = components["schemas"]["EditReviewRequest"]; +export type VoteResult = components["schemas"]["VoteResponse"]; +export type ReviewSortOption = components["schemas"]["ReviewSortOption"]; + +// Set once at startup; kept as a callback so this module never imports the auth store. +let tokenProvider: (() => string | null) | null = null; + +export function setAuthTokenProvider(provider: () => string | null): void { + tokenProvider = provider; +} + +const authorization: Middleware = { + onRequest({ request }) { + const token = tokenProvider?.(); + if (token) { + request.headers.set("Authorization", `Bearer ${token}`); + } + return request; + }, +}; + +/** Typed client over the generated schema; calls go to the same-origin `/api` proxy. */ +export const api = createClient({ baseUrl: "/" }); +api.use(authorization); diff --git a/frontend/src/api/editReviewBody.ts b/frontend/src/api/editReviewBody.ts new file mode 100644 index 0000000..9a9c683 --- /dev/null +++ b/frontend/src/api/editReviewBody.ts @@ -0,0 +1,44 @@ +import type { EditReviewRequest, Review } from "./client"; + +export interface ReviewFormValues { + rating: number; + title: string; + body: string; + pros: string; + cons: string; +} + +/** + * Builds the PATCH body from what actually changed — the three-state `Maybe` contract + * from the OpenAPI schema (see ADR-0002 and the EditReviewRequest DTO): + * - field omitted → leave unchanged + * - `{}` (Maybe.None) → clear the optional field + * - `{ Value: "…" }` → set the optional field + * Required fields (rating/title/body) can only be changed, never cleared, so they use + * plain optional properties. + */ +export function buildEditReviewBody(original: Review, edited: ReviewFormValues): EditReviewRequest { + const body: EditReviewRequest = {}; + + if (edited.rating !== original.rating) { + body.rating = edited.rating; + } + if (edited.title !== original.title) { + body.title = edited.title; + } + if (edited.body !== original.body) { + body.body = edited.body; + } + if (edited.pros !== (original.pros ?? "")) { + body.pros = edited.pros === "" ? {} : { Value: edited.pros }; + } + if (edited.cons !== (original.cons ?? "")) { + body.cons = edited.cons === "" ? {} : { Value: edited.cons }; + } + + return body; +} + +export function hasChanges(request: EditReviewRequest): boolean { + return Object.keys(request).length > 0; +} diff --git a/frontend/src/api/problem.ts b/frontend/src/api/problem.ts new file mode 100644 index 0000000..54c6673 --- /dev/null +++ b/frontend/src/api/problem.ts @@ -0,0 +1,19 @@ +/** Turns an RFC 7807 problem (plain or validation) into a single displayable message. */ +export function problemMessage(problem: unknown): string { + if (typeof problem === "object" && problem !== null) { + const details = problem as { errors?: Record; detail?: string; title?: string }; + if (details.errors) { + const firstField = Object.values(details.errors).find((messages) => messages.length > 0); + if (firstField?.[0]) { + return firstField[0]; + } + } + if (details.detail) { + return details.detail; + } + if (details.title) { + return details.title; + } + } + return "Something went wrong. Please try again."; +} diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts new file mode 100644 index 0000000..93c0988 --- /dev/null +++ b/frontend/src/api/schema.d.ts @@ -0,0 +1,604 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/products": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProductSummaryResponse"][]; + "application/json": components["schemas"]["ProductSummaryResponse"][]; + "text/json": components["schemas"]["ProductSummaryResponse"][]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/products/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProductDetailResponse"]; + "application/json": components["schemas"]["ProductDetailResponse"]; + "text/json": components["schemas"]["ProductDetailResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProfileResponse"]; + "application/json": components["schemas"]["ProfileResponse"]; + "text/json": components["schemas"]["ProfileResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/products/{slug}/reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + sort?: components["schemas"]["ReviewSortOption"]; + ratings?: number[]; + page?: number; + pageSize?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ReviewsPageResponse"]; + "application/json": components["schemas"]["ReviewsPageResponse"]; + "text/json": components["schemas"]["ReviewsPageResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SubmitReviewRequest"]; + "text/json": components["schemas"]["SubmitReviewRequest"]; + "application/*+json": components["schemas"]["SubmitReviewRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ReviewResponse"]; + "application/json": components["schemas"]["ReviewResponse"]; + "text/json": components["schemas"]["ReviewResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ValidationProblemDetails"]; + "application/json": components["schemas"]["ValidationProblemDetails"]; + "text/json": components["schemas"]["ValidationProblemDetails"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/reviews/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + options?: never; + head?: never; + patch: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["EditReviewRequest"]; + "text/json": components["schemas"]["EditReviewRequest"]; + "application/*+json": components["schemas"]["EditReviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ReviewResponse"]; + "application/json": components["schemas"]["ReviewResponse"]; + "text/json": components["schemas"]["ReviewResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ValidationProblemDetails"]; + "application/json": components["schemas"]["ValidationProblemDetails"]; + "text/json": components["schemas"]["ValidationProblemDetails"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + trace?: never; + }; + "/api/reviews/{id}/vote": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CastVoteRequest"]; + "text/json": components["schemas"]["CastVoteRequest"]; + "application/*+json": components["schemas"]["CastVoteRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["VoteResponse"]; + "application/json": components["schemas"]["VoteResponse"]; + "text/json": components["schemas"]["VoteResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ValidationProblemDetails"]; + "application/json": components["schemas"]["ValidationProblemDetails"]; + "text/json": components["schemas"]["ValidationProblemDetails"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["VoteResponse"]; + "application/json": components["schemas"]["VoteResponse"]; + "text/json": components["schemas"]["VoteResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ProblemDetails"]; + "text/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + CastVoteRequest: { + isUpvote: boolean; + }; + EditReviewRequest: { + /** Format: int32 */ + rating?: number | null; + title?: string | null; + body?: string | null; + pros?: { + Value?: string; + }; + cons?: { + Value?: string; + }; + }; + ProblemDetails: { + type?: string | null; + title?: string | null; + /** Format: int32 */ + status?: number | null; + detail?: string | null; + instance?: string | null; + } & { + [key: string]: unknown; + }; + ProductDetailResponse: { + /** Format: int64 */ + id: number; + slug: string; + name: string; + description: string; + imageUrl?: string | null; + /** Format: int32 */ + reviewCount: number; + /** Format: double */ + averageRating?: number | null; + /** Format: uuid */ + myReviewId?: string | null; + }; + ProductSummaryResponse: { + /** Format: int64 */ + id: number; + slug: string; + name: string; + imageUrl?: string | null; + /** Format: int32 */ + reviewCount: number; + /** Format: double */ + averageRating?: number | null; + }; + ProfileResponse: { + displayName: string; + /** Format: email */ + email?: string | null; + /** Format: uuid */ + authorId: string; + }; + ReviewResponse: { + /** Format: uuid */ + id: string; + /** Format: int32 */ + rating: number; + title: string; + body: string; + pros?: string | null; + cons?: string | null; + authorName: string; + /** Format: int32 */ + score: number; + mine: boolean; + myVote?: boolean | null; + /** Format: date-time */ + createdAtUtc: string; + /** Format: date-time */ + updatedAtUtc?: string | null; + }; + /** @enum {string} */ + ReviewSortOption: "MostHelpful" | "Newest" | "HighestRating" | "LowestRating"; + ReviewsPageResponse: { + items: components["schemas"]["ReviewResponse"][]; + /** Format: int32 */ + totalCount: number; + /** Format: int32 */ + page: number; + /** Format: int32 */ + pageSize: number; + }; + SubmitReviewRequest: { + /** Format: int32 */ + rating: number; + title: string; + body: string; + pros?: string | null; + cons?: string | null; + }; + ValidationProblemDetails: { + type?: string | null; + title?: string | null; + /** Format: int32 */ + status?: number | null; + detail?: string | null; + instance?: string | null; + errors: { + [key: string]: string[]; + }; + } & { + [key: string]: unknown; + }; + VoteResponse: { + /** Format: int32 */ + score: number; + myVote?: boolean | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/frontend/src/auth/useAuth.ts b/frontend/src/auth/useAuth.ts new file mode 100644 index 0000000..3d0954a --- /dev/null +++ b/frontend/src/auth/useAuth.ts @@ -0,0 +1,53 @@ +import { UserManager, type User } from "oidc-client-ts"; +import { defineStore } from "pinia"; + +// Lazy so importing this module (e.g. from unit tests) never touches browser APIs +// or requires the OIDC env to be present. +let userManager: UserManager | null = null; + +function getUserManager(): UserManager { + userManager ??= new UserManager({ + authority: import.meta.env.VITE_OIDC_AUTHORITY ?? "", + client_id: import.meta.env.VITE_OIDC_CLIENT_ID ?? "", + redirect_uri: `${window.location.origin}/auth/callback`, + post_logout_redirect_uri: window.location.origin, + response_type: "code", + scope: "openid profile email", + }); + return userManager; +} + +export const useAuth = defineStore("auth", { + state: () => ({ + user: null as User | null, + ready: false, + }), + getters: { + // Sign-in is only offered when the AppHost provisioned an OIDC client. + isAvailable: () => Boolean(import.meta.env.VITE_OIDC_AUTHORITY && import.meta.env.VITE_OIDC_CLIENT_ID), + isSignedIn: (state) => state.user !== null && !state.user.expired, + displayName: (state) => state.user?.profile.name ?? "", + accessToken: (state) => (state.user !== null && !state.user.expired ? state.user.access_token : null), + }, + actions: { + async initialize() { + if (this.isAvailable) { + this.user = await getUserManager().getUser(); + } + this.ready = true; + }, + async signIn(returnTo?: string) { + await getUserManager().signinRedirect({ state: returnTo ?? window.location.pathname }); + }, + /** Completes the redirect flow on /auth/callback; returns where to navigate next. */ + async completeSignIn(): Promise { + const user = await getUserManager().signinRedirectCallback(); + this.user = user; + return typeof user.state === "string" && user.state.startsWith("/") ? user.state : "/"; + }, + async signOut() { + this.user = null; + await getUserManager().signoutRedirect(); + }, + }, +}); diff --git a/frontend/src/components/ReviewCard.vue b/frontend/src/components/ReviewCard.vue new file mode 100644 index 0000000..1610c5f --- /dev/null +++ b/frontend/src/components/ReviewCard.vue @@ -0,0 +1,84 @@ + + + diff --git a/frontend/src/components/ReviewForm.vue b/frontend/src/components/ReviewForm.vue new file mode 100644 index 0000000..f45a94d --- /dev/null +++ b/frontend/src/components/ReviewForm.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/components/StarRating.vue b/frontend/src/components/StarRating.vue new file mode 100644 index 0000000..a99d719 --- /dev/null +++ b/frontend/src/components/StarRating.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..942ee95 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,14 @@ +/// + +interface ImportMetaEnv { + /** OIDC issuer (Zitadel), injected by the Aspire AppHost. Empty when auth is unavailable. */ + readonly VITE_OIDC_AUTHORITY?: string; + /** The provisioned PKCE SPA client id, injected by the Aspire AppHost. */ + readonly VITE_OIDC_CLIENT_ID?: string; +} + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/frontend/src/formatDate.ts b/frontend/src/formatDate.ts new file mode 100644 index 0000000..0a854b8 --- /dev/null +++ b/frontend/src/formatDate.ts @@ -0,0 +1,5 @@ +const dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium" }); + +export function formatDate(utcTimestamp: string): string { + return dateFormat.format(new Date(utcTimestamp)); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..9f1ec39 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,17 @@ +import { createPinia } from "pinia"; +import { createApp } from "vue"; +import App from "./App.vue"; +import { setAuthTokenProvider } from "./api/client"; +import { useAuth } from "./auth/useAuth"; +import { router } from "./router"; +import "./styles.css"; + +const app = createApp(App); +app.use(createPinia()); +app.use(router); + +const auth = useAuth(); +setAuthTokenProvider(() => auth.accessToken); +void auth.initialize(); + +app.mount("#app"); diff --git a/frontend/src/pages/AuthCallbackPage.vue b/frontend/src/pages/AuthCallbackPage.vue new file mode 100644 index 0000000..0caaefd --- /dev/null +++ b/frontend/src/pages/AuthCallbackPage.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/pages/CatalogPage.vue b/frontend/src/pages/CatalogPage.vue new file mode 100644 index 0000000..4b6775d --- /dev/null +++ b/frontend/src/pages/CatalogPage.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/pages/ProductDetailPage.vue b/frontend/src/pages/ProductDetailPage.vue new file mode 100644 index 0000000..ced40ef --- /dev/null +++ b/frontend/src/pages/ProductDetailPage.vue @@ -0,0 +1,278 @@ + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..307a144 --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,13 @@ +import { createRouter, createWebHistory } from "vue-router"; +import AuthCallbackPage from "./pages/AuthCallbackPage.vue"; +import CatalogPage from "./pages/CatalogPage.vue"; +import ProductDetailPage from "./pages/ProductDetailPage.vue"; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/", name: "catalog", component: CatalogPage }, + { path: "/products/:slug", name: "product", component: ProductDetailPage, props: true }, + { path: "/auth/callback", name: "auth-callback", component: AuthCallbackPage }, + ], +}); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..56b0c11 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,483 @@ +:root { + --color-background: #f6f7f9; + --color-surface: #ffffff; + --color-border: #e3e6ea; + --color-text: #1f2733; + --color-text-muted: #66707d; + --color-accent: #4353ff; + --color-accent-dark: #3644d9; + --color-star: #f5a623; + --color-positive: #1a7f37; + --color-negative: #c0392b; + --color-danger: #c0392b; + --radius: 10px; + --shadow: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.06); + font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--color-background); + color: var(--color-text); + line-height: 1.55; +} + +a { + color: var(--color-accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.9rem 1.5rem; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 10; +} + +.brand { + font-size: 1.15rem; + font-weight: 700; + color: var(--color-text); +} + +.brand:hover { + text-decoration: none; + color: var(--color-accent); +} + +.auth-area { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.user-name { + color: var(--color-text-muted); + font-size: 0.95rem; +} + +.page { + max-width: 960px; + margin: 0 auto; + padding: 1.5rem 1rem 4rem; +} + +.site-footer { + text-align: center; + padding: 1.25rem; + color: var(--color-text-muted); + font-size: 0.85rem; + border-top: 1px solid var(--color-border); +} + +/* --- Buttons ------------------------------------------------------------ */ + +.button { + appearance: none; + border: 1px solid var(--color-accent); + background: var(--color-accent); + color: #fff; + border-radius: 8px; + padding: 0.45rem 1rem; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; +} + +.button:hover:not(:disabled) { + background: var(--color-accent-dark); +} + +.button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.button.subtle { + background: transparent; + color: var(--color-text); + border-color: var(--color-border); + font-weight: 500; +} + +.button.subtle:hover:not(:disabled) { + background: var(--color-background); +} + +.button.danger { + background: transparent; + color: var(--color-danger); + border-color: var(--color-danger); +} + +.button.danger:hover:not(:disabled) { + background: #fdf0ee; +} + +/* --- Catalog ------------------------------------------------------------ */ + +.catalog-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; +} + +.product-card { + display: block; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + color: inherit; +} + +.product-card:hover { + text-decoration: none; + border-color: var(--color-accent); +} + +.product-card img { + width: 100%; + height: 160px; + object-fit: cover; + display: block; + background: var(--color-border); +} + +.product-card-body { + padding: 0.85rem 1rem 1rem; +} + +.product-card-body h2 { + font-size: 1.02rem; + margin: 0 0 0.4rem; +} + +.rating-line { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + color: var(--color-text-muted); +} + +/* --- Stars --------------------------------------------------------------- */ + +.star-rating { + display: inline-block; + position: relative; + font-size: 1.05rem; + line-height: 1; + color: #d4d9df; + white-space: nowrap; +} + +.stars-filled { + position: absolute; + inset: 0; + overflow: hidden; + color: var(--color-star); +} + +.star-rating.editable { + color: inherit; +} + +.star-button { + appearance: none; + border: none; + background: none; + font-size: 1.6rem; + line-height: 1; + padding: 0 0.1rem; + cursor: pointer; + color: #d4d9df; +} + +.star-button.filled { + color: var(--color-star); +} + +/* --- Product detail ------------------------------------------------------ */ + +.product-header { + display: flex; + gap: 1.5rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.25rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.product-header img { + width: 280px; + max-width: 100%; + height: 190px; + object-fit: cover; + border-radius: 8px; + background: var(--color-border); +} + +.product-header-info { + flex: 1; + min-width: 260px; +} + +.product-header-info h1 { + margin: 0 0 0.5rem; + font-size: 1.5rem; +} + +.review-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.9rem; + margin: 1.25rem 0 1rem; + font-size: 0.92rem; +} + +.review-controls select { + padding: 0.3rem 0.5rem; + border-radius: 6px; + border: 1px solid var(--color-border); + background: var(--color-surface); + font: inherit; +} + +.star-filter { + display: flex; + gap: 0.45rem; + align-items: center; +} + +.star-filter label { + display: inline-flex; + align-items: center; + gap: 0.2rem; + cursor: pointer; + color: var(--color-text-muted); +} + +/* --- Reviews -------------------------------------------------------------- */ + +.review-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1rem 1.25rem; + margin-bottom: 0.9rem; +} + +.review-card.mine { + border-color: var(--color-accent); +} + +.review-card-header { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.review-card-header h3 { + margin: 0; + font-size: 1.05rem; +} + +.badge { + background: var(--color-accent); + color: #fff; + font-size: 0.72rem; + font-weight: 700; + padding: 0.12rem 0.5rem; + border-radius: 99px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.review-meta { + color: var(--color-text-muted); + font-size: 0.85rem; + margin: 0.25rem 0 0.6rem; +} + +.review-body { + margin: 0 0 0.5rem; + white-space: pre-line; +} + +.review-pro, +.review-con { + margin: 0.15rem 0; + font-size: 0.92rem; +} + +.review-pro { + color: var(--color-positive); +} + +.review-con { + color: var(--color-negative); +} + +.review-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-top: 0.75rem; + flex-wrap: wrap; +} + +.vote-controls { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; +} + +.vote-button { + appearance: none; + border: 1px solid var(--color-border); + background: var(--color-surface); + border-radius: 99px; + padding: 0.25rem 0.8rem; + font-size: 0.85rem; + cursor: pointer; + color: var(--color-text-muted); +} + +.vote-button:hover:not(:disabled) { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.vote-button.active { + border-color: var(--color-accent); + background: #eef0ff; + color: var(--color-accent); + font-weight: 600; +} + +.vote-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.score { + font-weight: 700; + min-width: 2ch; + text-align: center; +} + +.score.positive { + color: var(--color-positive); +} + +.score.negative { + color: var(--color-negative); +} + +.own-actions { + display: flex; + gap: 0.5rem; +} + +.muted { + color: var(--color-text-muted); + font-size: 0.85rem; +} + +/* --- Review form ----------------------------------------------------------- */ + +.review-form { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.25rem; + margin: 1rem 0; + display: grid; + gap: 0.8rem; +} + +.review-form h3 { + margin: 0; +} + +.review-form label { + display: grid; + gap: 0.3rem; + font-size: 0.9rem; + font-weight: 600; +} + +.review-form input, +.review-form textarea { + font: inherit; + padding: 0.5rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 8px; + width: 100%; +} + +.review-form textarea { + min-height: 110px; + resize: vertical; +} + +.form-actions { + display: flex; + gap: 0.6rem; + align-items: center; +} + +.field-hint { + font-weight: 400; + color: var(--color-text-muted); +} + +/* --- Misc ------------------------------------------------------------------ */ + +.error-banner { + background: #fdf0ee; + border: 1px solid #f0c4bd; + color: var(--color-danger); + border-radius: 8px; + padding: 0.6rem 0.9rem; + margin: 0.75rem 0; + font-size: 0.92rem; +} + +.pagination { + display: flex; + align-items: center; + gap: 0.75rem; + justify-content: center; + margin-top: 1.25rem; +} + +.empty-state { + text-align: center; + color: var(--color-text-muted); + padding: 2.5rem 0; +} diff --git a/frontend/tests/e2e/catalog.spec.ts b/frontend/tests/e2e/catalog.spec.ts new file mode 100644 index 0000000..2a283c1 --- /dev/null +++ b/frontend/tests/e2e/catalog.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; + +test("the seeded catalog is browsable without signing in", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator(".product-card")).toHaveCount(10); + await expect(page.locator(".product-card").first()).toContainText("Sony WH-1000XM5"); + + await page.getByRole("link", { name: /Sony WH-1000XM5/ }).click(); + await expect(page.getByRole("heading", { level: 1 })).toContainText("Sony WH-1000XM5"); + await expect(page.locator(".review-card").first()).toBeVisible(); +}); + +test("reviews can be sorted and filtered by stars", async ({ page }) => { + await page.goto("/products/acme-smartwatch"); + + await expect(page.locator(".review-card")).toHaveCount(4); + + // Only the 1★ and 2★ reviews remain after filtering. + await page.locator(".star-filter label", { hasText: "1★" }).locator("input").check(); + await page.locator(".star-filter label", { hasText: "2★" }).locator("input").check(); + await expect(page.locator(".review-card")).toHaveCount(3); + + await page.getByLabel("Sort by").selectOption("Newest"); + await expect(page.locator(".review-card")).toHaveCount(3); +}); + +test("an unknown product shows a friendly not-found message", async ({ page }) => { + await page.goto("/products/definitely-not-a-product"); + await expect(page.locator(".error-banner")).toContainText("does not exist"); +}); diff --git a/frontend/tests/e2e/contract.spec.ts b/frontend/tests/e2e/contract.spec.ts new file mode 100644 index 0000000..7ef74c8 --- /dev/null +++ b/frontend/tests/e2e/contract.spec.ts @@ -0,0 +1,21 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; + +// The committed openapi.json is what the TypeScript client was generated from +// (ADR-0004). If the running API's document differs, the contract drifted: +// refresh with `npm run refresh:api` (see README) and commit the result. +test("the committed OpenAPI snapshot matches the running API", async ({ request }) => { + const response = await request.get("/swagger/v1/swagger.json"); + expect(response.ok()).toBe(true); + + const liveDocument = (await response.json()) as Record; + const snapshotPath = fileURLToPath(new URL("../../openapi.json", import.meta.url)); + const snapshot = JSON.parse(await readFile(snapshotPath, "utf8")) as Record; + + // The servers block depends on the host the document is served from. + delete liveDocument["servers"]; + delete snapshot["servers"]; + + expect(liveDocument).toEqual(snapshot); +}); diff --git a/frontend/tests/e2e/helpers/auth.ts b/frontend/tests/e2e/helpers/auth.ts new file mode 100644 index 0000000..6536a35 --- /dev/null +++ b/frontend/tests/e2e/helpers/auth.ts @@ -0,0 +1,29 @@ +import { expect, type Page } from "@playwright/test"; + +export const demoUser = { email: "demo@productreviews.local", name: "Demo Reviewer" }; +export const criticUser = { email: "critic@productreviews.local", name: "Casey Critic" }; +// Provisioned by the AppHost (ZitadelHosting.DemoUserPassword); documented in the README. +export const demoPassword = "ProductReviews123!"; + +/** Signs in through the real Zitadel hosted login (Login V2). */ +export async function signIn(page: Page, user: { email: string; name: string }): Promise { + await page.getByRole("button", { name: "Sign in" }).click(); + + await page.waitForURL(/ui\/v2\/login/); + await page.locator("input:not([type=hidden])").first().fill(user.email); + await page.keyboard.press("Enter"); + + const passwordInput = page.locator('input[type="password"]'); + await passwordInput.waitFor(); + await passwordInput.fill(demoPassword); + await page.keyboard.press("Enter"); + + await page.waitForURL((url) => url.origin === "http://localhost:4173"); + await expect(page.locator(".user-name")).toHaveText(user.name); +} + +export async function signOut(page: Page): Promise { + await page.getByRole("button", { name: "Sign out" }).click(); + await page.waitForURL((url) => url.origin === "http://localhost:4173"); + await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); +} diff --git a/frontend/tests/e2e/review-flow.spec.ts b/frontend/tests/e2e/review-flow.spec.ts new file mode 100644 index 0000000..fcedcfd --- /dev/null +++ b/frontend/tests/e2e/review-flow.spec.ts @@ -0,0 +1,83 @@ +import { expect, test, type Page } from "@playwright/test"; +import { criticUser, demoUser, signIn, signOut } from "./helpers/auth"; + +// One continuous story on a fresh (throwaway) E2E database: the demo user writes and +// edits a review, a second user votes on it. Serial by design — each step builds on +// the previous one. +test.describe.configure({ mode: "serial" }); + +const productPath = "/products/usb-c-cable-pack"; + +async function openProduct(page: Page): Promise { + await page.goto(productPath); + await expect(page.getByRole("heading", { level: 1 })).toContainText("USB-C Cable"); +} + +test("the demo user signs in and publishes a review", async ({ page }) => { + await openProduct(page); + await signIn(page, demoUser); + + await openProduct(page); + const form = page.locator(".review-form"); + await expect(form).toBeVisible(); + + await form.locator(".star-button").nth(4).click(); + await form.getByLabel("Title").fill("Cables that just work"); + await form.getByLabel("Review", { exact: true }).fill("Three cables, zero problems after a month of daily use."); + await form.getByLabel(/Pros/).fill("Braided jacket feels premium"); + await form.getByRole("button", { name: "Publish review" }).click(); + + const ownCard = page.locator(".review-card.mine"); + await expect(ownCard).toContainText("Cables that just work"); + await expect(ownCard).toContainText("Your review"); + await expect(ownCard).toContainText("Braided jacket feels premium"); + await expect(ownCard).toContainText(demoUser.name); + + // A second submission is not offered — one review per reviewer per product. + await expect(page.locator(".review-form")).toHaveCount(0); +}); + +test("the demo user edits the review and clears the pros line", async ({ page }) => { + await openProduct(page); + await signIn(page, demoUser); + + await openProduct(page); + await page.locator(".review-card.mine").getByRole("button", { name: "Edit" }).click(); + + const form = page.locator(".review-form"); + await form.locator(".star-button").nth(3).click(); + await form.getByLabel(/Pros/).fill(""); + await form.getByRole("button", { name: "Save changes" }).click(); + + const ownCard = page.locator(".review-card.mine"); + await expect(ownCard).toContainText("edited"); + await expect(ownCard).not.toContainText("Braided jacket feels premium"); +}); + +test("another user votes the review helpful and the score updates", async ({ page }) => { + await openProduct(page); + await signIn(page, criticUser); + + await openProduct(page); + const demoReview = page.locator(".review-card", { hasText: "Cables that just work" }); + await expect(demoReview.locator(".score")).toHaveText("0"); + + await demoReview.getByRole("button", { name: "👍 Helpful" }).click(); + await expect(demoReview.locator(".score")).toHaveText("+1"); + await expect(demoReview.getByRole("button", { name: "👍 Helpful" })).toHaveClass(/active/); + + await signOut(page); +}); + +test("the demo user deletes the review, freeing the slot", async ({ page }) => { + await openProduct(page); + await signIn(page, demoUser); + + await openProduct(page); + page.on("dialog", (dialog) => void dialog.accept()); + await page.locator(".review-card.mine").getByRole("button", { name: "Delete" }).click(); + + await expect(page.locator(".review-card.mine")).toHaveCount(0); + // The submit form is back — deleting freed the one-review-per-product slot. + await expect(page.locator(".review-form")).toBeVisible(); +}); diff --git a/frontend/tests/unit/ReviewCard.spec.ts b/frontend/tests/unit/ReviewCard.spec.ts new file mode 100644 index 0000000..9c5a580 --- /dev/null +++ b/frontend/tests/unit/ReviewCard.spec.ts @@ -0,0 +1,62 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; +import type { Review } from "../../src/api/client"; +import ReviewCard from "../../src/components/ReviewCard.vue"; + +function createReview(overrides: Partial = {}): Review { + return { + id: "0197e000-0000-7000-8000-000000000001", + rating: 4, + title: "Solid product", + body: "Does what it promises.", + pros: "Sturdy", + cons: null, + authorName: "Alice", + score: 3, + mine: false, + myVote: null, + createdAtUtc: "2026-01-01T00:00:00Z", + updatedAtUtc: null, + ...overrides, + }; +} + +describe("ReviewCard", () => { + it("marks the viewer's own review and never offers voting on it", () => { + const wrapper = mount(ReviewCard, { props: { review: createReview({ mine: true }), canVote: true } }); + expect(wrapper.find(".badge").text()).toBe("Your review"); + expect(wrapper.findAll(".vote-button")).toHaveLength(0); + expect(wrapper.text()).toContain("You can't vote on your own review"); + expect(wrapper.find(".own-actions").exists()).toBe(true); + }); + + it("disables vote buttons for signed-out viewers", () => { + const wrapper = mount(ReviewCard, { props: { review: createReview(), canVote: false } }); + for (const button of wrapper.findAll(".vote-button")) { + expect(button.attributes("disabled")).toBeDefined(); + } + }); + + it("emits vote when casting and removeVote when clicking the active direction", async () => { + const withoutVote = mount(ReviewCard, { props: { review: createReview(), canVote: true } }); + await withoutVote.findAll(".vote-button")[0]!.trigger("click"); + expect(withoutVote.emitted("vote")).toEqual([[true]]); + + const withUpvote = mount(ReviewCard, { props: { review: createReview({ myVote: true }), canVote: true } }); + await withUpvote.findAll(".vote-button")[0]!.trigger("click"); + expect(withUpvote.emitted("removeVote")).toHaveLength(1); + expect(withUpvote.emitted("vote")).toBeUndefined(); + }); + + it("shows pros and cons lines only when present", () => { + const wrapper = mount(ReviewCard, { + props: { review: createReview({ pros: "Light", cons: "Pricey" }), canVote: true }, + }); + expect(wrapper.find(".review-pro").text()).toContain("Light"); + expect(wrapper.find(".review-con").text()).toContain("Pricey"); + + const bare = mount(ReviewCard, { props: { review: createReview({ pros: null, cons: null }), canVote: true } }); + expect(bare.find(".review-pro").exists()).toBe(false); + expect(bare.find(".review-con").exists()).toBe(false); + }); +}); diff --git a/frontend/tests/unit/StarRating.spec.ts b/frontend/tests/unit/StarRating.spec.ts new file mode 100644 index 0000000..cfc6a35 --- /dev/null +++ b/frontend/tests/unit/StarRating.spec.ts @@ -0,0 +1,27 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; +import StarRating from "../../src/components/StarRating.vue"; + +describe("StarRating", () => { + it("renders the filled overlay proportional to the value", () => { + const wrapper = mount(StarRating, { props: { value: 3.5 } }); + expect(wrapper.find(".stars-filled").attributes("style")).toContain("width: 70%"); + }); + + it("announces 'not yet rated' for a null value", () => { + const wrapper = mount(StarRating, { props: { value: null } }); + expect(wrapper.find(".star-rating").attributes("aria-label")).toBe("Not yet rated"); + }); + + it("emits the selected star count in editable mode", async () => { + const wrapper = mount(StarRating, { props: { value: 2, editable: true } }); + await wrapper.findAll("button.star-button")[3]!.trigger("click"); + expect(wrapper.emitted("select")).toEqual([[4]]); + }); + + it("fills exactly the chosen stars in editable mode", () => { + const wrapper = mount(StarRating, { props: { value: 3, editable: true } }); + const filled = wrapper.findAll("button.star-button.filled"); + expect(filled).toHaveLength(3); + }); +}); diff --git a/frontend/tests/unit/editReviewBody.spec.ts b/frontend/tests/unit/editReviewBody.spec.ts new file mode 100644 index 0000000..40b0c1b --- /dev/null +++ b/frontend/tests/unit/editReviewBody.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { Review } from "../../src/api/client"; +import { buildEditReviewBody, hasChanges, type ReviewFormValues } from "../../src/api/editReviewBody"; + +function createReview(overrides: Partial = {}): Review { + return { + id: "0197e000-0000-7000-8000-000000000001", + rating: 4, + title: "Original title", + body: "Original body", + pros: "Original pro", + cons: null, + authorName: "Alice", + score: 0, + mine: true, + myVote: null, + createdAtUtc: "2026-01-01T00:00:00Z", + updatedAtUtc: null, + ...overrides, + }; +} + +function formValues(review: Review, overrides: Partial = {}): ReviewFormValues { + return { + rating: review.rating, + title: review.title, + body: review.body, + pros: review.pros ?? "", + cons: review.cons ?? "", + ...overrides, + }; +} + +describe("buildEditReviewBody — the three-state Maybe contract", () => { + it("sends nothing when nothing changed", () => { + const review = createReview(); + const body = buildEditReviewBody(review, formValues(review)); + expect(body).toEqual({}); + expect(hasChanges(body)).toBe(false); + }); + + it("sends only the fields that changed", () => { + const review = createReview(); + const body = buildEditReviewBody(review, formValues(review, { rating: 2, title: "New title" })); + expect(body).toEqual({ rating: 2, title: "New title" }); + }); + + it("clearing an optional field sends the empty Maybe ({} = None)", () => { + const review = createReview({ pros: "Original pro" }); + const body = buildEditReviewBody(review, formValues(review, { pros: "" })); + expect(body).toEqual({ pros: {} }); + }); + + it("setting an optional field sends Maybe.Some ({ Value })", () => { + const review = createReview({ cons: null }); + const body = buildEditReviewBody(review, formValues(review, { cons: "Battery drains fast" })); + expect(body).toEqual({ cons: { Value: "Battery drains fast" } }); + }); + + it("an untouched optional field is omitted entirely (leave unchanged)", () => { + const review = createReview({ pros: "Original pro", cons: "Original con" }); + const body = buildEditReviewBody(review, formValues(review, { title: "New title" })); + expect(body).toEqual({ title: "New title" }); + expect(body.pros).toBeUndefined(); + expect(body.cons).toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/problem.spec.ts b/frontend/tests/unit/problem.spec.ts new file mode 100644 index 0000000..c5f20d3 --- /dev/null +++ b/frontend/tests/unit/problem.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { problemMessage } from "../../src/api/problem"; + +describe("problemMessage", () => { + it("prefers the first field error of a ValidationProblemDetails", () => { + expect(problemMessage({ title: "Bad Request", errors: { title: ["Title must not be empty."] } })).toBe( + "Title must not be empty.", + ); + }); + + it("falls back to detail, then title", () => { + expect(problemMessage({ title: "Conflict", detail: "You already reviewed this product." })).toBe( + "You already reviewed this product.", + ); + expect(problemMessage({ title: "Conflict" })).toBe("Conflict"); + }); + + it("gives a generic message for unrecognized shapes", () => { + expect(problemMessage(undefined)).toBe("Something went wrong. Please try again."); + }); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3076fab --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true, + "jsx": "preserve", + "types": ["vite/client", "node"] + }, + "include": ["src", "tests", "vite.config.ts", "playwright.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..1db9de0 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,24 @@ +/// +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +// PORT and API_PROXY_TARGET are injected by the Aspire AppHost. The proxy keeps the +// browser same-origin with the API — no CORS, no API URL in browser code (ADR-0008). +const port = Number(process.env.PORT ?? 5173); +const apiProxyTarget = process.env.API_PROXY_TARGET ?? "http://localhost:5000"; +// /swagger rides along so the OpenAPI document (and its UI) is reachable from the +// frontend origin — the E2E contract test reads it there. +const proxy = { + "/api": { target: apiProxyTarget, changeOrigin: true }, + "/swagger": { target: apiProxyTarget, changeOrigin: true }, +}; + +export default defineConfig({ + plugins: [vue()], + server: { port, strictPort: true, proxy }, + preview: { port, strictPort: true, proxy }, + test: { + environment: "happy-dom", + include: ["tests/unit/**/*.spec.ts"], + }, +}); diff --git a/global.json b/global.json new file mode 100644 index 0000000..545c92c --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestFeature" + } +} diff --git a/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs b/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs new file mode 100644 index 0000000..42dcc46 --- /dev/null +++ b/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs @@ -0,0 +1,38 @@ +using ProductReviews.Domain.Catalog; +using StrongTypes; + +namespace ProductReviews.Api.Features.Catalog; + +public sealed record ProductSummaryResponse( + long Id, + NonEmptyString Slug, + NonEmptyString Name, + NonEmptyString? ImageUrl, + NonNegative ReviewCount, + double? AverageRating) +{ + public static ProductSummaryResponse From(Product product) + => new(product.Id, product.Slug, product.Name, product.ImageUrl, product.ReviewCount, product.AverageRating); +} + +public sealed record ProductDetailResponse( + long Id, + NonEmptyString Slug, + NonEmptyString Name, + NonEmptyString Description, + NonEmptyString? ImageUrl, + NonNegative ReviewCount, + double? AverageRating, + Guid? MyReviewId) +{ + public static ProductDetailResponse From(ProductDetailModel model) + => new( + model.Product.Id, + model.Product.Slug, + model.Product.Name, + model.Product.Description, + model.Product.ImageUrl, + model.Product.ReviewCount, + model.Product.AverageRating, + model.ViewerReviewId); +} diff --git a/src/ProductReviews.Api/Features/Catalog/CatalogController.cs b/src/ProductReviews.Api/Features/Catalog/CatalogController.cs new file mode 100644 index 0000000..5bf19c3 --- /dev/null +++ b/src/ProductReviews.Api/Features/Catalog/CatalogController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ProductReviews.Api.Infrastructure; +using ProductReviews.Domain.Catalog; +using StrongTypes; + +namespace ProductReviews.Api.Features.Catalog; + +[ApiController] +[Route("api/products")] +public sealed class CatalogController( + GetCatalogHandler getCatalog, + GetProductDetailHandler getProductDetail) : ControllerBase +{ + [HttpGet] + [AllowAnonymous] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetCatalog(CancellationToken cancellationToken) + { + var products = await getCatalog.HandleAsync(cancellationToken); + return Ok(products.Select(ProductSummaryResponse.From).ToList()); + } + + [HttpGet("{slug}")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProduct(NonEmptyString slug, CancellationToken cancellationToken) + { + var detail = await getProductDetail.HandleAsync(slug, User.AuthorIdOrNull(), cancellationToken); + return detail is null ? NotFound() : Ok(ProductDetailResponse.From(detail)); + } +} diff --git a/src/ProductReviews.Api/Features/Profile/ProfileController.cs b/src/ProductReviews.Api/Features/Profile/ProfileController.cs new file mode 100644 index 0000000..3175555 --- /dev/null +++ b/src/ProductReviews.Api/Features/Profile/ProfileController.cs @@ -0,0 +1,26 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ProductReviews.Api.Infrastructure; +using StrongTypes; + +namespace ProductReviews.Api.Features.Profile; + +public sealed record ProfileResponse(NonEmptyString DisplayName, Email? Email, Guid AuthorId); + +[ApiController] +[Route("api/me")] +public sealed class ProfileController : ControllerBase +{ + [HttpGet] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public ActionResult GetProfile() + { + var author = User.Author(); + // TryCreate (never Create): the claim is external input — absent or malformed simply means no email. + var email = Email.TryCreate(User.FindFirstValue("email")); + return Ok(new ProfileResponse(author.DisplayName, email, author.AuthorId)); + } +} diff --git a/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs b/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs new file mode 100644 index 0000000..46e9fa8 --- /dev/null +++ b/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs @@ -0,0 +1,94 @@ +using System.Text.Json.Serialization; +using ProductReviews.Domain.Reviews; +using StrongTypes; + +namespace ProductReviews.Api.Features.Reviews; + +/// Every constrained field is a strong type — the OpenAPI schema carries the +/// constraints and there is no data annotation anywhere (ADR-0002). +public sealed record SubmitReviewRequest( + Rating Rating, + NonEmptyString Title, + NonEmptyString Body, + NonEmptyString? Pros, + NonEmptyString? Cons); + +/// PATCH semantics (ADR-0002, the Maybe<T> showcase): omitting a field leaves it +/// unchanged. For the clearable optionals, {"value": null} (or {}) clears and +/// {"value": "…"} replaces — three states one nullable cannot express. +public sealed record EditReviewRequest( + Rating? Rating, + NonEmptyString? Title, + NonEmptyString? Body, + Maybe? Pros, + Maybe? Cons); + +public sealed record ReviewResponse( + Guid Id, + Rating Rating, + NonEmptyString Title, + NonEmptyString Body, + NonEmptyString? Pros, + NonEmptyString? Cons, + NonEmptyString AuthorName, + int Score, + bool Mine, + bool? MyVote, + DateTime CreatedAtUtc, + DateTime? UpdatedAtUtc) +{ + public static ReviewResponse From(ReviewWithViewerContext reviewWithContext) + => new( + reviewWithContext.Review.Id, + reviewWithContext.Review.Rating, + reviewWithContext.Review.Title, + reviewWithContext.Review.Body, + reviewWithContext.Review.Pros, + reviewWithContext.Review.Cons, + reviewWithContext.Review.AuthorName, + reviewWithContext.Review.Score, + reviewWithContext.Mine, + reviewWithContext.ViewerVote, + reviewWithContext.Review.CreatedAtUtc, + reviewWithContext.Review.UpdatedAtUtc); + + /// For submit/edit responses: the caller is the author, and authors can never vote on their own review. + public static ReviewResponse FromOwn(Review review) + => From(new ReviewWithViewerContext(review, Mine: true, ViewerVote: null)); +} + +public sealed record ReviewsPageResponse( + IReadOnlyList Items, + NonNegative TotalCount, + Positive Page, + Positive PageSize) +{ + public static ReviewsPageResponse From(ReviewsPage reviewsPage) + => new( + [.. reviewsPage.Reviews.Select(ReviewResponse.From)], + reviewsPage.TotalCount, + reviewsPage.Page, + reviewsPage.PageSize); +} + +/// The wire-facing sort enum: serialized as strings, mapped to the domain enum +/// with an exhaustive switch — domain enums never appear on the wire. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReviewSortOption +{ + MostHelpful, + Newest, + HighestRating, + LowestRating, +} + +public static class ReviewSortOptionExtensions +{ + public static ReviewSort Parse(this ReviewSortOption option) => option switch + { + ReviewSortOption.MostHelpful => ReviewSort.MostHelpful, + ReviewSortOption.Newest => ReviewSort.Newest, + ReviewSortOption.HighestRating => ReviewSort.HighestRating, + ReviewSortOption.LowestRating => ReviewSort.LowestRating, + }; +} diff --git a/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs b/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs new file mode 100644 index 0000000..ace115a --- /dev/null +++ b/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs @@ -0,0 +1,121 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using ProductReviews.Api.Infrastructure; +using ProductReviews.Domain.Reviews; +using StrongTypes; + +namespace ProductReviews.Api.Features.Reviews; + +[ApiController] +[Route("api/reviews")] +public sealed class ReviewsController( + GetReviewsPageHandler getReviewsPage, + SubmitReviewHandler submitReview, + EditReviewHandler editReview, + DeleteReviewHandler deleteReview) : ControllerBase +{ + [HttpGet("/api/products/{slug}/reviews")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetReviews( + NonEmptyString slug, + [FromQuery] ReviewSortOption sort = ReviewSortOption.MostHelpful, + [FromQuery] Rating[]? ratings = null, + [FromQuery] Positive? page = null, + [FromQuery] Positive? pageSize = null, + CancellationToken cancellationToken = default) + { + var reviewsPage = await getReviewsPage.HandleAsync( + slug, + sort.Parse(), + ratings ?? [], + page ?? 1.ToPositive(), + pageSize ?? 10.ToPositive(), + User.AuthorIdOrNull(), + cancellationToken); + return reviewsPage is null ? NotFound() : Ok(ReviewsPageResponse.From(reviewsPage)); + } + + [HttpPost("/api/products/{slug}/reviews")] + [Authorize] + [EnableRateLimiting(RateLimits.WritePolicy)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> SubmitReview( + NonEmptyString slug, + SubmitReviewRequest request, + CancellationToken cancellationToken) + { + var result = await submitReview.HandleAsync( + slug, User.Author(), request.Rating, request.Title, request.Body, request.Pros, request.Cons, cancellationToken); + + if (result.Error is { } error) + { + return error switch + { + SubmitReviewError.ProductNotFound => NotFound(), + SubmitReviewError.AlreadyReviewed => Conflict(new ProblemDetails + { + Title = "AlreadyReviewed", + Detail = "You have already reviewed this product. Edit or delete your existing review instead.", + Status = StatusCodes.Status409Conflict, + }), + }; + } + + var response = ReviewResponse.FromOwn(result.Success!); + return Created($"/api/products/{slug}/reviews", response); + } + + [HttpPatch("{id:guid}")] + [Authorize] + [EnableRateLimiting(RateLimits.WritePolicy)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> EditReview( + Guid id, + EditReviewRequest request, + CancellationToken cancellationToken) + { + var result = await editReview.HandleAsync( + id, User.Author().AuthorId, request.Rating, request.Title, request.Body, request.Pros, request.Cons, cancellationToken); + + if (result.Error is { } error) + { + return error switch + { + EditReviewError.ReviewNotFound => NotFound(), + EditReviewError.NotYourReview => Forbid(), + }; + } + + return Ok(ReviewResponse.FromOwn(result.Success!)); + } + + [HttpDelete("{id:guid}")] + [Authorize] + [EnableRateLimiting(RateLimits.WritePolicy)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteReview(Guid id, CancellationToken cancellationToken) + { + var error = await deleteReview.HandleAsync(id, User.Author().AuthorId, cancellationToken); + if (error is not { } deleteError) + { + return NoContent(); + } + + return deleteError switch + { + DeleteReviewError.ReviewNotFound => NotFound(), + DeleteReviewError.NotYourReview => Forbid(), + }; + } +} diff --git a/src/ProductReviews.Api/Features/Votes/VoteContracts.cs b/src/ProductReviews.Api/Features/Votes/VoteContracts.cs new file mode 100644 index 0000000..f45fc0f --- /dev/null +++ b/src/ProductReviews.Api/Features/Votes/VoteContracts.cs @@ -0,0 +1,11 @@ +using ProductReviews.Domain.Votes; + +namespace ProductReviews.Api.Features.Votes; + +public sealed record CastVoteRequest(bool IsUpvote); + +public sealed record VoteResponse(int Score, bool? MyVote) +{ + public static VoteResponse From(VoteSummary voteSummary) + => new(voteSummary.Score, voteSummary.ViewerVote); +} diff --git a/src/ProductReviews.Api/Features/Votes/VotesController.cs b/src/ProductReviews.Api/Features/Votes/VotesController.cs new file mode 100644 index 0000000..9cf781a --- /dev/null +++ b/src/ProductReviews.Api/Features/Votes/VotesController.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using ProductReviews.Api.Infrastructure; +using ProductReviews.Domain.Votes; +using StrongTypes; + +namespace ProductReviews.Api.Features.Votes; + +[ApiController] +[Route("api/reviews/{id:guid}/vote")] +public sealed class VotesController( + CastVoteHandler castVote, + RemoveVoteHandler removeVote) : ControllerBase +{ + [HttpPut] + [Authorize] + [EnableRateLimiting(RateLimits.WritePolicy)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CastVote( + Guid id, + CastVoteRequest request, + CancellationToken cancellationToken) + { + var result = await castVote.HandleAsync(id, User.Author().AuthorId, request.IsUpvote, cancellationToken); + + if (result.Error is { } error) + { + return error switch + { + CastVoteError.ReviewNotFound => NotFound(), + CastVoteError.OwnReview => OwnReviewProblem(), + }; + } + + return Ok(VoteResponse.From(result.Success!)); + } + + [HttpDelete] + [Authorize] + [EnableRateLimiting(RateLimits.WritePolicy)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> RemoveVote(Guid id, CancellationToken cancellationToken) + { + var result = await removeVote.HandleAsync(id, User.Author().AuthorId, cancellationToken); + + if (result.Error is { } error) + { + return error switch + { + RemoveVoteError.ReviewNotFound => NotFound(), + }; + } + + return Ok(VoteResponse.From(result.Success!)); + } + + private ActionResult OwnReviewProblem() + { + ModelState.AddModelError("id", "You cannot vote on your own review."); + return ValidationProblem(ModelState); + } +} diff --git a/src/ProductReviews.Api/Infrastructure/Authentication.cs b/src/ProductReviews.Api/Infrastructure/Authentication.cs new file mode 100644 index 0000000..7c7b800 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/Authentication.cs @@ -0,0 +1,117 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.IdentityModel.Tokens; + +namespace ProductReviews.Api.Infrastructure; + +/// JwtBearer against the OIDC authority (Zitadel locally — the AppHost injects +/// Oidc__Authority and Oidc__Audience). Authorization is applied per endpoint with +/// [Authorize]; there is deliberately no blanket RequireAuthorization anywhere. +public static class Authentication +{ + private const string UserinfoClientName = "oidc-userinfo"; + + public static void Configure(WebApplicationBuilder builder) + { + var authority = builder.Configuration["Oidc:Authority"]?.TrimEnd('/'); + var audience = builder.Configuration["Oidc:Audience"]; + + builder.Services.AddMemoryCache(); + builder.Services.AddHttpClient(UserinfoClientName); + + builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = authority; + options.RequireHttpsMetadata = authority?.StartsWith("https", StringComparison.OrdinalIgnoreCase) ?? true; + options.MapInboundClaims = false; + options.RefreshOnIssuerKeyNotFound = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = authority, + ValidateAudience = !string.IsNullOrEmpty(audience), + ValidAudience = audience, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30), + NameClaimType = "name", + }; + if (authority is not null) + { + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => EnrichFromUserinfoAsync(context, authority), + }; + } + }); + + builder.Services.AddAuthorization(); + } + + public static void Use(WebApplication app) + { + app.UseAuthentication(); + app.UseAuthorization(); + } + + // Zitadel's JWT access tokens carry only the subject; the display name and email live + // at the userinfo endpoint. Fetched once per subject (cached), so the domain snapshots + // AuthorName from the identity provider — never from anything the browser sends. + private static async Task EnrichFromUserinfoAsync(TokenValidatedContext context, string authority) + { + if (context.Principal?.FindFirst("name") is not null) + { + return; + } + var subject = context.Principal?.FindFirst("sub")?.Value; + var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString(); + if (subject is null || !authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var cache = context.HttpContext.RequestServices.GetRequiredService(); + var profile = await cache.GetOrCreateAsync($"oidc-userinfo:{subject}", async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15); + var httpClientFactory = context.HttpContext.RequestServices.GetRequiredService(); + var client = httpClientFactory.CreateClient(UserinfoClientName); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{authority}/oidc/v1/userinfo"); + request.Headers.Authorization = AuthenticationHeaderValue.Parse(authorizationHeader); + using var response = await client.SendAsync(request, context.HttpContext.RequestAborted); + if (!response.IsSuccessStatusCode) + { + return null; + } + using var document = JsonDocument.Parse(await response.Content.ReadAsByteArrayAsync(context.HttpContext.RequestAborted)); + return new UserinfoProfile( + document.RootElement.TryGetProperty("name", out var name) ? name.GetString() : null, + document.RootElement.TryGetProperty("email", out var email) ? email.GetString() : null); + }); + if (profile is null) + { + return; + } + + var claims = new List(); + if (profile.DisplayName is not null) + { + claims.Add(new Claim("name", profile.DisplayName)); + } + if (profile.Email is not null && context.Principal!.FindFirst("email") is null) + { + claims.Add(new Claim("email", profile.Email)); + } + if (claims.Count > 0) + { + context.Principal!.AddIdentity(new ClaimsIdentity(claims)); + } + } + + private sealed record UserinfoProfile(string? DisplayName, string? Email); +} diff --git a/src/ProductReviews.Api/Infrastructure/CurrentUser.cs b/src/ProductReviews.Api/Infrastructure/CurrentUser.cs new file mode 100644 index 0000000..722815d --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/CurrentUser.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using ProductReviews.Domain.Reviews; +using StrongTypes; + +namespace ProductReviews.Api.Infrastructure; + +/// Maps the authenticated principal to the domain's . +/// AuthorId is the SHA-256 of the OIDC sub claim (first 16 bytes as a Guid), so the +/// database stays Guid-keyed whatever shape the identity provider's ids have (ADR-0005). +public static class CurrentUser +{ + public static ReviewAuthor Author(this ClaimsPrincipal user) + { + var subject = user.FindFirstValue("sub") + ?? throw new InvalidOperationException("Authenticated principal is missing the 'sub' claim."); + var displayName = user.FindFirstValue("name") ?? subject; + return new ReviewAuthor(SubjectToGuid(subject), displayName.ToNonEmpty()); + } + + public static Guid? AuthorIdOrNull(this ClaimsPrincipal user) + => user.Identity?.IsAuthenticated == true && user.FindFirstValue("sub") is { } subject + ? SubjectToGuid(subject) + : null; + + private static Guid SubjectToGuid(string subject) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(subject)); + return new Guid(hash.AsSpan(0, 16)); + } +} diff --git a/src/ProductReviews.Api/Infrastructure/ErrorHandling.cs b/src/ProductReviews.Api/Infrastructure/ErrorHandling.cs new file mode 100644 index 0000000..ceab5d3 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/ErrorHandling.cs @@ -0,0 +1,12 @@ +namespace ProductReviews.Api.Infrastructure; + +/// Unhandled exceptions become RFC 7807 responses. Business failures never get +/// here — they travel as Result error enums and are mapped by controllers (ADR-0003). +public static class ErrorHandling +{ + public static void Configure(WebApplicationBuilder builder) + => builder.Services.AddProblemDetails(); + + public static void Use(WebApplication app) + => app.UseExceptionHandler(); +} diff --git a/src/ProductReviews.Api/Infrastructure/Observability.cs b/src/ProductReviews.Api/Infrastructure/Observability.cs new file mode 100644 index 0000000..4797000 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/Observability.cs @@ -0,0 +1,11 @@ +using ProductReviews.Domain.Persistence; + +namespace ProductReviews.Api.Infrastructure; + +/// API-specific health checks. Tracing, metrics, and the /health endpoints +/// themselves come from ServiceDefaults. +public static class Observability +{ + public static void Configure(WebApplicationBuilder builder) + => builder.Services.AddHealthChecks().AddDbContextCheck("database"); +} diff --git a/src/ProductReviews.Api/Infrastructure/OpenApi.cs b/src/ProductReviews.Api/Infrastructure/OpenApi.cs new file mode 100644 index 0000000..6e2be9e --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/OpenApi.cs @@ -0,0 +1,47 @@ +using Microsoft.OpenApi; +using ProductReviews.Domain.Reviews; +using StrongTypes.OpenApi.Swashbuckle; + +namespace ProductReviews.Api.Infrastructure; + +/// Swashbuckle with AddStrongTypes(), so the document carries the real +/// constraints (email format, minLength, exclusiveMinimum). The generated TypeScript +/// client is built from this document — it is the frontend contract (ADR-0004). +public static class OpenApi +{ + public static void Configure(WebApplicationBuilder builder) + { + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.AddStrongTypes(); + + // Our own Rating wrapper is unknown to the package, so its bounds are declared here — + // the one place the 1..5 rule exists outside the type itself (schema, not validation). + options.MapType(() => RatingSchema()); + options.MapType(() => RatingSchema()); + + options.SupportNonNullableReferenceTypes(); + options.NonNullableReferenceTypesAsRequired(); + }); + } + + public static void Use(WebApplication app) + { + if (!app.Environment.IsDevelopment()) + { + return; + } + + app.UseSwagger(); + app.UseSwaggerUI(); + } + + private static OpenApiSchema RatingSchema() => new() + { + Type = JsonSchemaType.Integer, + Format = "int32", + Minimum = Rating.MinimumStars.ToString(), + Maximum = Rating.MaximumStars.ToString(), + }; +} diff --git a/src/ProductReviews.Api/Infrastructure/Persistence.cs b/src/ProductReviews.Api/Infrastructure/Persistence.cs new file mode 100644 index 0000000..ec319e8 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/Persistence.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using ProductReviews.Domain.Persistence; +using ProductReviews.Domain.Persistence.Seeding; +using StrongTypes.EfCore; + +namespace ProductReviews.Api.Infrastructure; + +public static class Persistence +{ + /// Matches the database resource name in the AppHost. + public const string DatabaseResourceName = "productreviews"; + + public static void Configure(WebApplicationBuilder builder) + => builder.AddNpgsqlDbContext( + DatabaseResourceName, + configureDbContextOptions: options => options.UseStrongTypes()); + + /// Development-only convenience (ADR-0006): production applies migrations + /// from the deploy pipeline and never seeds. + public static async Task MigrateAndSeedAsync(WebApplication app) + { + if (!app.Environment.IsDevelopment()) + { + return; + } + + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + await DemoDataSeeder.SeedAsync(dbContext, scope.ServiceProvider.GetRequiredService(), CancellationToken.None); + } +} diff --git a/src/ProductReviews.Api/Infrastructure/RateLimits.cs b/src/ProductReviews.Api/Infrastructure/RateLimits.cs new file mode 100644 index 0000000..02689f9 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/RateLimits.cs @@ -0,0 +1,34 @@ +using System.Security.Claims; +using System.Threading.RateLimiting; + +namespace ProductReviews.Api.Infrastructure; + +/// A single fixed-window limiter on write endpoints, partitioned per user +/// (falling back to the caller's IP for the odd unauthenticated hit). Reads are unlimited. +public static class RateLimits +{ + public const string WritePolicy = "write"; + + private const int WritesPerWindow = 30; + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + public static void Configure(WebApplicationBuilder builder) + { + builder.Services.AddRateLimiter(limiter => + { + limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + limiter.AddPolicy(WritePolicy, httpContext => RateLimitPartition.GetFixedWindowLimiter( + httpContext.User.FindFirstValue("sub") + ?? httpContext.Connection.RemoteIpAddress?.ToString() + ?? "anonymous", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = WritesPerWindow, + Window = Window, + QueueLimit = 0, + })); + }); + } + + public static void Use(WebApplication app) => app.UseRateLimiter(); +} diff --git a/src/ProductReviews.Api/ProductReviews.Api.csproj b/src/ProductReviews.Api/ProductReviews.Api.csproj new file mode 100644 index 0000000..c54f982 --- /dev/null +++ b/src/ProductReviews.Api/ProductReviews.Api.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/ProductReviews.Api/Program.cs b/src/ProductReviews.Api/Program.cs new file mode 100644 index 0000000..94a0aa2 --- /dev/null +++ b/src/ProductReviews.Api/Program.cs @@ -0,0 +1,35 @@ +using ProductReviews.Api.Infrastructure; +using ProductReviews.Domain; +using ProductReviews.ServiceDefaults; +using StrongTypes.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +Persistence.Configure(builder); +Authentication.Configure(builder); +RateLimits.Configure(builder); +ErrorHandling.Configure(builder); +OpenApi.Configure(builder); +Observability.Configure(builder); + +builder.Services.AddControllers(); +builder.Services.AddStrongTypes(options => options.JsonErrorKeyCasing = JsonErrorKeyCasing.CamelCase); +builder.Services.AddReviewsDomain(); + +var app = builder.Build(); + +ErrorHandling.Use(app); +Authentication.Use(app); +RateLimits.Use(app); +OpenApi.Use(app); + +app.MapDefaultEndpoints(); +app.MapControllers(); + +await Persistence.MigrateAndSeedAsync(app); + +await app.RunAsync(); + +public partial class Program; diff --git a/src/ProductReviews.Api/Properties/launchSettings.json b/src/ProductReviews.Api/Properties/launchSettings.json new file mode 100644 index 0000000..1b8d574 --- /dev/null +++ b/src/ProductReviews.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5184", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7129;http://localhost:5184", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/ProductReviews.Api/appsettings.Development.json b/src/ProductReviews.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/ProductReviews.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/ProductReviews.Api/appsettings.json b/src/ProductReviews.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/ProductReviews.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/ProductReviews.AppHost/.editorconfig b/src/ProductReviews.AppHost/.editorconfig new file mode 100644 index 0000000..24240a0 --- /dev/null +++ b/src/ProductReviews.AppHost/.editorconfig @@ -0,0 +1,7 @@ +# The AppHost's whole job is cross-resource orchestration: env callbacks await +# TaskCompletionSources produced elsewhere, and fire-and-forget provisioning logs to the +# console. The VSTHRD deadlock rules target JoinableTaskFactory contexts and misfire here. +[*.cs] +dotnet_diagnostic.VSTHRD002.severity = none +dotnet_diagnostic.VSTHRD003.severity = none +dotnet_diagnostic.VSTHRD103.severity = none diff --git a/src/ProductReviews.AppHost/AppHost.cs b/src/ProductReviews.AppHost/AppHost.cs new file mode 100644 index 0000000..21f22da --- /dev/null +++ b/src/ProductReviews.AppHost/AppHost.cs @@ -0,0 +1,157 @@ +using ProductReviews.AppHost.Zitadel; + +var builder = DistributedApplication.CreateBuilder(args); + +// E2E mode (set by the Playwright harness): throwaway containers so every run starts clean, +// and the frontend serves a production build on a pinned port the tests know up front. +var e2e = string.Equals(builder.Configuration["ProductReviews:E2E"], "true", StringComparison.OrdinalIgnoreCase); + +var postgresUsername = builder.AddParameter("postgres-username", "postgres"); +var postgresPassword = builder.AddParameter("postgres-password", "postgres-dev-password", secret: true); + +var postgres = e2e + ? builder.AddPostgres("postgres", postgresUsername, postgresPassword) + : builder.AddPostgres("postgres", postgresUsername, postgresPassword) + .WithLifetime(ContainerLifetime.Persistent) + .WithContainerName("productreviews-postgres") + .WithDataVolume("productreviews-postgres-data"); + +var productReviewsDatabase = postgres.AddDatabase("productreviews"); +// "zitadeldb", not "zitadel" — the container resource already claims that name. +var zitadelDatabase = postgres.AddDatabase("zitadeldb"); + +// --- Identity provider: self-hosted Zitadel (ADR-0005) ----------------------- +// Zitadel writes its first-instance machine-user PATs into this bind-mounted dir on first +// init; the AppHost reads them to drive provisioning. Gitignored. E2E gets a unique temp +// dir: its Postgres is throwaway, so a PAT left over from a previous run would belong to a +// dead instance and provisioning would 401. +var repositoryRoot = Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "..")); +var zitadelKeyDirectory = e2e + ? Path.Combine(Path.GetTempPath(), $"productreviews-e2e-zitadel-{Guid.NewGuid():N}") + : Path.Combine(repositoryRoot, ".zitadel"); +Directory.CreateDirectory(zitadelKeyDirectory); +// Zitadel runs as a non-root uid and must be able to create files in the bind mount on Linux; +// Docker Desktop (Windows/macOS) ignores bind-mount permissions, so this is a no-op there. +if (!OperatingSystem.IsWindows()) +{ + File.SetUnixFileMode(zitadelKeyDirectory, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute); +} +var patPath = Path.Combine(zitadelKeyDirectory, "pat.txt"); + +var zitadel = builder.AddZitadel(postgresUsername, postgresPassword, postgres.GetEndpoint("tcp"), zitadelDatabase, zitadelKeyDirectory); +if (!e2e) +{ + zitadel.WithContainerName("productreviews-zitadel").WithLifetime(ContainerLifetime.Persistent); +} + +var zitadelHttp = zitadel.GetEndpoint("http"); +var zitadelAuthority = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); +builder.Eventing.Subscribe(zitadel.Resource, (_, _) => +{ + zitadelAuthority.TrySetResult(zitadelHttp.Url); + return Task.CompletedTask; +}); + +var zitadelLogin = builder.AddZitadelLogin(zitadel, zitadelKeyDirectory); +if (!e2e) +{ + zitadelLogin.WithContainerName("productreviews-zitadel-login").WithLifetime(ContainerLifetime.Persistent); +} + +// The SPA client id produced by provisioning; the api (token audience) and the frontend +// (login client id) both await it. +var oidc = new ZitadelOidc(); + +// --- API ---------------------------------------------------------------------- +// launchProfileName: null bypasses launchSettings (dynamic port, so the frontend can reference +// it) — which also drops ASPNETCORE_ENVIRONMENT, so set it explicitly. +var api = builder.AddProject("api", launchProfileName: null) + .WithHttpEndpoint() + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") + .WithReference(productReviewsDatabase).WaitFor(productReviewsDatabase) + .WaitFor(zitadel) + .WithEnvironment(async context => + { + context.EnvironmentVariables["Oidc__Authority"] = await zitadelAuthority.Task; + + // Audience = the provisioned SPA client id. Falls back to issuer-only validation if + // provisioning hasn't produced one (sign-in would be broken anyway; the API still serves reads). + var done = await Task.WhenAny(oidc.ClientId.Task, Task.Delay(TimeSpan.FromSeconds(120), context.CancellationToken)); + if (done == oidc.ClientId.Task && !string.IsNullOrEmpty(oidc.ClientId.Task.Result)) + { + context.EnvironmentVariables["Oidc__Audience"] = oidc.ClientId.Task.Result; + } + }); + +// --- Frontend: Vue 3 + Vite SPA (ADR-0008) ------------------------------------- +// AddNpmApp only runs the script — install once when node_modules is absent and gate on it. +var frontendDirectory = Path.Combine(repositoryRoot, "frontend"); +IResourceBuilder? frontendInstall = null; +if (!Directory.Exists(Path.Combine(frontendDirectory, "node_modules"))) +{ + var npm = OperatingSystem.IsWindows() ? "npm.cmd" : "npm"; + frontendInstall = builder.AddExecutable("frontend-install", npm, frontendDirectory, "ci"); +} + +// E2E builds inside this resource (not in the Playwright harness): import.meta.env.VITE_* +// values are baked into the bundle at BUILD time, so the build must run with this env. +var frontend = builder.AddNpmApp("frontend", "../../frontend", e2e ? "serve:e2e" : "dev") + .WithEnvironment("API_PROXY_TARGET", api.GetEndpoint("http")) + .WithReference(api) + .WaitFor(api) + .WithHttpEndpoint(port: e2e ? 4173 : 5173, env: "PORT") + .WithExternalHttpEndpoints() + .WithEnvironment(async context => + { + // Browser-facing OIDC config, baked into the Vite dev/preview server's env before start. + context.EnvironmentVariables["VITE_OIDC_AUTHORITY"] = await zitadelAuthority.Task; + context.EnvironmentVariables["VITE_OIDC_CLIENT_ID"] = await oidc.ClientId.Task; + }); + +if (frontendInstall is not null) +{ + frontend.WaitForCompletion(frontendInstall); +} + +// Provision once the frontend's endpoint is allocated (allocation happens before start and is +// not gated by WaitFor — provisioning on frontend START would deadlock, because the frontend +// waits for the api, and the api's env awaits the client id this produces). Fire-and-forget: +// provisioning itself waits for Zitadel to come up. +var frontendHttp = frontend.GetEndpoint("http"); +builder.Eventing.Subscribe(frontend.Resource, (_, _) => +{ + var frontendOrigin = frontendHttp.Url; + _ = Task.Run( + async () => + { + try + { + var authority = await zitadelAuthority.Task; + var (pat, clientId) = await ZitadelProvisioning.EnsureSpaClientWithFreshPatAsync( + patPath, authority, frontendOrigin, Console.WriteLine, CancellationToken.None); + oidc.ClientId.TrySetResult(clientId); + + // Isolated so a seeding hiccup never blocks sign-in for existing users. + try + { + await ZitadelProvisioning.EnsureDemoUsersAsync(authority, pat, Console.WriteLine, CancellationToken.None); + } + catch (Exception exception) + { + Console.Error.WriteLine($"Zitadel demo-user seeding failed (existing users can still sign in): {exception.Message}"); + } + } + catch (Exception exception) + { + Console.Error.WriteLine($"Zitadel provisioning failed; sign-in will be unavailable: {exception.Message}"); + oidc.ClientId.TrySetResult(""); + } + }, + CancellationToken.None); + return Task.CompletedTask; +}); + +builder.Build().Run(); diff --git a/src/ProductReviews.AppHost/ProductReviews.AppHost.csproj b/src/ProductReviews.AppHost/ProductReviews.AppHost.csproj new file mode 100644 index 0000000..47120fe --- /dev/null +++ b/src/ProductReviews.AppHost/ProductReviews.AppHost.csproj @@ -0,0 +1,17 @@ + + + + Exe + 1ff96779-171b-4b2b-a1c1-87d0baaea48e + + + + + + + + + + + + diff --git a/src/ProductReviews.AppHost/Properties/launchSettings.json b/src/ProductReviews.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..7ce6b1c --- /dev/null +++ b/src/ProductReviews.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17202;http://localhost:15055", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21043", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23276", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22027" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15055", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18228", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20199" + } + } + } +} diff --git a/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs b/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs new file mode 100644 index 0000000..bdacff3 --- /dev/null +++ b/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs @@ -0,0 +1,112 @@ +namespace ProductReviews.AppHost.Zitadel; + +/// The self-hosted Zitadel containers (ADR-0005): the core (API + Console) and the +/// separate Login V2 app the core redirects browsers to for the hosted sign-in page. +internal static class ZitadelHosting +{ + public const string Image = "ghcr.io/zitadel/zitadel"; + public const string Tag = "v4.15.1"; + public const string LoginImage = "ghcr.io/zitadel/zitadel-login"; + + // Dev-only masterkey (encrypts secrets at rest) — a real deployment supplies its own. + public const string DevMasterkey = "MasterkeyNeedsToHave32Characters"; + + // Fixed host port so the OIDC issuer is the stable http://localhost:8090 the browser, + // the SPA, and the API all agree on. + public const int CorePort = 8090; + + public const int LoginPort = 3001; + public const string LoginBaseUri = "http://localhost:3001/ui/v2/login/"; + + // The core writes the IAM_LOGIN_CLIENT machine user's PAT here on first init + // (bind-mounted dir shared with the login container). + public const string LoginClientPatContainerPath = "/machinekey/login-client.pat"; + + /// Password for the two seeded demo reviewers; meets Zitadel's default complexity policy. + /// Documented in the README — local demo only. + public const string DemoUserPassword = "ProductReviews123!"; + + public static IResourceBuilder AddZitadel( + this IDistributedApplicationBuilder builder, + IResourceBuilder postgresUsername, + IResourceBuilder postgresPassword, + EndpointReference postgresTcp, + IResourceBuilder zitadelDatabase, + string machineKeyDirectory) => + builder.AddContainer("zitadel", Image, Tag) + .WithArgs("start-from-init", "--masterkeyFromEnv", "--tlsMode", "disabled") + .WithHttpEndpoint(port: CorePort, targetPort: 8080, name: "http") + .WithBindMount(machineKeyDirectory, "/machinekey") + .WithEnvironment("ZITADEL_MASTERKEY", DevMasterkey) + .WithEnvironment("ZITADEL_EXTERNALSECURE", "false") + .WithEnvironment("ZITADEL_EXTERNALDOMAIN", "localhost") + .WithEnvironment("ZITADEL_EXTERNALPORT", CorePort.ToString()) + .WithEnvironment("ZITADEL_TLS_ENABLED", "false") + // First-instance machine user + long-lived PAT, written to the bind-mounted + // /machinekey/pat.txt. Applied only on the very first init of a fresh instance. + .WithEnvironment("ZITADEL_FIRSTINSTANCE_PATPATH", "/machinekey/pat.txt") + .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME", "productreviews-admin-sa") + .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME", "ProductReviews Admin SA") + .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE", "2100-01-01T00:00:00Z") + // Login V2: a second machine user (IAM_LOGIN_CLIENT) whose PAT the login container + // reads; the feature is switched on at init with the login app's URLs baked in. + // These settings only take effect on a FRESH init — changing them requires resetting + // the zitadel container and its database. + .WithEnvironment("ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH", LoginClientPatContainerPath) + .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME", "login-client") + .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME", "Login Client") + .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE", "2100-01-01T00:00:00Z") + .WithEnvironment("ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED", "true") + .WithEnvironment("ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI", LoginBaseUri) + .WithEnvironment("ZITADEL_OIDC_DEFAULTLOGINURLV2", $"{LoginBaseUri}login?authRequest=") + .WithEnvironment("ZITADEL_OIDC_DEFAULTLOGOUTURLV2", $"{LoginBaseUri}logout?post_logout_redirect=") + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_DATABASE", "zitadeldb") + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_USER_USERNAME", postgresUsername) + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_USER_PASSWORD", postgresPassword) + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE", "disable") + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME", postgresUsername) + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD", postgresPassword) + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE", "disable") + .WithEnvironment(context => + { + // Host/port as seen from inside the Aspire container network. + context.EnvironmentVariables["ZITADEL_DATABASE_POSTGRES_HOST"] = postgresTcp.Property(EndpointProperty.Host); + context.EnvironmentVariables["ZITADEL_DATABASE_POSTGRES_PORT"] = postgresTcp.Property(EndpointProperty.Port); + }) + .WaitFor(zitadelDatabase); + + public static IResourceBuilder AddZitadelLogin( + this IDistributedApplicationBuilder builder, + IResourceBuilder zitadel, + string machineKeyDirectory) + { + var coreHttp = zitadel.GetEndpoint("http"); + var loginClientPatPath = Path.Combine(machineKeyDirectory, "login-client.pat"); + return builder.AddContainer("zitadel-login", LoginImage, Tag) + .WithHttpEndpoint(port: LoginPort, targetPort: 3000, name: "http") + .WithBindMount(machineKeyDirectory, "/machinekey") + .WithEnvironment("NEXT_PUBLIC_BASE_PATH", "/ui/v2/login") + .WithEnvironment("ZITADEL_SERVICE_USER_TOKEN_FILE", LoginClientPatContainerPath) + // Zitadel resolves the instance from the host header, so back-channel calls over the + // container network must present the public host. + .WithEnvironment("CUSTOM_REQUEST_HEADERS", (string)$"Host:localhost:{CorePort},X-Forwarded-Proto:http") + .WithEnvironment(async context => + { + context.EnvironmentVariables["ZITADEL_API_URL"] = + ReferenceExpression.Create($"http://{coreHttp.Property(EndpointProperty.Host)}:{coreHttp.Property(EndpointProperty.Port)}"); + + // On a cold init the core writes the PAT only after first-instance setup finishes, + // and the login app exits immediately when the file is missing — so block start + // until it lands (WaitFor only gates on the container running, not on init done). + for (var attempt = 0; attempt < 300 && !context.CancellationToken.IsCancellationRequested; attempt++) + { + if (File.Exists(loginClientPatPath)) + { + return; + } + await Task.Delay(1000, context.CancellationToken); + } + }) + .WaitFor(zitadel); + } +} diff --git a/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs b/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs new file mode 100644 index 0000000..63baf5f --- /dev/null +++ b/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs @@ -0,0 +1,321 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace ProductReviews.AppHost.Zitadel; + +/// Carries the OIDC client id from provisioning (which knows the frontend's origin) +/// to the api and frontend resources — both await it in their env callbacks. +public sealed class ZitadelOidc +{ + public TaskCompletionSource ClientId { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); +} + +/// Idempotent dev provisioning against the self-hosted Zitadel: ensures a project, +/// a PKCE SPA client with JWT access tokens (so the API validates via JWKS), and the two demo +/// reviewers. Authenticates with the machine-user PAT Zitadel writes on first-instance init. +/// Everything is find-or-create, so re-running against a persistent instance is safe. +internal static class ZitadelProvisioning +{ + private const string ProjectName = "ProductReviews"; + private const string ApplicationName = "productreviews-spa"; + + public static readonly IReadOnlyList<(string UserName, string FirstName, string LastName)> DemoUsers = + [ + ("demo@productreviews.local", "Demo", "Reviewer"), + ("critic@productreviews.local", "Casey", "Critic"), + ]; + + public static async Task ReadPatAsync(string patPath, string authority, CancellationToken cancellationToken) + { + // A cold Zitadel can take minutes before first-instance init writes the PAT — wait + // patiently. But if it is already serving and still has no PAT after a short grace, the + // instance predates the FIRSTINSTANCE PAT settings and never will write one — fail fast + // with a reset hint instead of hanging. + using var http = new HttpClient { BaseAddress = new Uri(authority) }; + var servingSeconds = 0; + for (var attempt = 0; attempt < 300 && !cancellationToken.IsCancellationRequested; attempt++) + { + if (File.Exists(patPath)) + { + var pat = (await File.ReadAllTextAsync(patPath, cancellationToken)).Trim(); + if (!string.IsNullOrEmpty(pat)) + { + return pat; + } + } + + servingSeconds = await IsServingAsync(http, cancellationToken) ? servingSeconds + 1 : 0; + if (servingSeconds >= 15) + { + throw new InvalidOperationException( + $"Zitadel is serving but wrote no machine-user PAT at {patPath}. Reset it so init re-runs: " + + "remove the zitadel container and the Postgres data volume, then restart the AppHost."); + } + + await Task.Delay(1000, cancellationToken); + } + throw new InvalidOperationException($"Zitadel PAT not found at {patPath} after 300s."); + } + + /// Reads the PAT and provisions the SPA client, retrying on 401: during a cold + /// first init the PAT file on disk can briefly be a previous instance's (Zitadel overwrites + /// it only when init finishes), so a rejection means "re-read and try again", not "stop". + public static async Task<(string Pat, string ClientId)> EnsureSpaClientWithFreshPatAsync( + string patPath, string authority, string frontendOrigin, Action log, CancellationToken cancellationToken) + { + for (var attempt = 0; ; attempt++) + { + var pat = await ReadPatAsync(patPath, authority, cancellationToken); + try + { + return (pat, await EnsureSpaClientAsync(authority, pat, frontendOrigin, log, cancellationToken)); + } + catch (HttpRequestException exception) when (exception.StatusCode == HttpStatusCode.Unauthorized && attempt < 60) + { + log("Zitadel: PAT rejected (401) — waiting for the fresh instance to write its own PAT…"); + await Task.Delay(2000, cancellationToken); + } + } + } + + public static async Task EnsureSpaClientAsync( + string authority, string pat, string frontendOrigin, Action log, CancellationToken cancellationToken) + { + var redirectUris = new[] { $"{frontendOrigin}/auth/callback" }; + var postLogoutUris = new[] { frontendOrigin }; + + using var http = CreateClient(authority, pat); + + await WaitForDiscoveryAsync(http, cancellationToken); + await WaitForManagementReadyAsync(http, cancellationToken); + + var projectId = await FindProjectAsync(http, cancellationToken) ?? await CreateProjectAsync(http, cancellationToken); + var (applicationId, clientId) = await FindApplicationAsync(http, projectId, cancellationToken); + + if (clientId is null) + { + clientId = await CreateApplicationAsync(http, projectId, redirectUris, postLogoutUris, cancellationToken); + log($"Zitadel: created OIDC app '{ApplicationName}' (clientId {clientId})"); + } + else + { + // Keep redirect URIs in step with the frontend origin across runs. + await UpdateApplicationConfigAsync(http, projectId, applicationId!, redirectUris, postLogoutUris, cancellationToken); + log($"Zitadel: reused OIDC app '{ApplicationName}' (clientId {clientId})"); + } + + return clientId; + } + + public static async Task EnsureDemoUsersAsync(string authority, string pat, Action log, CancellationToken cancellationToken) + { + using var http = CreateClient(authority, pat); + + foreach (var (userName, firstName, lastName) in DemoUsers) + { + if (await UserExistsAsync(http, userName, cancellationToken)) + { + log($"Zitadel: demo user '{userName}' already exists"); + continue; + } + + using var response = await http.PostAsJsonAsync("management/v1/users/human/_import", new + { + userName, + profile = new { firstName, lastName, displayName = $"{firstName} {lastName}" }, + email = new { email = userName, isEmailVerified = true }, + password = ZitadelHosting.DemoUserPassword, + passwordChangeRequired = false, + }, cancellationToken); + + log(response.IsSuccessStatusCode + ? $"Zitadel: created demo user '{userName}'" + : $"Zitadel: demo user import failed ({(int)response.StatusCode}): {await response.Content.ReadAsStringAsync(cancellationToken)}"); + } + } + + private static HttpClient CreateClient(string authority, string pat) + { + var http = new HttpClient { BaseAddress = new Uri(authority) }; + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pat); + return http; + } + + private static async Task UserExistsAsync(HttpClient http, string userName, CancellationToken cancellationToken) + { + using var response = await http.PostAsJsonAsync("management/v1/users/_search", new + { + queries = new object[] { new { userNameQuery = new { userName, method = "TEXT_QUERY_METHOD_EQUALS" } } }, + }, cancellationToken); + response.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(await response.Content.ReadAsByteArrayAsync(cancellationToken)); + return document.RootElement.TryGetProperty("result", out var result) + && result.ValueKind == JsonValueKind.Array && result.GetArrayLength() > 0; + } + + private static async Task WaitForDiscoveryAsync(HttpClient http, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 180 && !cancellationToken.IsCancellationRequested; attempt++) + { + if (await IsServingAsync(http, cancellationToken)) + { + return; + } + await Task.Delay(1000, cancellationToken); + } + throw new InvalidOperationException("Zitadel OIDC discovery did not become reachable within 180s."); + } + + private static async Task IsServingAsync(HttpClient http, CancellationToken cancellationToken) + { + try + { + using var response = await http.GetAsync(".well-known/openid-configuration", cancellationToken); + return response.IsSuccessStatusCode; + } + catch (HttpRequestException) + { + return false; + } + } + + // Discovery answers before the management backend is ready (first call would 503) — poll an + // authenticated endpoint until it stops returning 5xx. Best-effort: on timeout the real calls + // surface the error. + private static async Task WaitForManagementReadyAsync(HttpClient http, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 120 && !cancellationToken.IsCancellationRequested; attempt++) + { + try + { + using var response = await http.GetAsync("auth/v1/users/me", cancellationToken); + if ((int)response.StatusCode < 500) + { + return; + } + } + catch (HttpRequestException) + { + } + await Task.Delay(1000, cancellationToken); + } + } + + private static async Task FindProjectAsync(HttpClient http, CancellationToken cancellationToken) + { + using var response = await http.PostAsJsonAsync("management/v1/projects/_search", new + { + queries = new object[] { new { nameQuery = new { name = ProjectName, method = "TEXT_QUERY_METHOD_EQUALS" } } }, + }, cancellationToken); + response.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(await response.Content.ReadAsByteArrayAsync(cancellationToken)); + if (document.RootElement.TryGetProperty("result", out var result) && result.ValueKind == JsonValueKind.Array) + { + foreach (var project in result.EnumerateArray()) + { + if (project.TryGetProperty("id", out var id)) + { + return id.GetString(); + } + } + } + return null; + } + + private static async Task CreateProjectAsync(HttpClient http, CancellationToken cancellationToken) + { + using var response = await http.PostAsJsonAsync("management/v1/projects", new { name = ProjectName }, cancellationToken); + response.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(await response.Content.ReadAsByteArrayAsync(cancellationToken)); + return document.RootElement.GetProperty("id").GetString()!; + } + + private static async Task<(string? ApplicationId, string? ClientId)> FindApplicationAsync( + HttpClient http, string projectId, CancellationToken cancellationToken) + { + using var response = await http.PostAsJsonAsync($"management/v1/projects/{projectId}/apps/_search", new + { + queries = new object[] { new { nameQuery = new { name = ApplicationName, method = "TEXT_QUERY_METHOD_EQUALS" } } }, + }, cancellationToken); + response.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(await response.Content.ReadAsByteArrayAsync(cancellationToken)); + if (document.RootElement.TryGetProperty("result", out var result) && result.ValueKind == JsonValueKind.Array) + { + foreach (var application in result.EnumerateArray()) + { + var applicationId = application.TryGetProperty("id", out var id) ? id.GetString() : null; + var clientId = application.TryGetProperty("oidcConfig", out var config) && config.TryGetProperty("clientId", out var cid) + ? cid.GetString() + : null; + if (clientId is not null) + { + return (applicationId, clientId); + } + } + } + return (null, null); + } + + private static async Task CreateApplicationAsync( + HttpClient http, string projectId, string[] redirectUris, string[] postLogoutUris, CancellationToken cancellationToken) + { + using var response = await http.PostAsJsonAsync( + $"management/v1/projects/{projectId}/apps/oidc", + OidcConfigBody(redirectUris, postLogoutUris, withName: true), + cancellationToken); + response.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(await response.Content.ReadAsByteArrayAsync(cancellationToken)); + return document.RootElement.GetProperty("clientId").GetString()!; + } + + private static async Task UpdateApplicationConfigAsync( + HttpClient http, string projectId, string applicationId, string[] redirectUris, string[] postLogoutUris, + CancellationToken cancellationToken) + { + using var response = await http.PutAsJsonAsync( + $"management/v1/projects/{projectId}/apps/{applicationId}/oidc_config", + OidcConfigBody(redirectUris, postLogoutUris, withName: false), + cancellationToken); + if (response.IsSuccessStatusCode) + { + return; + } + + // Zitadel rejects a no-op update with 400 "No changes" — which is success here. + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (response.StatusCode == HttpStatusCode.BadRequest && body.Contains("No changes", StringComparison.OrdinalIgnoreCase)) + { + return; + } + throw new HttpRequestException($"Updating OIDC app config failed ({(int)response.StatusCode}): {body}"); + } + + // A public SPA client: authorization code + PKCE, no secret. USER_AGENT (not WEB) so Zitadel + // serves CORS on the token endpoint for the registered redirect origins — the browser itself + // exchanges the code. JWT access tokens so the API validates via JWKS. devMode relaxes the + // https requirement for localhost redirect URIs. + private static object OidcConfigBody(string[] redirectUris, string[] postLogoutUris, bool withName) + { + var config = new Dictionary + { + ["redirectUris"] = redirectUris, + ["responseTypes"] = new[] { "OIDC_RESPONSE_TYPE_CODE" }, + ["grantTypes"] = new[] { "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN" }, + ["appType"] = "OIDC_APP_TYPE_USER_AGENT", + ["authMethodType"] = "OIDC_AUTH_METHOD_TYPE_NONE", + ["postLogoutRedirectUris"] = postLogoutUris, + ["devMode"] = true, + ["accessTokenType"] = "OIDC_TOKEN_TYPE_JWT", + ["accessTokenRoleAssertion"] = true, + // Profile claims (name) ride inside the id_token, so the SPA needs no userinfo call. + ["idTokenUserinfoAssertion"] = true, + }; + if (withName) + { + config["name"] = ApplicationName; + } + return config; + } +} diff --git a/src/ProductReviews.AppHost/appsettings.Development.json b/src/ProductReviews.AppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/ProductReviews.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/ProductReviews.AppHost/appsettings.json b/src/ProductReviews.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/src/ProductReviews.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/ProductReviews.AppHost/aspire.config.json b/src/ProductReviews.AppHost/aspire.config.json new file mode 100644 index 0000000..1e27328 --- /dev/null +++ b/src/ProductReviews.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "ProductReviews.AppHost.csproj" + } +} diff --git a/src/ProductReviews.Domain/Catalog/GetCatalog.cs b/src/ProductReviews.Domain/Catalog/GetCatalog.cs new file mode 100644 index 0000000..b5bc1bd --- /dev/null +++ b/src/ProductReviews.Domain/Catalog/GetCatalog.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using ProductReviews.Domain.Persistence; + +namespace ProductReviews.Domain.Catalog; + +public sealed class GetCatalogHandler(ReviewsDbContext dbContext) +{ + public async Task> HandleAsync(CancellationToken cancellationToken) + => await dbContext.Products + .AsNoTracking() + .OrderBy(product => product.Id) + .ToListAsync(cancellationToken); +} diff --git a/src/ProductReviews.Domain/Catalog/GetProductDetail.cs b/src/ProductReviews.Domain/Catalog/GetProductDetail.cs new file mode 100644 index 0000000..823bc20 --- /dev/null +++ b/src/ProductReviews.Domain/Catalog/GetProductDetail.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using ProductReviews.Domain.Persistence; +using StrongTypes; + +namespace ProductReviews.Domain.Catalog; + +/// ViewerReviewId is the signed-in viewer's own review of this product, when they have one. +public sealed record ProductDetailModel(Product Product, Guid? ViewerReviewId); + +public sealed class GetProductDetailHandler(ReviewsDbContext dbContext) +{ + /// Null means the product does not exist. + public async Task HandleAsync(NonEmptyString slug, Guid? viewerId, CancellationToken cancellationToken) + { + var product = await dbContext.Products + .AsNoTracking() + .SingleOrDefaultAsync(p => p.Slug == slug, cancellationToken); + if (product is null) + { + return null; + } + + Guid? viewerReviewId = viewerId is { } viewer + ? await dbContext.Reviews + .Where(review => review.ProductId == product.Id && review.AuthorId == viewer) + .Select(review => (Guid?)review.Id) + .SingleOrDefaultAsync(cancellationToken) + : null; + + return new ProductDetailModel(product, viewerReviewId); + } +} diff --git a/src/ProductReviews.Domain/Catalog/Product.cs b/src/ProductReviews.Domain/Catalog/Product.cs new file mode 100644 index 0000000..7bee746 --- /dev/null +++ b/src/ProductReviews.Domain/Catalog/Product.cs @@ -0,0 +1,50 @@ +using ProductReviews.Domain.Reviews; +using StrongTypes; + +namespace ProductReviews.Domain.Catalog; + +public sealed class Product +{ + private Product() + { + Slug = null!; + Name = null!; + Description = null!; + } + + public Product(long id, NonEmptyString slug, NonEmptyString name, NonEmptyString description, NonEmptyString? imageUrl, DateTime createdAtUtc) + { + Id = id; + Slug = slug; + Name = name; + Description = description; + ImageUrl = imageUrl; + CreatedAtUtc = createdAtUtc; + } + + // Product ids come from the upstream catalog; this application never generates them. + public long Id { get; private set; } + + public NonEmptyString Slug { get; private set; } + + public NonEmptyString Name { get; private set; } + + public NonEmptyString Description { get; private set; } + + public NonEmptyString? ImageUrl { get; private set; } + + public NonNegative ReviewCount { get; private set; } + + /// Null until the first review exists — a product is "not yet rated", never rated zero. + public double? AverageRating { get; private set; } + + public DateTime CreatedAtUtc { get; private set; } + + public ICollection Reviews { get; } = []; + + public void RefreshRatingSummary(IReadOnlyCollection currentRatings) + { + ReviewCount = currentRatings.Count.ToNonNegative(); + AverageRating = currentRatings.Count == 0 ? null : currentRatings.Average(rating => (double)rating.Value); + } +} diff --git a/src/ProductReviews.Domain/Catalog/ProductWithReviews.cs b/src/ProductReviews.Domain/Catalog/ProductWithReviews.cs new file mode 100644 index 0000000..3a8449d --- /dev/null +++ b/src/ProductReviews.Domain/Catalog/ProductWithReviews.cs @@ -0,0 +1,15 @@ +using ProductReviews.Domain.Reviews; + +namespace ProductReviews.Domain.Catalog; + +/// Proof-of-loading aggregate: it can only be constructed with the product's +/// reviews in hand, so code holding one never meets an unloaded navigation. +/// Construct via CompleteQueries, which owns the matching Include chain. +public sealed record ProductWithReviews(Product Product, IReadOnlyList Reviews) +{ + public static ProductWithReviews FromCompleteQuery(Product product) + => new(product, [.. product.Reviews]); + + public void RefreshRatingSummary() + => Product.RefreshRatingSummary([.. Reviews.Select(review => review.Rating)]); +} diff --git a/src/ProductReviews.Domain/DomainServices.cs b/src/ProductReviews.Domain/DomainServices.cs new file mode 100644 index 0000000..ff159b3 --- /dev/null +++ b/src/ProductReviews.Domain/DomainServices.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ProductReviews.Domain.Catalog; +using ProductReviews.Domain.Reviews; +using ProductReviews.Domain.Votes; + +namespace ProductReviews.Domain; + +public static class DomainServices +{ + public static IServiceCollection AddReviewsDomain(this IServiceCollection services) + { + services.TryAddSingleton(TimeProvider.System); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/ProductReviews.Domain/Persistence/CompleteQueries.cs b/src/ProductReviews.Domain/Persistence/CompleteQueries.cs new file mode 100644 index 0000000..4f77977 --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/CompleteQueries.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using ProductReviews.Domain.Catalog; +using ProductReviews.Domain.Votes; +using StrongTypes; + +namespace ProductReviews.Domain.Persistence; + +/// The single source of truth for what a "complete" aggregate loads. +/// Each method pairs an Include chain with the record that proves it ran. +public static class CompleteQueries +{ + public static async Task CompleteProductBySlug( + this ReviewsDbContext dbContext, NonEmptyString slug, CancellationToken cancellationToken) + { + var product = await dbContext.Products + .Include(p => p.Reviews) + .SingleOrDefaultAsync(p => p.Slug == slug, cancellationToken); + return product is null ? null : ProductWithReviews.FromCompleteQuery(product); + } + + public static async Task CompleteProductById( + this ReviewsDbContext dbContext, long productId, CancellationToken cancellationToken) + { + var product = await dbContext.Products + .Include(p => p.Reviews) + .SingleOrDefaultAsync(p => p.Id == productId, cancellationToken); + return product is null ? null : ProductWithReviews.FromCompleteQuery(product); + } + + public static async Task CompleteReviewById( + this ReviewsDbContext dbContext, Guid reviewId, CancellationToken cancellationToken) + { + var review = await dbContext.Reviews + .Include(r => r.Votes) + .SingleOrDefaultAsync(r => r.Id == reviewId, cancellationToken); + return review is null ? null : ReviewWithVotes.FromCompleteQuery(review); + } +} diff --git a/src/ProductReviews.Domain/Persistence/Configurations/ProductConfiguration.cs b/src/ProductReviews.Domain/Persistence/Configurations/ProductConfiguration.cs new file mode 100644 index 0000000..7482018 --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/Configurations/ProductConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ProductReviews.Domain.Catalog; + +namespace ProductReviews.Domain.Persistence.Configurations; + +internal sealed class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(product => product.Id).ValueGeneratedNever(); + builder.Property(product => product.Slug).HasMaxLength(100); + builder.HasIndex(product => product.Slug).IsUnique(); + builder.Property(product => product.Name).HasMaxLength(200); + builder.Property(product => product.Description).HasMaxLength(4000); + builder.Property(product => product.ImageUrl).HasMaxLength(500); + } +} diff --git a/src/ProductReviews.Domain/Persistence/Configurations/ReviewConfiguration.cs b/src/ProductReviews.Domain/Persistence/Configurations/ReviewConfiguration.cs new file mode 100644 index 0000000..4818fa7 --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/Configurations/ReviewConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ProductReviews.Domain.Catalog; +using ProductReviews.Domain.Reviews; + +namespace ProductReviews.Domain.Persistence.Configurations; + +internal sealed class ReviewConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(review => review.Id).ValueGeneratedNever(); + builder.Property(review => review.AuthorName).HasMaxLength(100); + builder.Property(review => review.Title).HasMaxLength(200); + builder.Property(review => review.Body).HasMaxLength(4000); + builder.Property(review => review.Pros).HasMaxLength(500); + builder.Property(review => review.Cons).HasMaxLength(500); + + builder.HasOne() + .WithMany(product => product.Reviews) + .HasForeignKey(review => review.ProductId) + .OnDelete(DeleteBehavior.Cascade); + + // One review per reviewer per product; deleting the review frees the slot. + builder.HasIndex(review => new { review.ProductId, review.AuthorId }) + .IsUnique() + .HasDatabaseName("uq_reviews_product_author"); + + // Covering indexes for the "most helpful" and rating sorts; "newest" rides the UUIDv7 PK. + builder.HasIndex(review => new { review.ProductId, review.Score, review.Id }) + .HasDatabaseName("ix_reviews_helpful"); + builder.HasIndex(review => new { review.ProductId, review.Rating, review.Score, review.Id }) + .HasDatabaseName("ix_reviews_rating"); + + // Backstop for the Rating invariant — the type makes invalid values unrepresentable + // in the application; this guards direct SQL writes. + builder.ToTable(table => table.HasCheckConstraint("ck_reviews_rating", "\"Rating\" BETWEEN 1 AND 5")); + } +} diff --git a/src/ProductReviews.Domain/Persistence/Configurations/ReviewVoteConfiguration.cs b/src/ProductReviews.Domain/Persistence/Configurations/ReviewVoteConfiguration.cs new file mode 100644 index 0000000..4674243 --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/Configurations/ReviewVoteConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ProductReviews.Domain.Reviews; +using ProductReviews.Domain.Votes; + +namespace ProductReviews.Domain.Persistence.Configurations; + +internal sealed class ReviewVoteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(vote => new { vote.ReviewId, vote.VoterId }); + + builder.HasOne() + .WithMany(review => review.Votes) + .HasForeignKey(vote => vote.ReviewId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/ProductReviews.Domain/Persistence/Migrations/.editorconfig b/src/ProductReviews.Domain/Persistence/Migrations/.editorconfig new file mode 100644 index 0000000..384bce3 --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/Migrations/.editorconfig @@ -0,0 +1,4 @@ +# Migrations are tool-generated; style rules don't apply to them. +[*.cs] +generated_code = true +dotnet_analyzer_diagnostic.severity = none diff --git a/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs b/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs new file mode 100644 index 0000000..a32e785 --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs @@ -0,0 +1,182 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ProductReviews.Domain.Persistence; + +#nullable disable + +namespace ProductReviews.Domain.Persistence.Migrations +{ + [DbContext(typeof(ReviewsDbContext))] + [Migration("20260715061407_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("AverageRating") + .HasColumnType("double precision"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ImageUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewCount") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Cons") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("Pros") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId", "AuthorId") + .IsUnique() + .HasDatabaseName("uq_reviews_product_author"); + + b.HasIndex("ProductId", "Score", "Id") + .HasDatabaseName("ix_reviews_helpful"); + + b.HasIndex("ProductId", "Rating", "Score", "Id") + .HasDatabaseName("ix_reviews_rating"); + + b.ToTable("Reviews", t => + { + t.HasCheckConstraint("ck_reviews_rating", "\"Rating\" BETWEEN 1 AND 5"); + }); + }); + + modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + { + b.Property("ReviewId") + .HasColumnType("uuid"); + + b.Property("VoterId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsUpvote") + .HasColumnType("boolean"); + + b.HasKey("ReviewId", "VoterId"); + + b.ToTable("ReviewVotes"); + }); + + modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + { + b.HasOne("ProductReviews.Domain.Catalog.Product", null) + .WithMany("Reviews") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + { + b.HasOne("ProductReviews.Domain.Reviews.Review", null) + .WithMany("Votes") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + { + b.Navigation("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.cs b/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.cs new file mode 100644 index 0000000..2276383 --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.cs @@ -0,0 +1,117 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProductReviews.Domain.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false), + Slug = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + ImageUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + ReviewCount = table.Column(type: "integer", nullable: false), + AverageRating = table.Column(type: "double precision", nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Reviews", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProductId = table.Column(type: "bigint", nullable: false), + AuthorId = table.Column(type: "uuid", nullable: false), + AuthorName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Rating = table.Column(type: "integer", nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Body = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + Pros = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Cons = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Score = table.Column(type: "integer", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Reviews", x => x.Id); + table.CheckConstraint("ck_reviews_rating", "\"Rating\" BETWEEN 1 AND 5"); + table.ForeignKey( + name: "FK_Reviews_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ReviewVotes", + columns: table => new + { + ReviewId = table.Column(type: "uuid", nullable: false), + VoterId = table.Column(type: "uuid", nullable: false), + IsUpvote = table.Column(type: "boolean", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReviewVotes", x => new { x.ReviewId, x.VoterId }); + table.ForeignKey( + name: "FK_ReviewVotes_Reviews_ReviewId", + column: x => x.ReviewId, + principalTable: "Reviews", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Products_Slug", + table: "Products", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_reviews_helpful", + table: "Reviews", + columns: new[] { "ProductId", "Score", "Id" }); + + migrationBuilder.CreateIndex( + name: "ix_reviews_rating", + table: "Reviews", + columns: new[] { "ProductId", "Rating", "Score", "Id" }); + + migrationBuilder.CreateIndex( + name: "uq_reviews_product_author", + table: "Reviews", + columns: new[] { "ProductId", "AuthorId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReviewVotes"); + + migrationBuilder.DropTable( + name: "Reviews"); + + migrationBuilder.DropTable( + name: "Products"); + } + } +} diff --git a/src/ProductReviews.Domain/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs b/src/ProductReviews.Domain/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs new file mode 100644 index 0000000..44ccd5a --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs @@ -0,0 +1,179 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ProductReviews.Domain.Persistence; + +#nullable disable + +namespace ProductReviews.Domain.Persistence.Migrations +{ + [DbContext(typeof(ReviewsDbContext))] + partial class ReviewsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("AverageRating") + .HasColumnType("double precision"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ImageUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewCount") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Cons") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("Pros") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId", "AuthorId") + .IsUnique() + .HasDatabaseName("uq_reviews_product_author"); + + b.HasIndex("ProductId", "Score", "Id") + .HasDatabaseName("ix_reviews_helpful"); + + b.HasIndex("ProductId", "Rating", "Score", "Id") + .HasDatabaseName("ix_reviews_rating"); + + b.ToTable("Reviews", t => + { + t.HasCheckConstraint("ck_reviews_rating", "\"Rating\" BETWEEN 1 AND 5"); + }); + }); + + modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + { + b.Property("ReviewId") + .HasColumnType("uuid"); + + b.Property("VoterId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsUpvote") + .HasColumnType("boolean"); + + b.HasKey("ReviewId", "VoterId"); + + b.ToTable("ReviewVotes"); + }); + + modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + { + b.HasOne("ProductReviews.Domain.Catalog.Product", null) + .WithMany("Reviews") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + { + b.HasOne("ProductReviews.Domain.Reviews.Review", null) + .WithMany("Votes") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + { + b.Navigation("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs b/src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs new file mode 100644 index 0000000..48e24bb --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using ProductReviews.Domain.Catalog; +using ProductReviews.Domain.Reviews; +using ProductReviews.Domain.Votes; +using StrongTypes.EfCore; + +namespace ProductReviews.Domain.Persistence; + +public sealed class ReviewsDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Products => Set(); + + public DbSet Reviews => Set(); + + public DbSet ReviewVotes => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.ApplyConfigurationsFromAssembly(typeof(ReviewsDbContext).Assembly); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + // The library's built-ins are converted by UseStrongTypes(); our own + // Rating wrapper needs this one registration, reusing the library's converter. + configurationBuilder.Properties() + .HaveConversion>(); + } +} diff --git a/src/ProductReviews.Domain/Persistence/ReviewsDbContextDesignTimeFactory.cs b/src/ProductReviews.Domain/Persistence/ReviewsDbContextDesignTimeFactory.cs new file mode 100644 index 0000000..2f8a097 --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/ReviewsDbContextDesignTimeFactory.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using StrongTypes.EfCore; + +namespace ProductReviews.Domain.Persistence; + +/// For `dotnet ef` only. The connection string is never opened during +/// `migrations add` — it just satisfies the provider registration. +public sealed class ReviewsDbContextDesignTimeFactory : IDesignTimeDbContextFactory +{ + public ReviewsDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql("Host=localhost;Database=productreviews-design-time").UseStrongTypes(); + return new ReviewsDbContext(optionsBuilder.Options); + } +} diff --git a/src/ProductReviews.Domain/Persistence/Seeding/DemoCatalog.cs b/src/ProductReviews.Domain/Persistence/Seeding/DemoCatalog.cs new file mode 100644 index 0000000..3b82592 --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/Seeding/DemoCatalog.cs @@ -0,0 +1,200 @@ +namespace ProductReviews.Domain.Persistence.Seeding; + +internal sealed record SeedAuthor(Guid Id, string Name); + +internal sealed record SeedReview( + string Author, + int Rating, + string Title, + string Body, + string? Pros, + string? Cons, + int Helpful, + int DaysAgo); + +internal sealed record SeedProduct( + long Id, + string Slug, + string Name, + string Description, + string? ImageUrl, + SeedReview[] Reviews); + +/// The demo catalog. Authors carry fixed Guids so seeded data is stable +/// across runs; Helpful is the target helpfulness score (negative = downvoted). +internal static class DemoCatalog +{ + public static readonly IReadOnlyList Authors = + [ + new(new Guid("00000000-0000-0000-0000-000000000001"), "Alice Novak"), + new(new Guid("00000000-0000-0000-0000-000000000002"), "Bob Marsh"), + new(new Guid("00000000-0000-0000-0000-000000000003"), "Carol Jensen"), + new(new Guid("00000000-0000-0000-0000-000000000004"), "Dave Kim"), + new(new Guid("00000000-0000-0000-0000-000000000005"), "Eve Horak"), + new(new Guid("00000000-0000-0000-0000-000000000006"), "Frank Ortega"), + new(new Guid("00000000-0000-0000-0000-000000000007"), "Grace Liu"), + new(new Guid("00000000-0000-0000-0000-000000000008"), "Henry Walsh"), + ]; + + public static readonly IReadOnlyList Products = + [ + new(1, "sony-wh-1000xm5", "Sony WH-1000XM5 Wireless Headphones", + "Industry-leading noise cancelling over-ear headphones with 30-hour battery life, multipoint Bluetooth, and a fold-flat travel case.", + "https://picsum.photos/seed/sony-wh-1000xm5/600/400", + [ + new("Alice Novak", 5, "The commute upgrade I didn't know I needed", + "The noise cancelling erases the tram entirely. I get off with more energy than I got on, and the battery outlasts a week of commuting.", + "Best-in-class noise cancelling", "Touch controls take a week to learn", 7, 45), + new("Bob Marsh", 4, "Excellent sound, slightly tight fit", + "Warm, detailed sound with plenty of bass. After three hours the clamping force starts to bother me, so they lose a star for marathon sessions.", + "Rich, warm sound signature", "Clamps a little after long sessions", 5, 38), + new("Carol Jensen", 5, "Calls finally sound professional", + "I bought these for video calls and the microphone array is shockingly good. Colleagues stopped asking if I moved to a quieter flat.", + "Crystal-clear call quality", null, 4, 21), + new("Dave Kim", 4, "Great, but bring the app", + "Out of the box the EQ is bass-heavy for my taste. Ten minutes in the companion app fixed it. Hardware is flawless.", + null, "Default EQ needs tweaking", 2, 9), + ]), + new(2, "usb-c-cable-pack", "USB-C Cable 3-Pack (1m)", + "Braided 60W USB-C to USB-C cables in one-metre length. E-marker chips, aluminium strain relief, and a lifetime replacement promise.", + "https://picsum.photos/seed/usb-c-cable-pack/600/400", + [ + new("Eve Horak", 4, "Cables that survive my backpack", + "Six months of being crushed under laptops and books and all three still work. The braiding shows no fraying yet.", + "Tough braided jacket", null, 3, 52), + new("Frank Ortega", 3, "Fine, but only 60W", + "They charge my phone perfectly well. Just know they will not fast-charge a 16-inch laptop — that needs a 100W cable, which these are not.", + null, "Not for high-wattage laptops", 4, 33), + new("Grace Liu", 3, "Exactly what the label says", + "One metre is shorter than you think when your socket is behind the sofa. Quality is fine; measure before you buy.", + null, "One metre is short in practice", 1, 17), + ]), + new(3, "acme-smartwatch", "Acme Pro Smartwatch", + "A budget smartwatch with a bright AMOLED display, heart-rate tracking, sleep insights, and a claimed seven-day battery.", + "https://picsum.photos/seed/acme-smartwatch/600/400", + [ + new("Henry Walsh", 2, "Seven-day battery is a fantasy", + "With the always-on display and workout tracking I get barely two days. The watch itself is fine; the marketing is not.", + "Bright, crisp display", "Real battery life is a third of the claim", 6, 41), + new("Alice Novak", 1, "Returned after a week", + "The heart-rate sensor read 140 while I was sitting still and 90 mid-sprint. For a fitness watch, that's disqualifying.", + null, "Wildly inaccurate heart-rate sensor", 5, 29), + new("Bob Marsh", 3, "Decent notifications, poor fitness", + "As a wrist notification mirror it does the job and the screen is lovely. Treat the health metrics as decoration.", + "Great screen for the price", "Health metrics are not trustworthy", 2, 15), + new("Carol Jensen", 1, "Strap broke in a fortnight", + "The proprietary strap lug snapped clean off fourteen days in. No standard straps fit, so the watch is now a paperweight.", + null, "Proprietary strap, fragile lugs", 3, 8), + ]), + new(4, "logi-mx-master-3s", "Logitech MX Master 3S", + "The flagship productivity mouse: 8K DPI sensor, silent switches, the MagSpeed infinite scroll wheel, and three-device pairing.", + "https://picsum.photos/seed/logi-mx-master-3s/600/400", + [ + new("Dave Kim", 5, "The scroll wheel ruins other mice", + "Flywheel scrolling through thousand-line files is genuinely addictive. Once you flick through a code review in one spin, everything else feels broken.", + "MagSpeed wheel is unmatched", null, 7, 60), + new("Eve Horak", 5, "Silent clicks saved my marriage", + "I work nights next to a sleeping partner. The 3S clicks are near-silent and the shape keeps my wrist happy through ten-hour days.", + "Whisper-quiet switches", "Not for small hands", 4, 34), + new("Frank Ortega", 4, "Heavy, in a good way — mostly", + "Rock solid and precise, but at 141 grams you will notice it after a fast-paced day. Desk workers will love it; flick-aimers will not.", + null, "Too heavy for quick movements", 3, 19), + new("Grace Liu", 5, "Three computers, one mouse", + "Pairs with my work laptop, home desktop, and tablet, and flows between them like they're one machine. Setup took five minutes.", + "Effortless multi-device switching", null, 2, 6), + ]), + new(5, "boombox-mini", "BoomBox Mini Bluetooth Speaker", + "A palm-sized Bluetooth speaker promising 360-degree sound, IPX5 splash resistance, and twelve hours of playback.", + "https://picsum.photos/seed/boombox-mini/600/400", + [ + new("Henry Walsh", 1, "Distorts at half volume", + "Anything above background-music level and the driver crackles like a broken radio. Twelve-hour battery means little when you can't stand the sound.", + null, "Audible distortion above 50% volume", 6, 47), + new("Alice Novak", 2, "Okay for podcasts, nothing else", + "Voice content is passable. Music has no low end at all — the '360-degree sound' is 360 degrees of tin.", + "Small enough for any bag", "No bass whatsoever", 4, 26), + new("Bob Marsh", 2, "Bluetooth drops every few songs", + "Random half-second dropouts with the phone two metres away. Firmware update didn't help. Pass.", + null, "Unstable Bluetooth connection", 1, 11), + ]), + new(6, "ipad-air-11", "iPad Air 11\" (M3, 256GB)", + "Apple's mid-range 11-inch tablet with the M3 chip, Apple Pencil Pro support, and a 256GB configuration in four colours.", + "https://picsum.photos/seed/ipad-air-11/600/400", + [ + new("Carol Jensen", 5, "Replaced my laptop for travel", + "With a keyboard folio this handles email, documents, and video editing on the road. The M3 never stutters and the battery lasts a full flight.", + "M3 is overkill in the best way", "Accessories cost extra — budget for them", 6, 50), + new("Dave Kim", 4, "Wonderful screen, 60Hz though", + "Colours and brightness are superb for the price. Coming from a 120Hz phone, scrolling looks a touch choppy — you notice it for a day, then forget.", + "Gorgeous laminated display", "No ProMotion at this price", 5, 31), + new("Eve Horak", 5, "The sketching setup I recommend to students", + "Pencil Pro hover plus zero perceptible latency makes this the best value drawing tablet that is also, well, a computer.", + "Perfect Pencil Pro integration", null, 3, 14), + new("Frank Ortega", 4, "Storage math finally works", + "256GB base storage means I stopped juggling offloaded apps. Would be five stars with Face ID instead of the power-button sensor.", + null, "Touch ID placement is awkward", 1, 5), + ]), + new(7, "xyz-mechanical-keyboard", "XYZ Mechanical Keyboard (Brown switches)", + "A hot-swappable 87-key mechanical keyboard with tactile brown switches, PBT keycaps, south-facing RGB, and tri-mode connectivity.", + "https://picsum.photos/seed/xyz-mechanical-keyboard/600/400", + [ + new("Grace Liu", 4, "Punches far above its price", + "Pre-lubed stabilisers, foam dampening, and PBT caps at this price is absurd. The stock browns are decent; I swapped them anyway because I could.", + "Hot-swap sockets, no soldering", null, 5, 42), + new("Henry Walsh", 4, "Solid board, mushy spacebar", + "Alphas feel crisp and the case has zero flex. The spacebar sounds like a wet sock — five minutes of tape fixed it, but I shouldn't have to.", + "Rock-solid aluminium plate", "Spacebar needs modding", 3, 27), + new("Alice Novak", 5, "My first mechanical, no regrets", + "Went from a laptop keyboard to this and my typing speed jumped 15 wpm. Battery lasts weeks with the RGB off.", + "Weeks of battery over Bluetooth", "RGB software is Windows-only", 2, 12), + new("Bob Marsh", 3, "Good hardware, beta software", + "The keyboard itself deserves four stars. The configuration app crashes, forgets profiles, and once remapped my Q to a media key. Buy it if you never touch the software.", + null, "Companion app is unreliable", 4, 7), + ]), + new(8, "travelpro-tripod", "TravelPro Phone Tripod", + "A foldable aluminium phone tripod extending to 1.6m, with a detachable Bluetooth shutter remote and a 360-degree ball head.", + "https://picsum.photos/seed/travelpro-tripod/600/400", + [ + new("Carol Jensen", 4, "Lighter than my water bottle", + "Folds to the length of a paperback and disappears into a daypack. Perfectly steady indoors; wind is another story.", + "Genuinely pocketable", "Wobbles in any breeze", 3, 36), + new("Dave Kim", 2, "The ball head does not lock", + "With a larger phone the head slowly droops mid-timelapse. Every long recording ends pointing at my shoes.", + null, "Head sags under heavier phones", 4, 22), + new("Eve Horak", 3, "Remote is the best part", + "The little Bluetooth shutter is worth half the price alone. The legs are fine on flat ground, fiddly on trails.", + "Handy detachable remote", "Leg locks are fiddly", 1, 10), + ]), + new(9, "single-origin-coffee", "Highland Single-Origin Coffee 1kg", + "Medium-roast single-origin arabica from high-altitude farms, roasted weekly in small batches with tasting notes of caramel, plum, and cocoa.", + "https://picsum.photos/seed/single-origin-coffee/600/400", + [ + new("Frank Ortega", 5, "Roast date on the bag, flavour in the cup", + "Arrived four days off roast and the difference from supermarket beans is night and day. The plum note is really there on a V60.", + "Always fresh — roast date printed", null, 6, 39), + new("Grace Liu", 5, "Converted my espresso-hating partner", + "Sweet, chocolatey, zero bitterness even when I fumble the shot. Our weekend ritual now starts with this bag.", + "Forgiving for beginners", "Sells out fast", 4, 24), + new("Henry Walsh", 4, "Excellent, mind the grind", + "As filter coffee it's superb. For espresso you'll chase the grind setting for a few days — worth it, but budget some beans for dialling in.", + null, "Needs careful dialling for espresso", 2, 13), + new("Alice Novak", 5, "The caramel note is not marketing", + "Blind-tested against two other roasters with friends and this won unanimously. Sweet finish that lingers.", + "Distinct caramel finish", null, 3, 4), + ]), + new(10, "powerjuice-10000", "PowerJuice 10000 Mini Power Bank", + "A credit-card footprint 10,000mAh power bank with built-in USB-C cable, 20W fast charge, and pass-through charging.", + "https://picsum.photos/seed/powerjuice-10000/600/400", + [ + new("Bob Marsh", 2, "Half the promised capacity", + "Measured with a USB meter it delivers barely 5,400mAh to my phone. Physics taxes every power bank, but this is beyond the usual conversion loss.", + null, "Real capacity far below spec", 5, 44), + new("Carol Jensen", 3, "Handy until the cable frays", + "The built-in cable is genius for three months. Then the strain relief cracks, and unlike a normal bank you can't just swap the cable.", + "No cables to forget", "Built-in cable is the weak point", 3, 28), + new("Dave Kim", 1, "Gets alarmingly hot", + "Fast-charging a phone while topping the bank up itself made it too hot to hold. I stopped using it; a charger should not double as a hand warmer.", + null, "Overheats during pass-through", 4, 16), + ]), + ]; +} diff --git a/src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs b/src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs new file mode 100644 index 0000000..43ab4ed --- /dev/null +++ b/src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using ProductReviews.Domain.Catalog; +using ProductReviews.Domain.Reviews; +using ProductReviews.Domain.Votes; +using StrongTypes; + +namespace ProductReviews.Domain.Persistence.Seeding; + +/// Idempotent demo seeding (ADR-0006): runs after migrations, does nothing +/// once products exist, and builds everything through the domain entities so seed +/// data satisfies the same invariants as user input. +public static class DemoDataSeeder +{ + public static async Task SeedAsync(ReviewsDbContext dbContext, TimeProvider timeProvider, CancellationToken cancellationToken) + { + if (await dbContext.Products.AnyAsync(cancellationToken)) + { + return; + } + + var nowUtc = timeProvider.GetUtcNow().UtcDateTime; + + foreach (var seedProduct in DemoCatalog.Products) + { + var product = new Product( + seedProduct.Id, + seedProduct.Slug.ToNonEmpty(), + seedProduct.Name.ToNonEmpty(), + seedProduct.Description.ToNonEmpty(), + seedProduct.ImageUrl?.ToNonEmpty(), + nowUtc.AddDays(-120)); + + var ratings = new List(); + foreach (var seedReview in seedProduct.Reviews) + { + var author = DemoCatalog.Authors.Single(a => a.Name == seedReview.Author); + var createdAtUtc = nowUtc.AddDays(-seedReview.DaysAgo); + + var review = new Review( + product.Id, + author.Id, + author.Name.ToNonEmpty(), + Rating.Create(seedReview.Rating), + seedReview.Title.ToNonEmpty(), + seedReview.Body.ToNonEmpty(), + seedReview.Pros?.ToNonEmpty(), + seedReview.Cons?.ToNonEmpty(), + createdAtUtc); + + var votes = CreateVotes(review, author.Id, seedReview.Helpful, createdAtUtc); + review.RefreshScore(votes); + ratings.Add(review.Rating); + + dbContext.Reviews.Add(review); + dbContext.ReviewVotes.AddRange(votes); + } + + product.RefreshRatingSummary(ratings); + dbContext.Products.Add(product); + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static List CreateVotes(Review review, Guid reviewAuthorId, int helpful, DateTime reviewCreatedAtUtc) + { + // Voters rotate through the other seed authors — never the review's own + // author, mirroring the "no voting on your own review" rule. + var voters = DemoCatalog.Authors.Where(a => a.Id != reviewAuthorId).ToList(); + var voteCount = int.Min(int.Abs(helpful), voters.Count); + + return [.. voters + .Take(voteCount) + .Select((voter, index) => new ReviewVote(review.Id, voter.Id, helpful > 0, reviewCreatedAtUtc.AddHours(index + 1)))]; + } +} diff --git a/src/ProductReviews.Domain/ProductReviews.Domain.csproj b/src/ProductReviews.Domain/ProductReviews.Domain.csproj new file mode 100644 index 0000000..9b3fbe9 --- /dev/null +++ b/src/ProductReviews.Domain/ProductReviews.Domain.csproj @@ -0,0 +1,15 @@ + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + diff --git a/src/ProductReviews.Domain/Reviews/DeleteReview.cs b/src/ProductReviews.Domain/Reviews/DeleteReview.cs new file mode 100644 index 0000000..74343b3 --- /dev/null +++ b/src/ProductReviews.Domain/Reviews/DeleteReview.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using ProductReviews.Domain.Persistence; + +namespace ProductReviews.Domain.Reviews; + +public enum DeleteReviewError +{ + ReviewNotFound, + NotYourReview, +} + +public sealed class DeleteReviewHandler(ReviewsDbContext dbContext) +{ + /// Null result means success — there is nothing to return, so no Result wrapper. + public async Task HandleAsync(Guid reviewId, Guid authorId, CancellationToken cancellationToken) + { + var review = await dbContext.Reviews.SingleOrDefaultAsync(r => r.Id == reviewId, cancellationToken); + if (review is null) + { + return DeleteReviewError.ReviewNotFound; + } + if (review.AuthorId != authorId) + { + return DeleteReviewError.NotYourReview; + } + + dbContext.Reviews.Remove(review); + + var product = await dbContext.CompleteProductById(review.ProductId, cancellationToken) + ?? throw new InvalidOperationException($"Product {review.ProductId} missing for review {review.Id}."); + var afterDeletion = product with { Reviews = [.. product.Reviews.Where(r => r.Id != review.Id)] }; + afterDeletion.RefreshRatingSummary(); + + await dbContext.SaveChangesAsync(cancellationToken); + return null; + } +} diff --git a/src/ProductReviews.Domain/Reviews/EditReview.cs b/src/ProductReviews.Domain/Reviews/EditReview.cs new file mode 100644 index 0000000..d18f866 --- /dev/null +++ b/src/ProductReviews.Domain/Reviews/EditReview.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using ProductReviews.Domain.Persistence; +using StrongTypes; + +namespace ProductReviews.Domain.Reviews; + +public enum EditReviewError +{ + ReviewNotFound, + NotYourReview, +} + +public sealed class EditReviewHandler(ReviewsDbContext dbContext, TimeProvider timeProvider) +{ + /// The Maybe<T> showcase (ADR-0002, §5 of the technical requirements): + /// null leaves a field unchanged; for the clearable optionals, None clears and Some replaces. + public async Task> HandleAsync( + Guid reviewId, + Guid editorId, + Rating? rating, + NonEmptyString? title, + NonEmptyString? body, + Maybe? pros, + Maybe? cons, + CancellationToken cancellationToken) + { + var review = await dbContext.Reviews.SingleOrDefaultAsync(r => r.Id == reviewId, cancellationToken); + if (review is null) + { + return EditReviewError.ReviewNotFound; + } + if (review.AuthorId != editorId) + { + return EditReviewError.NotYourReview; + } + + var ratingChanged = rating is { } newRating && newRating != review.Rating; + review.ApplyEdit(rating, title, body, pros, cons, timeProvider.GetUtcNow().UtcDateTime); + + if (ratingChanged) + { + var product = await dbContext.CompleteProductById(review.ProductId, cancellationToken) + ?? throw new InvalidOperationException($"Product {review.ProductId} missing for review {review.Id}."); + product.RefreshRatingSummary(); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return review; + } +} diff --git a/src/ProductReviews.Domain/Reviews/GetReviewsPage.cs b/src/ProductReviews.Domain/Reviews/GetReviewsPage.cs new file mode 100644 index 0000000..cc64511 --- /dev/null +++ b/src/ProductReviews.Domain/Reviews/GetReviewsPage.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using ProductReviews.Domain.Persistence; +using StrongTypes; + +namespace ProductReviews.Domain.Reviews; + +/// A review as one specific viewer sees it: with whether it is their own +/// and which way they voted on it. Constructed only by the page query, so both +/// viewer facts are guaranteed to have been loaded. +public sealed record ReviewWithViewerContext(Review Review, bool Mine, bool? ViewerVote); + +public sealed record ReviewsPage( + IReadOnlyList Reviews, + NonNegative TotalCount, + Positive Page, + Positive PageSize); + +public sealed class GetReviewsPageHandler(ReviewsDbContext dbContext) +{ + public const int MaximumPageSize = 50; + + /// Null means the product does not exist. + public async Task HandleAsync( + NonEmptyString slug, + ReviewSort sort, + IReadOnlyCollection ratingFilter, + Positive page, + Positive pageSize, + Guid? viewerId, + CancellationToken cancellationToken) + { + var productId = await dbContext.Products + .Where(product => product.Slug == slug) + .Select(product => (long?)product.Id) + .SingleOrDefaultAsync(cancellationToken); + if (productId is null) + { + return null; + } + + var reviews = dbContext.Reviews + .AsNoTracking() + .Where(review => review.ProductId == productId); + + if (ratingFilter.Count > 0) + { + reviews = reviews.Where(review => ratingFilter.Contains(review.Rating)); + } + + reviews = sort switch + { + ReviewSort.MostHelpful => reviews + .OrderByDescending(review => review.Score) + .ThenByDescending(review => review.Id), + ReviewSort.Newest => reviews + .OrderByDescending(review => review.Id), + ReviewSort.HighestRating => reviews + .OrderByDescending(review => review.Rating) + .ThenByDescending(review => review.Score) + .ThenByDescending(review => review.Id), + ReviewSort.LowestRating => reviews + .OrderBy(review => review.Rating) + .ThenByDescending(review => review.Score) + .ThenByDescending(review => review.Id), + }; + + var totalCount = await reviews.CountAsync(cancellationToken); + + var effectivePageSize = int.Min(pageSize.Value, MaximumPageSize); + var pageQuery = reviews + .Skip((page.Value - 1) * effectivePageSize) + .Take(effectivePageSize); + + var pageItems = viewerId is { } viewer + ? await pageQuery + .Select(review => new ReviewWithViewerContext( + review, + review.AuthorId == viewer, + review.Votes + .Where(vote => vote.VoterId == viewer) + .Select(vote => (bool?)vote.IsUpvote) + .FirstOrDefault())) + .ToListAsync(cancellationToken) + : await pageQuery + .Select(review => new ReviewWithViewerContext(review, false, null)) + .ToListAsync(cancellationToken); + + return new ReviewsPage(pageItems, totalCount.ToNonNegative(), page, effectivePageSize.ToPositive()); + } +} diff --git a/src/ProductReviews.Domain/Reviews/Rating.cs b/src/ProductReviews.Domain/Reviews/Rating.cs new file mode 100644 index 0000000..ffc0af9 --- /dev/null +++ b/src/ProductReviews.Domain/Reviews/Rating.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using StrongTypes; + +namespace ProductReviews.Domain.Reviews; + +/// A whole-star product rating, 1–5. Declared with the same three-line +/// recipe the library's own numeric wrappers use: [NumericWrapper] plus a +/// Value property and a TryCreate; the source generator emits the +/// rest (Create, Parse/TryParse, IParsable, comparison and equality operators). +[NumericWrapper(InvariantDescription = "a whole-star rating between 1 and 5")] +[JsonConverter(typeof(RatingJsonConverter))] +public readonly partial struct Rating +{ + public const int MinimumStars = 1; + public const int MaximumStars = 5; + + // Stored as (Value - MinimumStars) so default(Rating) is a valid one-star rating. + private readonly int offsetFromMinimum; + + private Rating(int offsetFromMinimum) => this.offsetFromMinimum = offsetFromMinimum; + + public int Value => offsetFromMinimum + MinimumStars; + + public static Rating? TryCreate(int value) + => value is >= MinimumStars and <= MaximumStars ? new Rating(value - MinimumStars) : null; + + // The generator emits Equals(int?)/CompareTo(int?), which for a concrete + // (non-generic) wrapper does not satisfy IEquatable/IComparable — + // these two exact-signature members close that gap. + public bool Equals(int other) => Value.Equals(other); + + public int CompareTo(int other) => Value.CompareTo(other); +} + +/// Wire format is the bare integer, exactly like the library's numeric +/// wrappers; out-of-range JSON fails deserialization before any action runs. +public sealed class RatingJsonConverter : JsonConverter +{ + public override Rating Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.Number || !reader.TryGetInt32(out var value)) + { + throw new JsonException($"The JSON value could not be converted to {nameof(Rating)}."); + } + + return Rating.TryCreate(value) + ?? throw new JsonException($"The JSON value '{value}' cannot be converted to {nameof(Rating)}."); + } + + public override void Write(Utf8JsonWriter writer, Rating value, JsonSerializerOptions options) + => writer.WriteNumberValue(value.Value); +} diff --git a/src/ProductReviews.Domain/Reviews/Review.cs b/src/ProductReviews.Domain/Reviews/Review.cs new file mode 100644 index 0000000..7024881 --- /dev/null +++ b/src/ProductReviews.Domain/Reviews/Review.cs @@ -0,0 +1,104 @@ +using ProductReviews.Domain.Votes; +using StrongTypes; + +namespace ProductReviews.Domain.Reviews; + +public sealed class Review +{ + private Review() + { + AuthorName = null!; + Title = null!; + Body = null!; + } + + // authorDisplayName deliberately matches no property name, so EF's + // constructor binding can never pick this ctor and re-run the id generation. + public Review( + long productId, + Guid authorId, + NonEmptyString authorDisplayName, + Rating rating, + NonEmptyString title, + NonEmptyString body, + NonEmptyString? pros, + NonEmptyString? cons, + DateTime createdAtUtc) + { + Id = Guid.CreateVersion7(new DateTimeOffset(createdAtUtc, TimeSpan.Zero)); + ProductId = productId; + AuthorId = authorId; + AuthorName = authorDisplayName; + Rating = rating; + Title = title; + Body = body; + Pros = pros; + Cons = cons; + CreatedAtUtc = createdAtUtc; + } + + /// UUIDv7, so the id doubles as a created-at tiebreaker for sorting. + public Guid Id { get; private set; } + + public long ProductId { get; private set; } + + public Guid AuthorId { get; private set; } + + public NonEmptyString AuthorName { get; private set; } + + public Rating Rating { get; private set; } + + public NonEmptyString Title { get; private set; } + + public NonEmptyString Body { get; private set; } + + public NonEmptyString? Pros { get; private set; } + + public NonEmptyString? Cons { get; private set; } + + /// Denormalized helpfulness: upvotes minus downvotes, always recomputed from the vote rows. + public int Score { get; private set; } + + public DateTime CreatedAtUtc { get; private set; } + + /// Null until the first edit. + public DateTime? UpdatedAtUtc { get; private set; } + + public ICollection Votes { get; } = []; + + /// PATCH semantics: a null parameter leaves the field unchanged; for the + /// clearable optionals, Maybe.None clears and Maybe.Some replaces. + public void ApplyEdit( + Rating? rating, + NonEmptyString? title, + NonEmptyString? body, + Maybe? pros, + Maybe? cons, + DateTime editedAtUtc) + { + if (rating is { } newRating) + { + Rating = newRating; + } + if (title is { } newTitle) + { + Title = newTitle; + } + if (body is { } newBody) + { + Body = newBody; + } + if (pros is { } prosChange) + { + Pros = prosChange.Value; + } + if (cons is { } consChange) + { + Cons = consChange.Value; + } + UpdatedAtUtc = editedAtUtc; + } + + public void RefreshScore(IReadOnlyCollection currentVotes) + => Score = currentVotes.Sum(vote => vote.ScoreContribution); +} diff --git a/src/ProductReviews.Domain/Reviews/ReviewAuthor.cs b/src/ProductReviews.Domain/Reviews/ReviewAuthor.cs new file mode 100644 index 0000000..fa4800c --- /dev/null +++ b/src/ProductReviews.Domain/Reviews/ReviewAuthor.cs @@ -0,0 +1,8 @@ +using StrongTypes; + +namespace ProductReviews.Domain.Reviews; + +/// The acting reviewer, as the domain sees them: an opaque stable id +/// (derived from the identity provider's subject, see ADR-0005) and the display +/// name snapshotted onto anything they write. +public sealed record ReviewAuthor(Guid AuthorId, NonEmptyString DisplayName); diff --git a/src/ProductReviews.Domain/Reviews/ReviewSort.cs b/src/ProductReviews.Domain/Reviews/ReviewSort.cs new file mode 100644 index 0000000..0669670 --- /dev/null +++ b/src/ProductReviews.Domain/Reviews/ReviewSort.cs @@ -0,0 +1,9 @@ +namespace ProductReviews.Domain.Reviews; + +public enum ReviewSort +{ + MostHelpful, + Newest, + HighestRating, + LowestRating, +} diff --git a/src/ProductReviews.Domain/Reviews/SubmitReview.cs b/src/ProductReviews.Domain/Reviews/SubmitReview.cs new file mode 100644 index 0000000..c5c1282 --- /dev/null +++ b/src/ProductReviews.Domain/Reviews/SubmitReview.cs @@ -0,0 +1,52 @@ +using ProductReviews.Domain.Persistence; +using StrongTypes; + +namespace ProductReviews.Domain.Reviews; + +public enum SubmitReviewError +{ + ProductNotFound, + AlreadyReviewed, +} + +public sealed class SubmitReviewHandler(ReviewsDbContext dbContext, TimeProvider timeProvider) +{ + public async Task> HandleAsync( + NonEmptyString productSlug, + ReviewAuthor author, + Rating rating, + NonEmptyString title, + NonEmptyString body, + NonEmptyString? pros, + NonEmptyString? cons, + CancellationToken cancellationToken) + { + var product = await dbContext.CompleteProductBySlug(productSlug, cancellationToken); + if (product is null) + { + return SubmitReviewError.ProductNotFound; + } + if (product.Reviews.Any(review => review.AuthorId == author.AuthorId)) + { + return SubmitReviewError.AlreadyReviewed; + } + + var review = new Review( + product.Product.Id, + author.AuthorId, + author.DisplayName, + rating, + title, + body, + pros, + cons, + timeProvider.GetUtcNow().UtcDateTime); + dbContext.Reviews.Add(review); + + var afterSubmit = product with { Reviews = [.. product.Reviews, review] }; + afterSubmit.RefreshRatingSummary(); + + await dbContext.SaveChangesAsync(cancellationToken); + return review; + } +} diff --git a/src/ProductReviews.Domain/Votes/CastVote.cs b/src/ProductReviews.Domain/Votes/CastVote.cs new file mode 100644 index 0000000..0789feb --- /dev/null +++ b/src/ProductReviews.Domain/Votes/CastVote.cs @@ -0,0 +1,49 @@ +using ProductReviews.Domain.Persistence; +using StrongTypes; + +namespace ProductReviews.Domain.Votes; + +public enum CastVoteError +{ + ReviewNotFound, + OwnReview, +} + +public sealed class CastVoteHandler(ReviewsDbContext dbContext, TimeProvider timeProvider) +{ + /// Idempotent upsert: a first vote inserts, a repeated vote re-points the existing one. + public async Task> HandleAsync( + Guid reviewId, + Guid voterId, + bool isUpvote, + CancellationToken cancellationToken) + { + var review = await dbContext.CompleteReviewById(reviewId, cancellationToken); + if (review is null) + { + return CastVoteError.ReviewNotFound; + } + if (review.Review.AuthorId == voterId) + { + return CastVoteError.OwnReview; + } + + var existingVote = review.Votes.SingleOrDefault(vote => vote.VoterId == voterId); + ReviewWithVotes afterVote; + if (existingVote is null) + { + var vote = new ReviewVote(reviewId, voterId, isUpvote, timeProvider.GetUtcNow().UtcDateTime); + dbContext.ReviewVotes.Add(vote); + afterVote = review with { Votes = [.. review.Votes, vote] }; + } + else + { + existingVote.ChangeDirection(isUpvote); + afterVote = review; + } + + afterVote.RefreshScore(); + await dbContext.SaveChangesAsync(cancellationToken); + return new VoteSummary(afterVote.Review.Score, isUpvote); + } +} diff --git a/src/ProductReviews.Domain/Votes/RemoveVote.cs b/src/ProductReviews.Domain/Votes/RemoveVote.cs new file mode 100644 index 0000000..4aecd73 --- /dev/null +++ b/src/ProductReviews.Domain/Votes/RemoveVote.cs @@ -0,0 +1,38 @@ +using ProductReviews.Domain.Persistence; +using StrongTypes; + +namespace ProductReviews.Domain.Votes; + +public enum RemoveVoteError +{ + ReviewNotFound, +} + +public sealed class RemoveVoteHandler(ReviewsDbContext dbContext) +{ + /// Withdrawing a vote that was never cast is a success — the end state is identical. + public async Task> HandleAsync( + Guid reviewId, + Guid voterId, + CancellationToken cancellationToken) + { + var review = await dbContext.CompleteReviewById(reviewId, cancellationToken); + if (review is null) + { + return RemoveVoteError.ReviewNotFound; + } + + var existingVote = review.Votes.SingleOrDefault(vote => vote.VoterId == voterId); + if (existingVote is null) + { + return new VoteSummary(review.Review.Score, null); + } + + dbContext.ReviewVotes.Remove(existingVote); + var afterRemoval = review with { Votes = [.. review.Votes.Where(vote => vote.VoterId != voterId)] }; + afterRemoval.RefreshScore(); + + await dbContext.SaveChangesAsync(cancellationToken); + return new VoteSummary(afterRemoval.Review.Score, null); + } +} diff --git a/src/ProductReviews.Domain/Votes/ReviewVote.cs b/src/ProductReviews.Domain/Votes/ReviewVote.cs new file mode 100644 index 0000000..9d75882 --- /dev/null +++ b/src/ProductReviews.Domain/Votes/ReviewVote.cs @@ -0,0 +1,30 @@ +namespace ProductReviews.Domain.Votes; + +/// One reviewer's helpful/not-helpful verdict on one review. +/// The composite key (ReviewId, VoterId) makes "one vote per reviewer per review" structural. +public sealed class ReviewVote +{ + private ReviewVote() + { + } + + public ReviewVote(Guid reviewId, Guid voterId, bool isUpvote, DateTime createdAtUtc) + { + ReviewId = reviewId; + VoterId = voterId; + IsUpvote = isUpvote; + CreatedAtUtc = createdAtUtc; + } + + public Guid ReviewId { get; private set; } + + public Guid VoterId { get; private set; } + + public bool IsUpvote { get; private set; } + + public DateTime CreatedAtUtc { get; private set; } + + public int ScoreContribution => IsUpvote ? 1 : -1; + + public void ChangeDirection(bool isUpvote) => IsUpvote = isUpvote; +} diff --git a/src/ProductReviews.Domain/Votes/ReviewWithVotes.cs b/src/ProductReviews.Domain/Votes/ReviewWithVotes.cs new file mode 100644 index 0000000..b396cf2 --- /dev/null +++ b/src/ProductReviews.Domain/Votes/ReviewWithVotes.cs @@ -0,0 +1,15 @@ +using ProductReviews.Domain.Reviews; + +namespace ProductReviews.Domain.Votes; + +/// Proof-of-loading aggregate: a review together with all its votes, so the +/// helpfulness score can be recomputed without trusting a lazily-loaded navigation. +/// Construct via CompleteQueries, which owns the matching Include chain. +public sealed record ReviewWithVotes(Review Review, IReadOnlyList Votes) +{ + public static ReviewWithVotes FromCompleteQuery(Review review) + => new(review, [.. review.Votes]); + + public void RefreshScore() + => Review.RefreshScore([.. Votes]); +} diff --git a/src/ProductReviews.Domain/Votes/VoteSummary.cs b/src/ProductReviews.Domain/Votes/VoteSummary.cs new file mode 100644 index 0000000..decf477 --- /dev/null +++ b/src/ProductReviews.Domain/Votes/VoteSummary.cs @@ -0,0 +1,5 @@ +namespace ProductReviews.Domain.Votes; + +/// The state a voter sees after a vote operation: the review's recomputed +/// score and their own current vote (null = no vote). +public sealed record VoteSummary(int Score, bool? ViewerVote); diff --git a/src/ProductReviews.ServiceDefaults/Extensions.cs b/src/ProductReviews.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..436f8a8 --- /dev/null +++ b/src/ProductReviews.ServiceDefaults/Extensions.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace ProductReviews.ServiceDefaults; + +// Common Aspire service wiring: service discovery, resilience, health checks, +// and OpenTelemetry. Deliberately in its own namespace — consumers opt in with +// an explicit using instead of the extensions leaking through a framework +// namespace. +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Health endpoints are Development-only; exposing them publicly has + // security implications — see https://aka.ms/dotnet/aspire/healthchecks. + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks(HealthEndpointPath); + + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj b/src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj new file mode 100644 index 0000000..c567fa5 --- /dev/null +++ b/src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj @@ -0,0 +1,19 @@ + + + + true + + + + + + + + + + + + + + + diff --git a/tests/ProductReviews.Api.IntegrationTests/CatalogApiTests.cs b/tests/ProductReviews.Api.IntegrationTests/CatalogApiTests.cs new file mode 100644 index 0000000..cb4c781 --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/CatalogApiTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace ProductReviews.Api.IntegrationTests; + +// Wire-level assertions on anonymous JSON (never the API's DTO classes), so a +// contract rename fails a test instead of silently tracking. +[Collection(nameof(SharedApiCollection))] +public sealed class CatalogApiTests(SharedApiContext context) +{ + [Fact] + public async Task GetCatalog_ReturnsTheSeededProducts_WithTheExpectedContract() + { + var response = await context.AnonymousClient.GetAsync("/api/products", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var products = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(10, products.GetArrayLength()); + + // No test writes reviews on the first seeded product, so its aggregates are stable. + var first = products[0]; + Assert.Equal(1, first.GetProperty("id").GetInt64()); + Assert.Equal("sony-wh-1000xm5", first.GetProperty("slug").GetString()); + Assert.Equal("Sony WH-1000XM5 Wireless Headphones", first.GetProperty("name").GetString()); + Assert.Equal(4, first.GetProperty("reviewCount").GetInt32()); + Assert.Equal(4.5, first.GetProperty("averageRating").GetDouble()); + Assert.False(string.IsNullOrEmpty(first.GetProperty("imageUrl").GetString())); + } + + [Fact] + public async Task GetProduct_UnknownSlug_Returns404Problem() + { + var response = await context.AnonymousClient.GetAsync("/api/products/does-not-exist", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetProduct_ForAnonymousViewer_HasNoOwnReview() + { + var response = await context.AnonymousClient.GetAsync("/api/products/ipad-air-11", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var product = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("ipad-air-11", product.GetProperty("slug").GetString()); + Assert.Equal(JsonValueKind.Null, product.GetProperty("myReviewId").ValueKind); + Assert.False(string.IsNullOrEmpty(product.GetProperty("description").GetString())); + } +} diff --git a/tests/ProductReviews.Api.IntegrationTests/OpenApiDocumentTests.cs b/tests/ProductReviews.Api.IntegrationTests/OpenApiDocumentTests.cs new file mode 100644 index 0000000..eb2eb1f --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/OpenApiDocumentTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace ProductReviews.Api.IntegrationTests; + +/// The demo's headline claim: the OpenAPI document carries the strong-type +/// constraints, so the generated frontend client sees exactly what the API enforces. +[Collection(nameof(SharedApiCollection))] +public sealed class OpenApiDocumentTests(SharedApiContext context) +{ + private async Task LoadDocumentAsync() + { + var response = await context.AnonymousClient.GetAsync("/swagger/v1/swagger.json", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task NonEmptyString_CarriesMinLength_AndRequiredness() + { + var document = await LoadDocumentAsync(); + var submitRequest = document.GetProperty("components").GetProperty("schemas").GetProperty("SubmitReviewRequest"); + + Assert.Equal(1, submitRequest.GetProperty("properties").GetProperty("title").GetProperty("minLength").GetInt32()); + Assert.Equal(1, submitRequest.GetProperty("properties").GetProperty("body").GetProperty("minLength").GetInt32()); + + var required = submitRequest.GetProperty("required").EnumerateArray().Select(name => name.GetString()).ToList(); + Assert.Contains("rating", required); + Assert.Contains("title", required); + Assert.Contains("body", required); + Assert.DoesNotContain("pros", required); + Assert.True(submitRequest.GetProperty("properties").GetProperty("pros").GetProperty("nullable").GetBoolean()); + } + + [Fact] + public async Task Email_CarriesTheEmailFormat() + { + var document = await LoadDocumentAsync(); + var email = document.GetProperty("components").GetProperty("schemas") + .GetProperty("ProfileResponse").GetProperty("properties").GetProperty("email"); + + Assert.Equal("email", email.GetProperty("format").GetString()); + Assert.Equal(254, email.GetProperty("maxLength").GetInt32()); + } + + [Fact] + public async Task PositiveInt_CarriesExclusiveMinimum() + { + var document = await LoadDocumentAsync(); + var parameters = document.GetProperty("paths").GetProperty("/api/products/{slug}/reviews") + .GetProperty("get").GetProperty("parameters"); + + var pageParameter = parameters.EnumerateArray().Single(parameter => parameter.GetProperty("name").GetString() == "page"); + Assert.Equal(0, pageParameter.GetProperty("schema").GetProperty("minimum").GetInt32()); + Assert.True(pageParameter.GetProperty("schema").GetProperty("exclusiveMinimum").GetBoolean()); + } + + [Fact] + public async Task CustomRatingWrapper_CarriesItsBounds() + { + var document = await LoadDocumentAsync(); + var rating = document.GetProperty("components").GetProperty("schemas") + .GetProperty("SubmitReviewRequest").GetProperty("properties").GetProperty("rating"); + + Assert.Equal(1, rating.GetProperty("minimum").GetInt32()); + Assert.Equal(5, rating.GetProperty("maximum").GetInt32()); + } +} diff --git a/tests/ProductReviews.Api.IntegrationTests/ProductReviews.Api.IntegrationTests.csproj b/tests/ProductReviews.Api.IntegrationTests/ProductReviews.Api.IntegrationTests.csproj new file mode 100644 index 0000000..92671c5 --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/ProductReviews.Api.IntegrationTests.csproj @@ -0,0 +1,21 @@ + + + + Exe + false + true + true + + + + + + + + + + + + + + diff --git a/tests/ProductReviews.Api.IntegrationTests/ProfileApiTests.cs b/tests/ProductReviews.Api.IntegrationTests/ProfileApiTests.cs new file mode 100644 index 0000000..2bc590c --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/ProfileApiTests.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace ProductReviews.Api.IntegrationTests; + +[Collection(nameof(SharedApiCollection))] +public sealed class ProfileApiTests(SharedApiContext context) +{ + [Fact] + public async Task GetMe_Anonymous_Returns401() + { + var response = await context.AnonymousClient.GetAsync("/api/me", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetMe_ReturnsTheProfileFromTheToken() + { + using var client = context.CreateClientFor(SharedApiContext.RandomSubject(), "Profile Pat", "pat@example.com"); + + var response = await client.GetAsync("/api/me", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var profile = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("Profile Pat", profile.GetProperty("displayName").GetString()); + Assert.Equal("pat@example.com", profile.GetProperty("email").GetString()); + Assert.NotEqual(Guid.Empty, profile.GetProperty("authorId").GetGuid()); + } + + [Fact] + public async Task GetMe_MalformedEmailClaim_YieldsNullEmail_NotAnError() + { + using var client = context.CreateClientFor(SharedApiContext.RandomSubject(), "No-mail Ned", "not-an-email"); + + var response = await client.GetAsync("/api/me", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var profile = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(JsonValueKind.Null, profile.GetProperty("email").ValueKind); + } +} diff --git a/tests/ProductReviews.Api.IntegrationTests/ReviewWritesApiTests.cs b/tests/ProductReviews.Api.IntegrationTests/ReviewWritesApiTests.cs new file mode 100644 index 0000000..5579ce8 --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/ReviewWritesApiTests.cs @@ -0,0 +1,180 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace ProductReviews.Api.IntegrationTests; + +[Collection(nameof(SharedApiCollection))] +public sealed class ReviewWritesApiTests(SharedApiContext context) +{ + private static object ValidReviewBody(int rating = 5) => new + { + rating, + title = "Exceeded my expectations", + body = "Bought it on a whim and it turned out to be the best purchase this year.", + pros = "Sturdy build", + cons = (string?)null, + }; + + [Fact] + public async Task SubmitReview_Anonymous_Returns401() + { + var response = await context.AnonymousClient.PostAsJsonAsync( + "/api/products/usb-c-cable-pack/reviews", ValidReviewBody(), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task SubmitReview_UpdatesTheProductAggregates() + { + using var client = context.CreateClientFor(SharedApiContext.RandomSubject(), "Paula Prospect"); + + // powerjuice-10000 is seeded with ratings 2, 3, 1 → count 3, average 2.0. This is the + // only test that writes to it. + var created = await client.PostAsJsonAsync( + "/api/products/powerjuice-10000/reviews", ValidReviewBody(rating: 5), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + + var review = await created.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(5, review.GetProperty("rating").GetInt32()); + Assert.True(review.GetProperty("mine").GetBoolean()); + Assert.Equal("Paula Prospect", review.GetProperty("authorName").GetString()); + Assert.Equal(JsonValueKind.Null, review.GetProperty("updatedAtUtc").ValueKind); + + var productResponse = await client.GetAsync("/api/products/powerjuice-10000", TestContext.Current.CancellationToken); + var product = await productResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(4, product.GetProperty("reviewCount").GetInt32()); + Assert.Equal(2.75, product.GetProperty("averageRating").GetDouble()); + Assert.Equal( + review.GetProperty("id").GetString(), + product.GetProperty("myReviewId").GetString()); + } + + [Fact] + public async Task SubmitReview_EmptyTitle_FailsAtTheJsonBoundary_WithTheFieldKey() + { + using var client = context.CreateClientFor(SharedApiContext.RandomSubject(), "Blank Betty"); + + var response = await client.PostAsJsonAsync( + "/api/products/usb-c-cable-pack/reviews", + new { rating = 4, title = "", body = "Fine." }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + // Key is normalized to the camelCase property name by Kalicz.StrongTypes.AspNetCore. + Assert.True(problem.GetProperty("errors").TryGetProperty("title", out _)); + } + + [Fact] + public async Task SubmitReview_OutOfRangeRating_FailsAtTheJsonBoundary() + { + using var client = context.CreateClientFor(SharedApiContext.RandomSubject(), "Sixstar Sam"); + + var response = await client.PostAsJsonAsync( + "/api/products/usb-c-cable-pack/reviews", + new { rating = 6, title = "All the stars", body = "More stars than the scale allows." }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.True(problem.GetProperty("errors").TryGetProperty("rating", out _)); + } + + [Fact] + public async Task SubmitReview_SecondReviewForTheSameProduct_Returns409() + { + using var client = context.CreateClientFor(SharedApiContext.RandomSubject(), "Repeat Rita"); + + var first = await client.PostAsJsonAsync( + "/api/products/single-origin-coffee/reviews", ValidReviewBody(), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var second = await client.PostAsJsonAsync( + "/api/products/single-origin-coffee/reviews", ValidReviewBody(rating: 2), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); + } + + [Fact] + public async Task SubmitReview_UnknownProduct_Returns404() + { + using var client = context.CreateClientFor(SharedApiContext.RandomSubject(), "Lost Lars"); + + var response = await client.PostAsJsonAsync( + "/api/products/not-a-product/reviews", ValidReviewBody(), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task EditReview_FollowsTheThreeStateMaybeContract() + { + using var client = context.CreateClientFor(SharedApiContext.RandomSubject(), "Editing Edith"); + + var created = await client.PostAsJsonAsync( + "/api/products/travelpro-tripod/reviews", + new { rating = 4, title = "Good tripod", body = "Steady enough.", pros = "Light", cons = "Wobbly head" }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var reviewId = (await created.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken)) + .GetProperty("id").GetString(); + + // {} = Maybe.None = clear pros; cons omitted = leave unchanged. + var cleared = await client.PatchAsJsonAsync( + $"/api/reviews/{reviewId}", new { pros = new { } }, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, cleared.StatusCode); + var afterClear = await cleared.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(JsonValueKind.Null, afterClear.GetProperty("pros").ValueKind); + Assert.Equal("Wobbly head", afterClear.GetProperty("cons").GetString()); + + // {"Value": …} = Maybe.Some = set; plain nullable fields update in place. + var updated = await client.PatchAsJsonAsync( + $"/api/reviews/{reviewId}", + new { rating = 2, pros = new { Value = "Still light" } }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, updated.StatusCode); + var afterUpdate = await updated.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, afterUpdate.GetProperty("rating").GetInt32()); + Assert.Equal("Still light", afterUpdate.GetProperty("pros").GetString()); + Assert.Equal("Good tripod", afterUpdate.GetProperty("title").GetString()); + Assert.NotEqual(JsonValueKind.Null, afterUpdate.GetProperty("updatedAtUtc").ValueKind); + } + + [Fact] + public async Task EditReview_ByAnotherUser_Returns403() + { + using var author = context.CreateClientFor(SharedApiContext.RandomSubject(), "Owner Olga"); + using var intruder = context.CreateClientFor(SharedApiContext.RandomSubject(), "Intruding Ivan"); + + var created = await author.PostAsJsonAsync( + "/api/products/xyz-mechanical-keyboard/reviews", ValidReviewBody(), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var reviewId = (await created.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken)) + .GetProperty("id").GetString(); + + var response = await intruder.PatchAsJsonAsync( + $"/api/reviews/{reviewId}", new { title = "Hijacked" }, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task DeleteReview_FreesTheSlotForANewReview() + { + using var client = context.CreateClientFor(SharedApiContext.RandomSubject(), "Deleting Dana"); + + var created = await client.PostAsJsonAsync( + "/api/products/boombox-mini/reviews", ValidReviewBody(rating: 1), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var reviewId = (await created.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken)) + .GetProperty("id").GetString(); + + var deleted = await client.DeleteAsync($"/api/reviews/{reviewId}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode); + + var resubmitted = await client.PostAsJsonAsync( + "/api/products/boombox-mini/reviews", ValidReviewBody(rating: 3), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, resubmitted.StatusCode); + } +} diff --git a/tests/ProductReviews.Api.IntegrationTests/ReviewsQueryApiTests.cs b/tests/ProductReviews.Api.IntegrationTests/ReviewsQueryApiTests.cs new file mode 100644 index 0000000..4792626 --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/ReviewsQueryApiTests.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace ProductReviews.Api.IntegrationTests; + +[Collection(nameof(SharedApiCollection))] +public sealed class ReviewsQueryApiTests(SharedApiContext context) +{ + [Fact] + public async Task GetReviews_FilteredByRating_ReturnsOnlyMatchingReviews() + { + var response = await context.AnonymousClient.GetAsync( + "/api/products/acme-smartwatch/reviews?ratings=1&ratings=2", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(3, page.GetProperty("totalCount").GetInt32()); + foreach (var review in page.GetProperty("items").EnumerateArray()) + { + Assert.InRange(review.GetProperty("rating").GetInt32(), 1, 2); + } + } + + [Fact] + public async Task GetReviews_SortedByNewest_IsInDescendingCreationOrder() + { + var response = await context.AnonymousClient.GetAsync( + "/api/products/sony-wh-1000xm5/reviews?sort=newest", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + var createdTimestamps = page.GetProperty("items").EnumerateArray() + .Select(review => review.GetProperty("createdAtUtc").GetDateTime()) + .ToList(); + Assert.True(createdTimestamps.Count >= 2); + Assert.Equal(createdTimestamps.OrderByDescending(timestamp => timestamp), createdTimestamps); + } + + [Fact] + public async Task GetReviews_SortedByMostHelpful_IsInDescendingScoreOrder() + { + var response = await context.AnonymousClient.GetAsync( + "/api/products/sony-wh-1000xm5/reviews?sort=mostHelpful", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + var scores = page.GetProperty("items").EnumerateArray() + .Select(review => review.GetProperty("score").GetInt32()) + .ToList(); + Assert.Equal(scores.OrderByDescending(score => score), scores); + } + + [Fact] + public async Task GetReviews_PageSizeIsCappedAtFifty() + { + var response = await context.AnonymousClient.GetAsync( + "/api/products/sony-wh-1000xm5/reviews?pageSize=5000", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(50, page.GetProperty("pageSize").GetInt32()); + } + + [Fact] + public async Task GetReviews_ZeroPage_IsRejectedByThePositiveWrapper() + { + var response = await context.AnonymousClient.GetAsync( + "/api/products/sony-wh-1000xm5/reviews?page=0", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs b/tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs new file mode 100644 index 0000000..e0724ae --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs @@ -0,0 +1,76 @@ +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using Testcontainers.PostgreSql; +using Xunit; + +namespace ProductReviews.Api.IntegrationTests; + +public sealed class ReviewsApiFactory(string connectionString) : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Development runs the real migrate + seed path at startup (ADR-0006) — + // the tests exercise it instead of preparing the schema themselves. + builder.UseEnvironment("Development"); + builder.UseSetting("ConnectionStrings:productreviews", connectionString); + + builder.ConfigureServices(services => + services.PostConfigure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.Authority = null; + options.RequireHttpsMetadata = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = TestTokens.Issuer, + ValidAudience = TestTokens.Audience, + IssuerSigningKey = TestTokens.SigningKey, + NameClaimType = "name", + }; + })); + } +} + +/// One Postgres container + one API host for the whole run (ADR-0007); +/// tests isolate through unique authors/products, never by resetting the database. +public sealed class SharedApiContext : IAsyncLifetime +{ + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17.5").Build(); + + public ReviewsApiFactory Factory { get; private set; } = null!; + + public HttpClient AnonymousClient { get; private set; } = null!; + + public async ValueTask InitializeAsync() + { + await postgres.StartAsync(); + Factory = new ReviewsApiFactory(postgres.GetConnectionString()); + AnonymousClient = Factory.CreateClient(); + } + + public async ValueTask DisposeAsync() + { + AnonymousClient.Dispose(); + await Factory.DisposeAsync(); + await postgres.DisposeAsync(); + } + + /// A client authenticated as the given subject. Tests mint a fresh random + /// subject per scenario, which is what keeps them independent (one review per + /// product per author). + public HttpClient CreateClientFor(string subject, string displayName, string? email = null) + { + var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", TestTokens.CreateToken(subject, displayName, email)); + return client; + } + + public static string RandomSubject() => $"test-user-{Guid.NewGuid():N}"; +} + +[CollectionDefinition(nameof(SharedApiCollection))] +public sealed class SharedApiCollection : ICollectionFixture; diff --git a/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs b/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs new file mode 100644 index 0000000..3e179e0 --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs @@ -0,0 +1,40 @@ +using System.Text; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace ProductReviews.Api.IntegrationTests; + +/// Mints JWTs against a test-owned symmetric key. The API's real JwtBearer +/// pipeline validates them (ADR-0007) — only the trust anchor is swapped, not the code path. +public static class TestTokens +{ + public const string Issuer = "https://tests.productreviews.local"; + public const string Audience = "productreviews-tests"; + + public static readonly SymmetricSecurityKey SigningKey = + new(Encoding.UTF8.GetBytes("productreviews-integration-tests-signing-key-0123456789")); + + private static readonly JsonWebTokenHandler Handler = new(); + + public static string CreateToken(string subject, string displayName, string? email = null) + { + var claims = new Dictionary + { + ["sub"] = subject, + ["name"] = displayName, + }; + if (email is not null) + { + claims["email"] = email; + } + + return Handler.CreateToken(new SecurityTokenDescriptor + { + Issuer = Issuer, + Audience = Audience, + Claims = claims, + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256), + }); + } +} diff --git a/tests/ProductReviews.Api.IntegrationTests/VotesApiTests.cs b/tests/ProductReviews.Api.IntegrationTests/VotesApiTests.cs new file mode 100644 index 0000000..9031796 --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/VotesApiTests.cs @@ -0,0 +1,82 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace ProductReviews.Api.IntegrationTests; + +[Collection(nameof(SharedApiCollection))] +public sealed class VotesApiTests(SharedApiContext context) +{ + private async Task CreateReviewAsync(HttpClient author) + { + var created = await author.PostAsJsonAsync( + "/api/products/logi-mx-master-3s/reviews", + new { rating = 5, title = "Great mouse", body = "The scroll wheel alone is worth it." }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + return (await created.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken)) + .GetProperty("id").GetString()!; + } + + [Fact] + public async Task Vote_Flip_AndWithdraw_RecomputeTheScore() + { + using var author = context.CreateClientFor(SharedApiContext.RandomSubject(), "Author Abby"); + using var voter = context.CreateClientFor(SharedApiContext.RandomSubject(), "Voting Viktor"); + var reviewId = await CreateReviewAsync(author); + + var upvoted = await voter.PutAsJsonAsync( + $"/api/reviews/{reviewId}/vote", new { isUpvote = true }, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, upvoted.StatusCode); + var afterUpvote = await upvoted.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(1, afterUpvote.GetProperty("score").GetInt32()); + Assert.True(afterUpvote.GetProperty("myVote").GetBoolean()); + + var flipped = await voter.PutAsJsonAsync( + $"/api/reviews/{reviewId}/vote", new { isUpvote = false }, TestContext.Current.CancellationToken); + var afterFlip = await flipped.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(-1, afterFlip.GetProperty("score").GetInt32()); + Assert.False(afterFlip.GetProperty("myVote").GetBoolean()); + + var withdrawn = await voter.DeleteAsync($"/api/reviews/{reviewId}/vote", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, withdrawn.StatusCode); + var afterWithdrawal = await withdrawn.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(0, afterWithdrawal.GetProperty("score").GetInt32()); + Assert.Equal(JsonValueKind.Null, afterWithdrawal.GetProperty("myVote").ValueKind); + } + + [Fact] + public async Task Vote_OnYourOwnReview_IsRejected() + { + using var author = context.CreateClientFor(SharedApiContext.RandomSubject(), "Self-voting Sven"); + var reviewId = await CreateReviewAsync(author); + + var response = await author.PutAsJsonAsync( + $"/api/reviews/{reviewId}/vote", new { isUpvote = true }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.True(problem.GetProperty("errors").TryGetProperty("id", out _)); + } + + [Fact] + public async Task Vote_Anonymous_Returns401() + { + var response = await context.AnonymousClient.PutAsJsonAsync( + $"/api/reviews/{Guid.NewGuid()}/vote", new { isUpvote = true }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Vote_OnUnknownReview_Returns404() + { + using var voter = context.CreateClientFor(SharedApiContext.RandomSubject(), "Voting Vera"); + + var response = await voter.PutAsJsonAsync( + $"/api/reviews/{Guid.NewGuid()}/vote", new { isUpvote = true }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/ProductReviews.Domain.Tests/DomainGenerators.cs b/tests/ProductReviews.Domain.Tests/DomainGenerators.cs new file mode 100644 index 0000000..416643e --- /dev/null +++ b/tests/ProductReviews.Domain.Tests/DomainGenerators.cs @@ -0,0 +1,28 @@ +using FsCheck; +using FsCheck.Fluent; +using ProductReviews.Domain.Reviews; +using StrongTypes; +// FsCheck ships its own NonEmptyString test wrapper; ours is the StrongTypes one. +using NonEmptyString = StrongTypes.NonEmptyString; + +namespace ProductReviews.Domain.Tests; + +/// Project-specific arbitraries. Register together with the library's: +/// [Properties(Arbitrary = [typeof(DomainGenerators), typeof(Generators)])] +/// (StrongTypes.FsCheck.Generators covers NonEmptyString, Maybe, and friends). +public static class DomainGenerators +{ + public static Arbitrary Rating { get; } = + Gen.Choose(Reviews.Rating.MinimumStars, Reviews.Rating.MaximumStars) + .Select(stars => Reviews.Rating.Create(stars)) + .ToArbitrary(); + + /// The three-state edit parameter: ~30% skip (null), ~20% clear (None), ~50% set. + public static Arbitrary?> NullableMaybeNonEmptyString { get; } = + Gen.Frequency( + (3, Gen.Constant?>(null)), + (2, Gen.Constant?>(Maybe.None)), + (5, StrongTypes.FsCheck.Generators.NonEmptyString.Generator + .Select(value => (Maybe?)Maybe.Some(value)))) + .ToArbitrary(); +} diff --git a/tests/ProductReviews.Domain.Tests/ProductRatingSummaryTests.cs b/tests/ProductReviews.Domain.Tests/ProductRatingSummaryTests.cs new file mode 100644 index 0000000..c06dc5c --- /dev/null +++ b/tests/ProductReviews.Domain.Tests/ProductRatingSummaryTests.cs @@ -0,0 +1,53 @@ +using FsCheck.Xunit; +using ProductReviews.Domain.Catalog; +using ProductReviews.Domain.Reviews; +using StrongTypes; +using StrongTypes.FsCheck; +using Xunit; + +namespace ProductReviews.Domain.Tests; + +[Properties(Arbitrary = [typeof(DomainGenerators), typeof(Generators)])] +public sealed class ProductRatingSummaryTests +{ + private static Product CreateProduct() + => new( + id: 1, + slug: "test-product".ToNonEmpty(), + name: "Test Product".ToNonEmpty(), + description: "A product for tests.".ToNonEmpty(), + imageUrl: null, + createdAtUtc: ReviewTestData.CreatedAtUtc); + + [Property] + public void CountAndAverage_MatchTheRatings(Rating[] ratings) + { + var product = CreateProduct(); + + product.RefreshRatingSummary(ratings); + + Assert.Equal(ratings.Length, product.ReviewCount.Value); + if (ratings.Length == 0) + { + Assert.Null(product.AverageRating); + } + else + { + Assert.NotNull(product.AverageRating); + Assert.Equal(ratings.Average(rating => (double)rating.Value), product.AverageRating.Value, precision: 10); + Assert.InRange(product.AverageRating.Value, Rating.MinimumStars, Rating.MaximumStars); + } + } + + [Fact] + public void NoReviews_MeansNotYetRated_NeverZero() + { + var product = CreateProduct(); + + product.RefreshRatingSummary([Rating.Create(3)]); + product.RefreshRatingSummary([]); + + Assert.Null(product.AverageRating); + Assert.Equal(0, product.ReviewCount.Value); + } +} diff --git a/tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj b/tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj new file mode 100644 index 0000000..a63d511 --- /dev/null +++ b/tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj @@ -0,0 +1,20 @@ + + + + Exe + false + true + true + + + + + + + + + + + + + diff --git a/tests/ProductReviews.Domain.Tests/RatingTests.cs b/tests/ProductReviews.Domain.Tests/RatingTests.cs new file mode 100644 index 0000000..5a66da1 --- /dev/null +++ b/tests/ProductReviews.Domain.Tests/RatingTests.cs @@ -0,0 +1,30 @@ +using FsCheck.Xunit; +using ProductReviews.Domain.Reviews; +using StrongTypes.FsCheck; +using Xunit; + +namespace ProductReviews.Domain.Tests; + +[Properties(Arbitrary = [typeof(DomainGenerators), typeof(Generators)])] +public sealed class RatingTests +{ + [Property] + public bool TryCreate_AcceptsExactlyOneThroughFive(int value) + => (Rating.TryCreate(value) is not null) == (value is >= 1 and <= 5); + + [Property] + public bool Value_RoundTripsThroughCreate(Rating rating) + => Rating.Create(rating.Value) == rating; + + [Property] + public bool Ordering_FollowsTheUnderlyingValue(Rating left, Rating right) + => left.CompareTo(right) == left.Value.CompareTo(right.Value); + + [Fact] + public void Default_IsAValidOneStarRating() + => Assert.Equal(1, default(Rating).Value); + + [Fact] + public void Create_OutOfRange_Throws() + => Assert.Throws(() => Rating.Create(6)); +} diff --git a/tests/ProductReviews.Domain.Tests/ReviewEditTests.cs b/tests/ProductReviews.Domain.Tests/ReviewEditTests.cs new file mode 100644 index 0000000..fee4567 --- /dev/null +++ b/tests/ProductReviews.Domain.Tests/ReviewEditTests.cs @@ -0,0 +1,70 @@ +using FsCheck.Xunit; +using ProductReviews.Domain.Reviews; +using StrongTypes; +using StrongTypes.FsCheck; +using Xunit; + +namespace ProductReviews.Domain.Tests; + +/// The PATCH contract of Review.ApplyEdit: null leaves a field unchanged; +/// for the clearable optionals, Maybe.None clears and Maybe.Some replaces. +[Properties(Arbitrary = [typeof(DomainGenerators), typeof(Generators)])] +public sealed class ReviewEditTests +{ + [Fact] + public void AllNulls_ChangeNothing_ButMarkTheReviewEdited() + { + var review = ReviewTestData.CreateReview(pros: "Great battery".ToNonEmpty()); + var originalRating = review.Rating; + var originalTitle = review.Title; + var originalBody = review.Body; + var originalPros = review.Pros; + + review.ApplyEdit(rating: null, title: null, body: null, pros: null, cons: null, ReviewTestData.EditedAtUtc); + + Assert.Equal(originalRating, review.Rating); + Assert.Equal(originalTitle, review.Title); + Assert.Equal(originalBody, review.Body); + Assert.Equal(originalPros, review.Pros); + Assert.Null(review.Cons); + Assert.Equal(ReviewTestData.EditedAtUtc, review.UpdatedAtUtc); + } + + [Property] + public void Pros_FollowTheThreeStateContract(Maybe? prosChange) + { + var originalPros = "Original pro".ToNonEmpty(); + var review = ReviewTestData.CreateReview(pros: originalPros); + + review.ApplyEdit(rating: null, title: null, body: null, pros: prosChange, cons: null, ReviewTestData.EditedAtUtc); + + NonEmptyString? expected = prosChange is { } change ? change.Value : originalPros; + Assert.Equal(expected, review.Pros); + } + + [Property] + public void Cons_CanBeSetFromNothing(Maybe? consChange) + { + var review = ReviewTestData.CreateReview(cons: null); + + review.ApplyEdit(rating: null, title: null, body: null, pros: null, cons: consChange, ReviewTestData.EditedAtUtc); + + NonEmptyString? expected = consChange is { } change ? change.Value : null; + Assert.Equal(expected, review.Cons); + } + + [Property] + public void OnlyProvidedFieldsChange(Rating? rating, NonEmptyString? title, NonEmptyString? body) + { + var review = ReviewTestData.CreateReview(); + var originalRating = review.Rating; + var originalTitle = review.Title; + var originalBody = review.Body; + + review.ApplyEdit(rating, title, body, pros: null, cons: null, ReviewTestData.EditedAtUtc); + + Assert.Equal(rating ?? originalRating, review.Rating); + Assert.Equal(title ?? originalTitle, review.Title); + Assert.Equal(body ?? originalBody, review.Body); + } +} diff --git a/tests/ProductReviews.Domain.Tests/ReviewTestData.cs b/tests/ProductReviews.Domain.Tests/ReviewTestData.cs new file mode 100644 index 0000000..31d4a11 --- /dev/null +++ b/tests/ProductReviews.Domain.Tests/ReviewTestData.cs @@ -0,0 +1,22 @@ +using ProductReviews.Domain.Reviews; +using StrongTypes; + +namespace ProductReviews.Domain.Tests; + +internal static class ReviewTestData +{ + public static readonly DateTime CreatedAtUtc = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + public static readonly DateTime EditedAtUtc = new(2026, 2, 1, 12, 0, 0, DateTimeKind.Utc); + + public static Review CreateReview(NonEmptyString? pros = null, NonEmptyString? cons = null) + => new( + productId: 1, + authorId: Guid.NewGuid(), + authorDisplayName: "Alice Novak".ToNonEmpty(), + rating: Rating.Create(4), + title: "A solid product".ToNonEmpty(), + body: "Does what it promises.".ToNonEmpty(), + pros: pros, + cons: cons, + createdAtUtc: CreatedAtUtc); +} diff --git a/tests/ProductReviews.Domain.Tests/ScoreTests.cs b/tests/ProductReviews.Domain.Tests/ScoreTests.cs new file mode 100644 index 0000000..17e5966 --- /dev/null +++ b/tests/ProductReviews.Domain.Tests/ScoreTests.cs @@ -0,0 +1,31 @@ +using FsCheck.Xunit; +using ProductReviews.Domain.Votes; +using Xunit; + +namespace ProductReviews.Domain.Tests; + +public sealed class ScoreTests +{ + [Property] + public bool RefreshScore_IsAlwaysUpvotesMinusDownvotes(bool[] voteDirections) + { + var review = ReviewTestData.CreateReview(); + var votes = voteDirections + .Select(isUpvote => new ReviewVote(review.Id, Guid.NewGuid(), isUpvote, ReviewTestData.CreatedAtUtc)) + .ToList(); + + review.RefreshScore(votes); + + return review.Score == votes.Count(vote => vote.IsUpvote) - votes.Count(vote => !vote.IsUpvote); + } + + [Fact] + public void ChangeDirection_FlipsTheContribution() + { + var vote = new ReviewVote(Guid.NewGuid(), Guid.NewGuid(), isUpvote: true, ReviewTestData.CreatedAtUtc); + Assert.Equal(1, vote.ScoreContribution); + + vote.ChangeDirection(isUpvote: false); + Assert.Equal(-1, vote.ScoreContribution); + } +}