From 33eee959a31f2ded9ee5ed9165b5f5280146a4f2 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 07:56:41 +0200 Subject: [PATCH 01/11] Add spec: business + technical requirements, ADRs, assumptions Spec-first foundation for the StrongTypes showcase: product-terms business requirements, the technical design (stack, domain model, API surface, conventions), eight ADRs covering the pivotal decisions, and a list of assumptions to validate. Co-Authored-By: Claude Fable 5 --- ...0001-vertical-slices-and-project-layout.md | 72 ++++ ...002-validation-lives-in-the-type-system.md | 51 +++ .../0003-business-errors-are-result-enums.md | 38 +++ .../0004-openapi-is-the-frontend-contract.md | 40 +++ docs/adr/0005-auth-zitadel-oidc-pkce.md | 43 +++ .../0006-seeding-at-startup-not-migrations.md | 36 ++ docs/adr/0007-tests-use-real-dependencies.md | 42 +++ docs/adr/0008-frontend-vue-spa.md | 37 +++ docs/adr/README.md | 22 ++ docs/assumptions.md | 45 +++ docs/business-requirements.md | 100 ++++++ docs/technical-requirements.md | 310 ++++++++++++++++++ 12 files changed, 836 insertions(+) create mode 100644 docs/adr/0001-vertical-slices-and-project-layout.md create mode 100644 docs/adr/0002-validation-lives-in-the-type-system.md create mode 100644 docs/adr/0003-business-errors-are-result-enums.md create mode 100644 docs/adr/0004-openapi-is-the-frontend-contract.md create mode 100644 docs/adr/0005-auth-zitadel-oidc-pkce.md create mode 100644 docs/adr/0006-seeding-at-startup-not-migrations.md create mode 100644 docs/adr/0007-tests-use-real-dependencies.md create mode 100644 docs/adr/0008-frontend-vue-spa.md create mode 100644 docs/adr/README.md create mode 100644 docs/assumptions.md create mode 100644 docs/business-requirements.md create mode 100644 docs/technical-requirements.md 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..3136615 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3426 @@ +{ + "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", + "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/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..3f3be8f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "productreviews-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit", + "generate:api": "openapi-typescript openapi.json -o src/api/schema.d.ts", + "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", + "typescript": "~5.9.3", + "vite": "^8.1.4", + "vitest": "^4.1.10", + "vue-tsc": "^3.3.7" + } +} 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..ab86044 --- /dev/null +++ b/frontend/src/pages/CatalogPage.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/pages/ProductDetailPage.vue b/frontend/src/pages/ProductDetailPage.vue new file mode 100644 index 0000000..42934c8 --- /dev/null +++ b/frontend/src/pages/ProductDetailPage.vue @@ -0,0 +1,271 @@ + + + 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/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..31ec137 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,19 @@ +/// +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"; +const proxy = { "/api": { 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/src/ProductReviews.Api/ProductReviews.Api.csproj b/src/ProductReviews.Api/ProductReviews.Api.csproj index 7f6b18d..c54f982 100644 --- a/src/ProductReviews.Api/ProductReviews.Api.csproj +++ b/src/ProductReviews.Api/ProductReviews.Api.csproj @@ -5,8 +5,9 @@ - + + From c701052bc760f555690104ed915804eeece5947c Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 08:45:15 +0200 Subject: [PATCH 06/11] Add backend tests: FsCheck property tests + Testcontainers integration suite Domain tests (13): Rating bounds/ordering properties, the ApplyEdit three-state Maybe contract, score arithmetic, and rating-summary aggregation, with a shared generators class re-exporting the Kalicz.StrongTypes.FsCheck arbitraries. Integration tests (28): one shared Postgres container + API host per run, exercising the real migrate/seed startup path; wire-level JsonElement asserts on anonymous payloads; JWTs minted against a test-owned key through the real JwtBearer pipeline; covers catalog, filtering/sorting/paging, submit/edit/ delete (incl. the {}/{"Value"} Maybe semantics), voting rules, profile, and the OpenAPI document constraints. No mocks anywhere. Co-Authored-By: Claude Fable 5 --- Directory.Packages.props | 2 +- ProductReviews.slnx | 4 + .../ProductReviews.Domain.csproj | 2 + .../CatalogApiTests.cs | 51 +++++ .../OpenApiDocumentTests.cs | 70 +++++++ ...ProductReviews.Api.IntegrationTests.csproj | 21 ++ .../ProfileApiTests.cs | 44 +++++ .../ReviewWritesApiTests.cs | 180 ++++++++++++++++++ .../ReviewsQueryApiTests.cs | 74 +++++++ .../SharedApiContext.cs | 76 ++++++++ .../TestTokens.cs | 40 ++++ .../VotesApiTests.cs | 82 ++++++++ .../DomainGenerators.cs | 28 +++ .../ProductRatingSummaryTests.cs | 53 ++++++ .../ProductReviews.Domain.Tests.csproj | 20 ++ .../RatingTests.cs | 30 +++ .../ReviewEditTests.cs | 70 +++++++ .../ReviewTestData.cs | 22 +++ .../ProductReviews.Domain.Tests/ScoreTests.cs | 31 +++ 19 files changed, 899 insertions(+), 1 deletion(-) create mode 100644 tests/ProductReviews.Api.IntegrationTests/CatalogApiTests.cs create mode 100644 tests/ProductReviews.Api.IntegrationTests/OpenApiDocumentTests.cs create mode 100644 tests/ProductReviews.Api.IntegrationTests/ProductReviews.Api.IntegrationTests.csproj create mode 100644 tests/ProductReviews.Api.IntegrationTests/ProfileApiTests.cs create mode 100644 tests/ProductReviews.Api.IntegrationTests/ReviewWritesApiTests.cs create mode 100644 tests/ProductReviews.Api.IntegrationTests/ReviewsQueryApiTests.cs create mode 100644 tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs create mode 100644 tests/ProductReviews.Api.IntegrationTests/TestTokens.cs create mode 100644 tests/ProductReviews.Api.IntegrationTests/VotesApiTests.cs create mode 100644 tests/ProductReviews.Domain.Tests/DomainGenerators.cs create mode 100644 tests/ProductReviews.Domain.Tests/ProductRatingSummaryTests.cs create mode 100644 tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj create mode 100644 tests/ProductReviews.Domain.Tests/RatingTests.cs create mode 100644 tests/ProductReviews.Domain.Tests/ReviewEditTests.cs create mode 100644 tests/ProductReviews.Domain.Tests/ReviewTestData.cs create mode 100644 tests/ProductReviews.Domain.Tests/ScoreTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 14d0ace..8125300 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,7 +35,7 @@ - + diff --git a/ProductReviews.slnx b/ProductReviews.slnx index e99a513..4b02a2a 100644 --- a/ProductReviews.slnx +++ b/ProductReviews.slnx @@ -5,4 +5,8 @@ + + + + diff --git a/src/ProductReviews.Domain/ProductReviews.Domain.csproj b/src/ProductReviews.Domain/ProductReviews.Domain.csproj index fd42f4f..9b3fbe9 100644 --- a/src/ProductReviews.Domain/ProductReviews.Domain.csproj +++ b/src/ProductReviews.Domain/ProductReviews.Domain.csproj @@ -3,6 +3,8 @@ + + all runtime; build; native; contentfiles; analyzers 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); + } +} From 2a5d240b2a00c0f0187f245c61542953b6a54be4 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 08:48:26 +0200 Subject: [PATCH 07/11] Add frontend tests: Vitest unit suite + Playwright E2E harness Unit (16): the buildEditReviewBody three-state Maybe contract, StarRating display/input behavior, ReviewCard vote/ownership rules, problem message extraction. E2E: Playwright boots the full Aspire stack in E2E mode (throwaway containers, preview build on :4173), signs in through the real Zitadel hosted login, and walks browse -> review -> edit (clear pros) -> vote -> delete; a contract spec fails when the committed openapi.json drifts from the running API (refresh via npm run refresh:api). Co-Authored-By: Claude Fable 5 --- frontend/package.json | 1 + frontend/playwright.config.ts | 25 +++++++ frontend/scripts/refresh-openapi.mjs | 16 +++++ frontend/tests/e2e/catalog.spec.ts | 31 ++++++++ frontend/tests/e2e/contract.spec.ts | 21 ++++++ frontend/tests/e2e/helpers/auth.ts | 29 ++++++++ frontend/tests/e2e/review-flow.spec.ts | 83 ++++++++++++++++++++++ frontend/tests/unit/ReviewCard.spec.ts | 60 ++++++++++++++++ frontend/tests/unit/StarRating.spec.ts | 27 +++++++ frontend/tests/unit/editReviewBody.spec.ts | 67 +++++++++++++++++ frontend/tests/unit/problem.spec.ts | 19 +++++ frontend/vite.config.ts | 7 +- 12 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/scripts/refresh-openapi.mjs create mode 100644 frontend/tests/e2e/catalog.spec.ts create mode 100644 frontend/tests/e2e/contract.spec.ts create mode 100644 frontend/tests/e2e/helpers/auth.ts create mode 100644 frontend/tests/e2e/review-flow.spec.ts create mode 100644 frontend/tests/unit/ReviewCard.spec.ts create mode 100644 frontend/tests/unit/StarRating.spec.ts create mode 100644 frontend/tests/unit/editReviewBody.spec.ts create mode 100644 frontend/tests/unit/problem.spec.ts diff --git a/frontend/package.json b/frontend/package.json index 3f3be8f..3780855 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "preview": "vite preview", "typecheck": "vue-tsc --noEmit", "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" diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..1852b34 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,25 @@ +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: { + command: "npm run build && 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/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..c8c72eb --- /dev/null +++ b/frontend/tests/unit/ReviewCard.spec.ts @@ -0,0 +1,60 @@ +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..222049e --- /dev/null +++ b/frontend/tests/unit/problem.spec.ts @@ -0,0 +1,19 @@ +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/vite.config.ts b/frontend/vite.config.ts index 31ec137..1db9de0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,7 +6,12 @@ import { defineConfig } from "vite"; // 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"; -const proxy = { "/api": { target: apiProxyTarget, changeOrigin: true } }; +// /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()], From f7482160306fed6b2e7fc4ca19a7421cf5592dda Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 08:50:00 +0200 Subject: [PATCH 08/11] Add README quickstart + StrongTypes tour and CLAUDE.md template conventions Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md 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/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) From e1d442c7614198d3c908f525546757f01886bf97 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 09:02:18 +0200 Subject: [PATCH 09/11] Fix issues found by E2E: stale responses, E2E env baking, identity claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard review reloads with a request sequence so rapid sort/filter changes cannot apply out of order. - E2E builds the frontend inside the AppHost resource (serve:e2e): VITE_* values bake into the bundle at build time, so the build must run with the Aspire-injected env; Playwright no longer pre-builds. - E2E uses a throwaway Zitadel machine-key dir — a PAT left over from a previous run belongs to a dead instance and provisioning would 401. - Rename the zitadeldb resource (collided with the zitadel container name). - Profile claims: the SPA gets name via idTokenUserinfoAssertion; the API enriches the validated principal from the userinfo endpoint (cached per subject) because Zitadel JWT access tokens carry only the subject. Co-Authored-By: Claude Fable 5 --- frontend/package.json | 1 + frontend/playwright.config.ts | 4 +- frontend/src/pages/ProductDetailPage.vue | 8 +++ .../Infrastructure/Authentication.cs | 72 +++++++++++++++++++ src/ProductReviews.AppHost/AppHost.cs | 15 ++-- .../Zitadel/ZitadelHosting.cs | 2 +- .../Zitadel/ZitadelProvisioning.cs | 2 + 7 files changed, 98 insertions(+), 6 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 3780855..fbdf87a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", + "serve:e2e": "vite build && vite preview", "typecheck": "vue-tsc --noEmit", "generate:api": "openapi-typescript openapi.json -o src/api/schema.d.ts", "refresh:api": "node scripts/refresh-openapi.mjs && npm run generate:api", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 1852b34..3dbac2e 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -16,7 +16,9 @@ export default defineConfig({ trace: "retain-on-failure", }, webServer: { - command: "npm run build && dotnet run --project ../src/ProductReviews.AppHost", + // 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, diff --git a/frontend/src/pages/ProductDetailPage.vue b/frontend/src/pages/ProductDetailPage.vue index 42934c8..8ce4b7b 100644 --- a/frontend/src/pages/ProductDetailPage.vue +++ b/frontend/src/pages/ProductDetailPage.vue @@ -45,7 +45,10 @@ async function loadProduct(): Promise { product.value = data; } +let reviewsRequestSequence = 0; + async function loadReviews(): Promise { + const requestNumber = ++reviewsRequestSequence; const { data, error } = await api.GET("/api/products/{slug}/reviews", { params: { path: { slug: props.slug }, @@ -57,6 +60,11 @@ async function loadReviews(): Promise { }, }, }); + // A newer request superseded this one — dropping the stale response keeps + // rapid sort/filter changes from applying out of order. + if (requestNumber !== reviewsRequestSequence) { + return; + } if (error !== undefined || data === undefined) { listError.value = problemMessage(error); return; diff --git a/src/ProductReviews.Api/Infrastructure/Authentication.cs b/src/ProductReviews.Api/Infrastructure/Authentication.cs index 021654e..7c7b800 100644 --- a/src/ProductReviews.Api/Infrastructure/Authentication.cs +++ b/src/ProductReviews.Api/Infrastructure/Authentication.cs @@ -1,4 +1,8 @@ +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; @@ -8,11 +12,16 @@ namespace ProductReviews.Api.Infrastructure; /// [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 => @@ -32,6 +41,13 @@ public static void Configure(WebApplicationBuilder builder) ClockSkew = TimeSpan.FromSeconds(30), NameClaimType = "name", }; + if (authority is not null) + { + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => EnrichFromUserinfoAsync(context, authority), + }; + } }); builder.Services.AddAuthorization(); @@ -42,4 +58,60 @@ 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.AppHost/AppHost.cs b/src/ProductReviews.AppHost/AppHost.cs index 46f964f..f8336d4 100644 --- a/src/ProductReviews.AppHost/AppHost.cs +++ b/src/ProductReviews.AppHost/AppHost.cs @@ -17,13 +17,18 @@ .WithDataVolume("productreviews-postgres-data"); var productReviewsDatabase = postgres.AddDatabase("productreviews"); -var zitadelDatabase = postgres.AddDatabase("zitadel"); +// "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. +// 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 = Path.Combine(repositoryRoot, ".zitadel"); +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. @@ -91,7 +96,9 @@ frontendInstall = builder.AddExecutable("frontend-install", npm, frontendDirectory, "ci"); } -var frontend = builder.AddNpmApp("frontend", "../../frontend", e2e ? "preview" : "dev") +// 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) diff --git a/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs b/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs index ccb3b04..bdacff3 100644 --- a/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs +++ b/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs @@ -60,7 +60,7 @@ public static IResourceBuilder AddZitadel( .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", "zitadel") + .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") diff --git a/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs b/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs index e9375e4..4f12ee4 100644 --- a/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs +++ b/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs @@ -288,6 +288,8 @@ private static object OidcConfigBody(string[] redirectUris, string[] postLogoutU ["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) { From c3cc4b9d234a9c17d639355fccfff12ccf1edb9f Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 09:12:35 +0200 Subject: [PATCH 10/11] Retry Zitadel provisioning on 401 with a re-read PAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During a cold first init the PAT file on disk can briefly belong to a previous instance (Zitadel overwrites it only when init finishes) — a 401 means "re-read and try again", not "give up". Co-Authored-By: Claude Fable 5 --- src/ProductReviews.AppHost/AppHost.cs | 5 ++--- .../Zitadel/ZitadelProvisioning.cs | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/ProductReviews.AppHost/AppHost.cs b/src/ProductReviews.AppHost/AppHost.cs index f8336d4..21f22da 100644 --- a/src/ProductReviews.AppHost/AppHost.cs +++ b/src/ProductReviews.AppHost/AppHost.cs @@ -130,9 +130,8 @@ try { var authority = await zitadelAuthority.Task; - var pat = await ZitadelProvisioning.ReadPatAsync(patPath, authority, CancellationToken.None); - var clientId = await ZitadelProvisioning.EnsureSpaClientAsync( - authority, pat, frontendOrigin, Console.WriteLine, CancellationToken.None); + 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. diff --git a/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs b/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs index 4f12ee4..63baf5f 100644 --- a/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs +++ b/src/ProductReviews.AppHost/Zitadel/ZitadelProvisioning.cs @@ -59,6 +59,27 @@ public static async Task ReadPatAsync(string patPath, string authority, 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) { From ca9e37e8f34478197ce67310ec675d30a47f34d7 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 17:40:25 +0200 Subject: [PATCH 11/11] Add CI pipeline, Dependabot, Prettier, CODEOWNERS CI mirrors the house pattern: backend build + tests (analyzers fail the build, Testcontainers integration suite), frontend format/typecheck/unit/ build, and a full-stack Playwright E2E job with cached browsers and trace upload. Dependabot covers nuget (central package management), npm, and github-actions, grouped weekly. Co-Authored-By: Claude Fable 5 --- .github/CODEOWNERS | 1 + .github/dependabot.yml | 32 +++++ .github/workflows/ci.yml | 143 +++++++++++++++++++++++ frontend/.prettierignore | 8 ++ frontend/.prettierrc.json | 3 + frontend/package-lock.json | 17 +++ frontend/package.json | 3 + frontend/src/pages/CatalogPage.vue | 5 +- frontend/src/pages/ProductDetailPage.vue | 9 +- frontend/tests/unit/ReviewCard.spec.ts | 4 +- frontend/tests/unit/problem.spec.ts | 10 +- 11 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc.json 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/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..9bbb863 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,8 @@ +# Generated — the API owns these shapes (npm run refresh:api). +src/api/schema.d.ts +openapi.json + +dist/ +node_modules/ +test-results/ +playwright-report/ diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..963354f --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 120 +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3136615..13de6c2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@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", @@ -2429,6 +2430,22 @@ "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", diff --git a/frontend/package.json b/frontend/package.json index fbdf87a..7f7c267 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,8 @@ "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", @@ -28,6 +30,7 @@ "@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", diff --git a/frontend/src/pages/CatalogPage.vue b/frontend/src/pages/CatalogPage.vue index ab86044..4b6775d 100644 --- a/frontend/src/pages/CatalogPage.vue +++ b/frontend/src/pages/CatalogPage.vue @@ -34,8 +34,9 @@ onMounted(async () => {

diff --git a/frontend/src/pages/ProductDetailPage.vue b/frontend/src/pages/ProductDetailPage.vue index 8ce4b7b..ced40ef 100644 --- a/frontend/src/pages/ProductDetailPage.vue +++ b/frontend/src/pages/ProductDetailPage.vue @@ -30,9 +30,7 @@ const totalPages = computed(() => { return Math.max(1, Math.ceil(totalCount / pageSize)); }); -const canWriteReview = computed( - () => auth.isSignedIn && product.value !== null && product.value.myReviewId == null, -); +const canWriteReview = computed(() => auth.isSignedIn && product.value !== null && product.value.myReviewId == null); async function loadProduct(): Promise { const { data, error, response } = await api.GET("/api/products/{slug}", { @@ -201,8 +199,9 @@ watch(

diff --git a/frontend/tests/unit/ReviewCard.spec.ts b/frontend/tests/unit/ReviewCard.spec.ts index c8c72eb..9c5a580 100644 --- a/frontend/tests/unit/ReviewCard.spec.ts +++ b/frontend/tests/unit/ReviewCard.spec.ts @@ -49,7 +49,9 @@ describe("ReviewCard", () => { }); it("shows pros and cons lines only when present", () => { - const wrapper = mount(ReviewCard, { props: { review: createReview({ pros: "Light", cons: "Pricey" }), canVote: true } }); + 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"); diff --git a/frontend/tests/unit/problem.spec.ts b/frontend/tests/unit/problem.spec.ts index 222049e..c5f20d3 100644 --- a/frontend/tests/unit/problem.spec.ts +++ b/frontend/tests/unit/problem.spec.ts @@ -3,13 +3,15 @@ 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."); + 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", detail: "You already reviewed this product." })).toBe( + "You already reviewed this product.", + ); expect(problemMessage({ title: "Conflict" })).toBe("Conflict"); });