From 8a52703340d353614b4ec48069a0ff52912d14d2 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Tue, 7 Apr 2026 14:44:08 -0700 Subject: [PATCH 01/11] [ADR-161] Spec for Layout customization backend service --- Directory.Packages.props | 4 - src/_spec_LayoutCustomizationService.md | 918 ++++++++++++++++++++++++ 2 files changed, 918 insertions(+), 4 deletions(-) create mode 100644 src/_spec_LayoutCustomizationService.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 12dcfc3e..6dfb016f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -42,10 +42,6 @@ - - - - \ No newline at end of file diff --git a/src/_spec_LayoutCustomizationService.md b/src/_spec_LayoutCustomizationService.md new file mode 100644 index 00000000..246fd0d6 --- /dev/null +++ b/src/_spec_LayoutCustomizationService.md @@ -0,0 +1,918 @@ +# Layout Customization Service (Backend) + +> **Status:** Draft +> **Will become:** `_doc_LayoutCustomizationService.md` once implementation is complete + +## Overview + +The Layout Customization Service is a microservice backend that enables administrators to +remotely create, edit, compile, and publish remote control layouts for the AdaptiveRemote +client application. Administrators edit layouts via a web editor; the backend stores, +compiles, validates, and distributes them; client applications download and cache layouts +and update automatically when new versions are published. This epic covers the backend +services only. The web editor UI, client-side layout integration, CI/CD deployment, and +load testing are covered by separate related epics. + +## Terminology + +| Term | Meaning | +|------|---------| +| Client application | The end-user AdaptiveRemote Windows app (remote control) | +| Editor application | The administrator-facing Blazor WebAssembly app for editing layouts | +| Backend | The microservices defined in this epic | +| End user | Person using the client application | +| Administrator | Person using the editor application to modify layouts for an end user | +| Raw layout | An administrator-editable layout definition (source format, JSON) | +| Compiled layout | A processed layout ready for client consumption (command JSON + CSS) | + +## Responsibilities & Boundaries + +- **Owns:** Storage, compilation, validation, and distribution of remote layouts via REST + APIs; layout change notifications to connected clients via SSE +- **Does not own:** Web editor UI; client-side layout consumption, caching, and update + application; CI/CD deployment pipeline; load testing infrastructure; user authentication + (delegated to external IdP); layout schema definition (defined in the editor epic) +- **Integrates with:** External OAuth2 identity provider (JWT validation); `AdaptiveRemote.Contracts` + shared library (layout DTOs); client application (SSE consumer); editor application + (layout CRUD consumer) + +## Key Design Decisions + +### Repo organization: solution filters, not folder split + +_Context:_ Backend projects will be added to the same repo as the client application. Options +were to reorganize into top-level `client/` and `backend/` folders, or to keep everything +under `src/` and `test/` and use solution filters. + +_Decision:_ Keep all projects under `src/` and `test/`. Add `client.slnf` and `backend.slnf` +solution filters so developers can load only the relevant set. A master `AdaptiveRemote.sln` +includes all projects. + +_Consequences:_ No folder restructuring of existing client projects. Consistent layout for +both audiences. Backend project names follow the convention `AdaptiveRemote.Backend.*` to +avoid collision with client projects. + +### DynamoDB for layout storage; SQS for processing queue + +_Context:_ Layout access patterns are almost entirely key/user/timestamp lookups with opaque +JSON content. A relational database would add server management overhead without providing +relational features we'd actually use. Given the AWS deployment target, purpose-built managed +AWS services are the natural fit. + +_Decision:_ Use **DynamoDB** for `RawLayoutService` and `CompiledLayoutService` storage. +Partition key is `UserId`; sort key is `Id` (a KSUID or similar time-ordered ID). This +covers all access patterns: point-read by ID, list all layouts for a user, get the active +layout by user. Layout elements (`RawLayout.Elements`, `CompiledLayout.Elements`) and CSS +(`CompiledLayout.CssDefinitions`) are serialized to JSON strings and stored as DynamoDB +string attributes. Use **SQS** as the message queue between `RawLayoutService` and +`LayoutProcessingService`. The SQS queue is configured with a **max receive count of 3** +(4 total attempts including the first); messages that exhaust retries are moved to a +**Dead Letter Queue (DLQ)**. DLQ messages are retained for 14 days. `LayoutProcessingService` +logs an error on every failed attempt and on DLQ arrival. The raw layout's `ValidationResult` +is not automatically updated for DLQ messages; manual reprocessing (by re-saving the raw +layout) is required. This is a known limitation and a candidate for future improvement. + +_Consequences:_ No database server to provision or manage in production. Pay-per-request +pricing is well-suited to low initial traffic. Local development uses LocalStack +(a Docker container that emulates DynamoDB, SQS, and Lambda). Strong .NET support via AWSSDK. +The DynamoDB single-table design requires upfront key schema decisions; the partition/sort key +model above is sufficient for all current access patterns. Adding a new query pattern (e.g., +list layouts by name) may require a Global Secondary Index. + +### Direct HTTP between services for MVP; event-driven boundary preserved + +_Context:_ The epic raised event-driven architecture (e.g., Kafka) as a question. Event-driven +adds substantial infrastructure complexity (broker, consumer groups, at-least-once delivery) +not justified at MVP scale, but the architecture should not rule it out. + +_Decision:_ Services communicate via direct HTTP for the initial implementation, with one +exception: `RawLayoutService` enqueues a message to SQS when a layout is saved, and +`LayoutProcessingService` polls that queue. This makes compilation inherently asynchronous — +the editor receives a `201 Created` immediately after save and learns that compilation is +complete via an SSE event (the same stream the client uses). All other service-to-service +calls (e.g., `LayoutProcessingService` → `LayoutCompilerService`) remain synchronous HTTP. +Each service-to-service communication boundary is modeled as an injected interface so the +transport can be changed without modifying callers. + +_Consequences:_ Async processing prevents slow compilation from blocking the editor. The +SQS queue provides natural retry and backpressure if `LayoutProcessingService` is +temporarily unavailable. Synchronous HTTP for the remaining internal calls keeps the design +simple for MVP. + +### All service-to-service communication is interface-abstracted + +_Context:_ The transport mechanism for any given service-to-service call may need to change +(e.g., HTTP → SQS, or direct call → fan-out to multiple consumers). Callers should not be +coupled to transport details. + +_Decision:_ Every cross-service call is expressed as an injected interface in the calling +service. The interface captures intent (what is being requested or notified), not transport. +Implementations are registered in DI and can be swapped without changing callers. This +applies uniformly: storage repositories, HTTP client wrappers, SQS publishers, and SSE +notification publishers all follow this pattern. + +_Consequences:_ Transport changes are contained to the implementation class and DI +registration. The pattern adds a small amount of indirection but makes each service +independently testable with mock implementations. No special framework is required — +standard .NET DI is sufficient. + +### Service discovery and load balancing + +_Context:_ Services that communicate via HTTP need a way to locate each other. The approach +differs between local development and production. + +_Decision:_ + +**Production:** Services run as Docker containers on **AWS ECS (Fargate)**. Internal +service-to-service traffic uses **ECS Service Connect**, which provides DNS-based service +discovery and client-side load balancing within the ECS cluster. Each service registers +under a short name (e.g., `rawlayoutservice`); callers reach it at +`http://rawlayoutservice/...` with no additional infrastructure. External traffic from the +client and editor applications enters through **AWS API Gateway**, which handles auth +validation and routes requests to the appropriate ECS service. + +**Local development:** Docker Compose provides service discovery automatically via Docker +DNS. Services are reachable by their Compose service name (e.g., +`http://rawlayoutservice:8080`). No additional tooling is required. + +**Configuration:** Each service's base URL is injected via environment variable or +`appsettings.json`. No URLs are hardcoded. The same binaries run locally and in production; +only configuration changes. + +_Consequences:_ ECS Service Connect eliminates the need for a separate internal load +balancer or service mesh. Docker Compose DNS makes local development zero-configuration. +The environment-variable–driven URL model is a standard .NET pattern and requires no +framework changes. + +### Orchestration over choreography for the compilation pipeline + +_Context:_ `LayoutProcessingService` coordinates five steps (fetch → compile → validate → +store → notify) and is therefore coupled to five other services. Choreography was considered +as an alternative: each service would react to events rather than being called, eliminating +the central coordinator. + +_Decision:_ Keep the orchestrator pattern. The compilation pipeline is strictly linear with +no fan-out, making choreography's main benefit (independent step scaling and reuse across +workflows) inapplicable. In an orchestrated design, error handling — specifically the +`ValidationResult` write-back to `RawLayoutService` on failure — lives in one place with +full context. In a choreographed design, `RawLayoutService` would need to subscribe to +failure events, adding business logic to a CRUD service. The orchestrator's coupling is +managed through injected interfaces (independently testable) and it owns no storage of its +own. Revisit choreography if the pipeline grows significantly or steps need to be reused +across multiple workflows. + +_Consequences:_ The overall workflow is explicit and debuggable in one place. `LayoutProcessingService` +is intentionally coupled to its participants — this is the orchestrator pattern working as +designed, not a design flaw. + +### Lambda for stateless services; ECS Fargate for stateful services + +_Context:_ `LayoutCompilerService` and `LayoutValidationService` are stateless and invoked +only when an administrator saves a layout — not on the hot path of any client request. +Running them as always-on ECS containers means paying for idle capacity on services that +may go hours without being invoked. + +_Decision:_ Host `LayoutCompilerService` and `LayoutValidationService` as **AWS Lambda +functions**. All other services run as **ECS Fargate** containers. Lambda functions are +exposed via **Lambda Function URLs** (no API Gateway layer needed for internal calls); +`LayoutProcessingService` reaches them over HTTPS using the existing `ILayoutCompilerClient` +and `ILayoutValidationClient` HTTP interfaces — the ECS-to-Lambda boundary is transparent +to callers. Use **Native AOT** compilation for the Lambda functions to minimize cold start +latency. Cold starts are acceptable regardless, because `LayoutProcessingService` is already +running asynchronously via SQS — a cold start adds seconds to a background process, not to +a user-facing response. LocalStack emulates Lambda locally, consistent with the existing +DynamoDB and SQS setup. + +_Consequences:_ Pay-per-invocation cost model for low-frequency services. No idle container +cost. Native AOT requires that Lambda function code avoids reflection-heavy libraries. +Lambda Function URLs keep the calling convention identical to ECS HTTP services, preserving +the interface abstraction. + +### Shared contracts library for layout definition DTOs; existing App types stay in App + +_Context:_ The client application, editor application, and backend all need to work with +layout data structures. The existing `RemoteLayoutElement` and `Command` types in +`AdaptiveRemote.App.Models` were considered for sharing, but they inherit from `MvvmObject` +and carry MVVM properties, execution delegates, and client lifecycle concerns — they cannot +live in a framework-agnostic library. + +_Decision:_ Introduce `AdaptiveRemote.Contracts` as a shared .NET class library (no +**platform-specific** dependencies, no `-windows` target) containing layout definition DTOs +and a source-generated `JsonSerializerContext`. "No platform-specific dependencies" means +no WPF, Windows APIs, or Blazor — BCL libraries including `System.Text.Json` and +`System.Collections.Generic` are permitted and expected. The library contains pure records +representing what a layout element *is* — name, label, glyph, grid position, CSS overrides +— with no behavior. The existing `Command` and `RemoteLayoutElement` types remain in +`AdaptiveRemote.App` as runtime types; they are mapped from the Contracts DTOs at +layout-apply time (responsibility of the client-side consumption epic). + +`AdaptiveRemote.Contracts` defines a `LayoutContractsJsonContext : JsonSerializerContext` +annotated with `[JsonSerializable]` for each top-level DTO type. This serves two purposes: +source-generated serialization is **required** for the Native AOT Lambda functions +(`LayoutCompilerService`, `LayoutValidationService`), and placing the context in Contracts +ensures all consumers share one consistent serialization definition rather than maintaining +separate contexts that could drift. + +The client application uses the Contracts DTOs and context directly for deserializing API +responses. JSON field names and structure are defined once and shared by both the +serializing backend and the deserializing client. + +`AdaptiveRemote.Contracts` is included in both `client.slnf` and `backend.slnf`. + +_Consequences:_ Single source of truth for the wire format. Breaking changes to shared +types are caught at compile time across all consumers. The App runtime types and Contracts +DTOs are not duplicates — they serve different purposes (runtime behavior vs. data +transport). The mapping from DTO to runtime type is a contained, testable step. + +### Server-Sent Events for client push notifications + +_Context:_ The `NotificationService` needs to push layout-change events to connected clients. +WebSockets support bidirectional communication, which is unnecessary — clients only need to +receive events. + +_Decision:_ Use Server-Sent Events (SSE) over HTTPS. The client application opens a +persistent SSE connection on startup. Standard SSE retry handles reconnection automatically. + +_Consequences:_ Simpler server implementation than WebSockets. Works through most HTTP +proxies and firewalls. Limitation: SSE is one-way; if bidirectional communication is +needed in the future, migration to WebSockets would be required. + +### OAuth2 with AWS Cognito; two flows for two client types + +_Context:_ The client application runs unattended on a disabled user's machine and cannot +present an interactive login. Stress bot accounts need to be provisioned programmatically +without manual IdP UI work. A custom API key store was considered but would require owning +key generation, hashing, rotation, and revocation — a non-trivial security surface. + +_Decision:_ Use **AWS Cognito** as the identity provider with two OAuth2 flows: + +- **Authorization Code flow** — for administrators using the editor application. Standard + browser-based login; Cognito handles MFA, session management, and token refresh. +- **Client Credentials flow** — for the client application and stress bot accounts. Each + machine client is registered as a Cognito app client with a `client_id` and + `client_secret`, stored in environment variables or a config file. Tokens are acquired + and refreshed automatically in the background; no user interaction occurs. Bot accounts + are provisioned and revoked via the Cognito API (scriptable, no manual console work). + +All services validate JWT bearer tokens from Cognito using the published JWKS endpoint. +Services receive the `sub` claim as the stable user identifier. No custom auth service or +user database is required. + +For local development, use a **dedicated Cognito dev user pool** rather than a local OIDC +stub. This avoids incomplete emulation and ensures auth behavior matches production exactly. +The dev user pool requires only AWS credentials and internet access — both already assumed +for LocalStack configuration. + +_Consequences:_ Client application and bot auth is non-interactive and config-file–driven, +matching the desired UX. Cognito handles all security-sensitive concerns (key storage, token +signing, revocation). Cognito is AWS-native, consistent with DynamoDB, SQS, and Lambda. +The dev user pool adds a small AWS dependency to local development but is free within +Cognito's free tier. + +### Auto-update layout on notification; defer application until user is idle + +_Context:_ When the backend publishes a new compiled layout, the client needs to update. +Applying immediately risks disrupting an active interaction; requiring a manual user action +adds friction. + +_Decision:_ Auto-update. When the client receives an SSE layout-changed event, it fetches +the new compiled layout. It defers applying it (swapping the active layout) until the user +is idle. The exact idle-detection policy is defined in the client-side consumption epic. + +_Consequences:_ End users always see the latest layout without manual intervention. The +deferral policy protects against jarring mid-interaction updates but is out of scope for +this epic. + +## Planned Implementation + +### Project naming convention + +| Project | Type | +|---------|------| +| `AdaptiveRemote.Contracts` | Shared class library (DTOs, enums) | +| `AdaptiveRemote.Backend.RawLayoutService` | .NET 10 Web API — ECS Fargate | +| `AdaptiveRemote.Backend.CompiledLayoutService` | .NET 10 Web API — ECS Fargate | +| `AdaptiveRemote.Backend.LayoutCompilerService` | .NET 10 Lambda function (Native AOT) | +| `AdaptiveRemote.Backend.LayoutValidationService` | .NET 10 Lambda function (Native AOT) | +| `AdaptiveRemote.Backend.LayoutProcessingService` | .NET 10 Web API — ECS Fargate | +| `AdaptiveRemote.Backend.NotificationService` | .NET 10 Web API — ECS Fargate (SSE) | + +Test projects follow the pattern `.Tests` under `test/`. + +### Shared Contracts (`AdaptiveRemote.Contracts`) + +```csharp +// Identifies the runtime command type. The client uses this to instantiate the correct +// App runtime type (TiVoCommand, IRCommand, LifecycleCommand, ActionCommand). +// Type-specific execution parameters are resolved by the client from its own configuration: +// TiVo — CommandId = Name.ToUpperInvariant() (existing convention) +// IR — payload programmed via remote, stored in ProgrammaticSettings +// Others — keyed by Name +// Subtypes with additional properties are deferred until a concrete need arises. +public enum CommandType { Lifecycle, TiVo, IR, Action } + +// Shared behavioral interface — prevents drift between the compiled and raw command types. +// Adding a new behavioral property means updating this interface first; the compiler +// will flag any implementing record that doesn't follow. +public interface ICommandProperties +{ + CommandType Type { get; } + string Name { get; } + string Label { get; } + string? Glyph { get; } + string SpeakPhrase { get; } + string? Reverse { get; } +} + +// --------------------------------------------------------------------------- +// Compiled layout element DTOs +// Used in CompiledLayout.Elements. Deserialized directly by the client application. +// Contains only behavioral properties — grid positions and CSS overrides have been +// compiled into CssDefinitions and are not needed by the client. +// --------------------------------------------------------------------------- + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(CommandDefinitionDto), "command")] +[JsonDerivedType(typeof(LayoutGroupDefinitionDto), "group")] +public abstract record LayoutElementDto(string CssId); + +// Maps to AdaptiveRemote.App.Models.Command at layout-apply time (client epic). +// Type carries the CommandType discriminator so the client knows which runtime type to instantiate. +// No subtype hierarchy is used — all behavioral properties are flat; type-specific execution +// parameters are resolved by the client from its own configuration (see CommandType above). +public record CommandDefinitionDto( + CommandType Type, + string Name, + string Label, + string? Glyph, + string SpeakPhrase, + string? Reverse, + string CssId +) : LayoutElementDto(CssId), ICommandProperties; + +// Maps to AdaptiveRemote.App.Models.LayoutGroup at layout-apply time (client epic). +public record LayoutGroupDefinitionDto( + string CssId, + IReadOnlyList Children +) : LayoutElementDto(CssId); + +// --------------------------------------------------------------------------- +// Raw layout element DTOs +// Shared between the editor application (serialization) and LayoutCompilerService +// (deserialization). Extends behavioral properties with authoring properties that +// the compiler resolves into CssDefinitions and strips from the compiled output. +// --------------------------------------------------------------------------- + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(RawCommandDefinitionDto), "command")] +[JsonDerivedType(typeof(RawLayoutGroupDefinitionDto), "group")] +public abstract record RawLayoutElementDto( + string CssId, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null // per-element CSS overrides (e.g. red background for Power) +); + +public record RawCommandDefinitionDto( + CommandType Type, + string Name, + string Label, + string? Glyph, + string SpeakPhrase, + string? Reverse, + string CssId, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null +) : RawLayoutElementDto(CssId, GridRow, GridColumn, GridRowSpan, GridColumnSpan, AdditionalCss), + ICommandProperties; + +public record RawLayoutGroupDefinitionDto( + string CssId, + IReadOnlyList Children, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null +) : RawLayoutElementDto(CssId, GridRow, GridColumn, GridRowSpan, GridColumnSpan, AdditionalCss); + +// --------------------------------------------------------------------------- +// Top-level layout records +// --------------------------------------------------------------------------- + +// Administrator-editable source format. Elements are typed; no opaque JSON string. +public record RawLayout( + Guid Id, + string UserId, + string Name, + IReadOnlyList Elements, + int Version, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + ValidationResult? ValidationResult // written by LayoutProcessingService via IRawLayoutStatusWriter +); + +// Client-consumable format produced by LayoutCompilerService. +// Deserialized directly by the client application — no intermediate parsing model needed. +// The client maps Elements → runtime Command objects at layout-apply time (client epic). +public record CompiledLayout( + Guid Id, + Guid RawLayoutId, + string UserId, + bool IsActive, + int Version, + IReadOnlyList Elements, + string CssDefinitions, // global CSS for the layout grid + DateTimeOffset CompiledAt +); + +// Editor-consumable preview format, produced by LayoutCompilerService. +public record PreviewLayout( + Guid RawLayoutId, + int Version, + string RenderedHtml, + string RenderedCss, + DateTimeOffset CompiledAt, + ValidationResult ValidationResult +); + +public record ValidationIssue(string Code, string Message, string? Path); +public record ValidationResult(bool IsValid, IReadOnlyList Issues); + +// Source-generated JSON context — required for Native AOT Lambda functions; +// shared by all consumers to ensure consistent serialization behaviour. +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(RawLayout))] +[JsonSerializable(typeof(CompiledLayout))] +[JsonSerializable(typeof(PreviewLayout))] +[JsonSerializable(typeof(ValidationResult))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +public partial class LayoutContractsJsonContext : JsonSerializerContext { } +``` + +### Interfaces + +```csharp +// RawLayoutService — CRUD for the editor; also implements IRawLayoutStatusWriter (below). +// Editor consumers depend on IRawLayoutRepository only. +// LayoutProcessingService depends on IRawLayoutStatusWriter only. +// RawLayoutService implements both; neither consumer gets more surface than it needs. +interface IRawLayoutRepository +{ + Task GetAsync(Guid id, CancellationToken ct); + Task> ListByUserAsync(string userId, CancellationToken ct); + Task SaveAsync(RawLayout layout, CancellationToken ct); + Task DeleteAsync(Guid id, CancellationToken ct); +} + +// Narrow write-back interface for LayoutProcessingService to record compilation results +// on a raw layout without requiring full CRUD access to RawLayoutService. +interface IRawLayoutStatusWriter +{ + Task UpdateValidationResultAsync(Guid rawLayoutId, ValidationResult result, CancellationToken ct); +} + +// RawLayoutService → LayoutProcessingService — SQS-backed; enqueues a message on layout save +interface ILayoutProcessingTrigger +{ + Task TriggerAsync(Guid rawLayoutId, CancellationToken ct); +} + +// LayoutProcessingService injects IRawLayoutRepository (shared with the editor) to fetch +// the raw layout by ID after dequeuing an SQS message. It also injects IRawLayoutStatusWriter +// (separate narrow interface) to write the ValidationResult back on completion. + +// LayoutCompilerService — stateless; called by LayoutProcessingService (CompileAsync) +// and by RawLayoutService (CompilePreviewAsync) for the live preview endpoint. +// CompilePreviewAsync takes only the elements — no stored RawLayout record needed. +interface ILayoutCompilerClient +{ + Task CompileAsync(RawLayout raw, CancellationToken ct); + Task CompilePreviewAsync(IReadOnlyList elements, CancellationToken ct); +} + +// LayoutValidationService — stateless; called by LayoutProcessingService +interface ILayoutValidationClient +{ + Task ValidateAsync(CompiledLayout compiled, CancellationToken ct); +} + +// CompiledLayoutService — storage and retrieval +// SetActiveAsync sets IsActive = true on the specified layout and clears it on all +// other layouts for the same user. +interface ICompiledLayoutRepository +{ + Task GetActiveForUserAsync(string userId, CancellationToken ct); + Task> ListByUserAsync(string userId, CancellationToken ct); + Task GetByIdAsync(Guid id, CancellationToken ct); + Task SaveAsync(CompiledLayout layout, CancellationToken ct); + Task SetActiveAsync(Guid id, string userId, CancellationToken ct); +} + +// NotificationService — called by RawLayoutService on save, and by LayoutProcessingService on publish +// SSE event types: +// layout-saved → editor subscribes; used to detect concurrent saves on the same layout +// layout-ready → client subscribes; triggers download of the new compiled layout +// Future: layout-error (compilation failed) can be added if polling for validation results proves insufficient +interface INotificationPublisher +{ + Task PublishLayoutSavedAsync(string userId, Guid rawLayoutId, CancellationToken ct); + Task PublishLayoutReadyAsync(string userId, Guid compiledLayoutId, CancellationToken ct); +} +``` + +### REST API surface + +**Endpoints consumed by the client application:** + +``` +GET /layouts/compiled → list compiled layouts for the authenticated user +GET /layouts/compiled/active → active compiled layout; 404 if none exists yet +GET /layouts/compiled/{id} → specific compiled layout by ID +PUT /layouts/compiled/{id}/active → set a compiled layout as active +GET /notifications/layouts/stream → SSE stream; emits layout-saved and layout-ready events +``` + +**Endpoints consumed by the editor application:** + +``` +GET /layouts/raw → list raw layouts for the authenticated user +GET /layouts/raw/{id} → fetch a specific raw layout by ID +POST /layouts/raw → create a new raw layout (triggers compilation) +PUT /layouts/raw/{id} → update a raw layout (triggers recompilation) +DELETE /layouts/raw/{id} → delete a raw layout +POST /layouts/raw/preview → compile a live preview from unsaved elements (no storage); + request body: IReadOnlyList; + returns PreviewLayout +``` + +**Internal endpoints (not exposed via API Gateway):** + +``` +POST /compile → LayoutCompilerService: compile a raw layout to CompiledLayout +POST /compile/preview → LayoutCompilerService: compile elements to PreviewLayout +POST /validate → LayoutValidationService: validate a compiled layout +``` + +### Data flow: publish a layout (administrator saves) + +1. Editor `POST`s or `PUT`s a raw layout → `RawLayoutService` stores it in DynamoDB +2. `RawLayoutService` calls `INotificationPublisher.PublishLayoutSavedAsync(userId, rawLayoutId)` + → `NotificationService` pushes `layout-saved` SSE event to any connected editor for that user + (concurrent-edit awareness — the saving editor and any others watching the same layout are notified) +3. `RawLayoutService` calls `ILayoutProcessingTrigger.TriggerAsync(rawLayoutId)` → SQS message enqueued; + returns `201 Created` to the editor +4. `LayoutProcessingService` dequeues the SQS message, fetches the raw layout from `RawLayoutService` +5. Calls `ILayoutCompilerClient.CompileAsync(raw)` → `LayoutCompilerService` returns compiled layout +6. Calls `ILayoutValidationClient.ValidateAsync(compiled)` → `LayoutValidationService` returns `ValidationResult` +7. If valid: stores compiled layout via `CompiledLayoutService`; if invalid: the failure is + recorded on the `RawLayout` record (`ValidationResult`) so the editor can display it on next fetch +8. If valid: calls `INotificationPublisher.PublishLayoutReadyAsync(userId, compiledId)` + → `NotificationService` pushes `layout-ready` SSE event to any connected client for that user + +### Data flow: client startup + +1. Client authenticates with Cognito using Client Credentials flow; receives JWT +2. Client `GET /layouts/compiled/active` → receives active compiled layout and caches it + locally; if `404`, client falls back to a bundled default layout until one is published +3. Client opens SSE connection: `GET /notifications/layouts/stream` +4. On SSE `layout-ready` event: client re-fetches `GET /layouts/compiled/active`, applies + when user is idle + +### Local development + +All services run locally via `docker-compose`. A **LocalStack** container provides local emulation of DynamoDB, SQS, and Lambda. A +**dedicated Cognito dev user pool** handles JWT issuance and validation — real Cognito is +used rather than a local stub to ensure auth behavior matches production exactly. AWS +credentials are required for local development (for both LocalStack and Cognito). All internal +service-to-service URLs are resolved via Docker DNS. The client application is configured +to point to the local backend via `appsettings.Development.json`. + +## Related Epics + +The following epics will each receive their own spec before implementation begins. + +| Epic | Scope | +|------|-------| +| **[ADR-161](https://jodasoft.atlassian.net/browse/ADR-161)** (this) | Backend services: storage, compilation, validation, processing, notifications | +| **[ADR-162](https://jodasoft.atlassian.net/browse/ADR-162)** | Client-side layout consumption: download, cache, apply, auto-update | +| **[ADR-163](https://jodasoft.atlassian.net/browse/ADR-163)** | Blazor WebAssembly editor: text editor + live preview | +| **[ADR-164](https://jodasoft.atlassian.net/browse/ADR-164)** | AWS CI/CD deployment pipeline: containerized deployment to AWS | +| **[ADR-165](https://jodasoft.atlassian.net/browse/ADR-165)** | Stress testing and availability: bot accounts, load scenarios, availability metrics | + +## Open Questions + +- [x] ~~Which external IdP will be used in production?~~ **Resolved:** AWS Cognito. + Authorization Code flow for editor users; Client Credentials flow for the client + application and bot accounts. Dev environment uses a dedicated Cognito dev user pool. +- [x] ~~Should layout compilation be synchronous or asynchronous?~~ **Resolved:** Async, by + virtue of using SQS. `RawLayoutService` returns `201 Created` immediately; compilation + result is delivered via SSE (`layout-ready` or `layout-error`). +- [ ] What validation rules does `LayoutValidationService` enforce? The structure is defined + by `RawLayoutElementDto` (nested hierarchy of `RawCommandDefinitionDto` and + `RawLayoutGroupDefinitionDto`; grid position and per-element CSS overrides included). + The specific constraints (e.g. valid grid ranges, required fields, CSS syntax) must be + defined before `LayoutValidationService` can be implemented. This is a dependency on the + editor epic. +- [x] ~~Can a user have multiple named layouts?~~ **Resolved:** Multiple layouts per user + are supported from the start; one is designated active. `CompiledLayout` carries `UserId` + and `IsActive`. A dedicated endpoint sets the active layout. Client-side support for + switching layouts (e.g. by input source) is deferred to a future client epic. + +## Related Docs + +- [`src/_doc_Projects.md`](_doc_Projects.md) +- [`src/AdaptiveRemote.App/Services/_doc_Services.md`](AdaptiveRemote.App/Services/_doc_Services.md) +- [`src/AdaptiveRemote.App/Services/Commands/_doc_Commands.md`](AdaptiveRemote.App/Services/Commands/_doc_Commands.md) +- [`src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md`](AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md) +- [`src/AdaptiveRemote.App/Services/ProgrammaticSettings/_doc_ProgrammaticSettings.md`](AdaptiveRemote.App/Services/ProgrammaticSettings/_doc_ProgrammaticSettings.md) + +## Tasks + +### Task 1 — Repo reorganization and shared contracts ([ADR-166](https://jodasoft.atlassian.net/browse/ADR-166)) + +Add solution filters and the `AdaptiveRemote.Contracts` shared library. + +- [ ] `client.slnf` and `backend.slnf` solution filters created; both build cleanly with `dotnet build /warnaserror` +- [ ] `AdaptiveRemote.Contracts` project created; targets `net10.0` (no `-windows`); no platform-specific dependencies +- [ ] All DTOs, enums, interfaces, and `LayoutContractsJsonContext` from the spec's Shared Contracts section are implemented +- [ ] `AdaptiveRemote.Contracts` is referenced by `AdaptiveRemote.App` and builds without warnings +- [ ] All existing client unit tests and headless E2E tests pass + +### Task 2 — Static layout MVP ([ADR-167](https://jodasoft.atlassian.net/browse/ADR-167)) + +Create `AdaptiveRemote.Backend.CompiledLayoutService` returning the current hardcoded layout. +Establish the backend API integration test infrastructure, the observability pattern (health +endpoints, structured logging, metrics), and the log validation pattern for API tests. All +subsequent backend services follow these patterns from the start. + +- [ ] `AdaptiveRemote.Backend.CompiledLayoutService` project created under `src/`; included in `backend.slnf` +- [ ] `GET /layouts/compiled/active` returns the current hardcoded layout serialized as `CompiledLayout` using `LayoutContractsJsonContext` +- [ ] No auth required for this task; endpoint is unauthenticated +- [ ] `GET /health` implemented; returns `200 OK` with service name and version; **this pattern is required for all subsequent backend services** +- [ ] Structured logging pattern established: log messages defined as `[LoggerMessage]` source-generated methods (same discipline as `MessageLogger.cs` in the client app); request/response logging middleware applied; **this pattern is required for all subsequent backend services** +- [ ] Metrics pattern established: key operations emit structured log events that serve as the local-dev metrics signal (e.g. request count, status code); CloudWatch as the production sink is deferred to the CI/CD deployment epic; **this pattern is required for all subsequent backend services** +- [ ] `docker-compose` configured so structured log output is visible for all running services in local dev +- [ ] Service runs in `docker-compose` and is reachable from the client app via `appsettings.Development.json` +- [ ] Backend API integration test project created (e.g. `AdaptiveRemote.Backend.ApiTests`); + includes an `HttpClient` fixture that spins up services via `docker-compose` and is + runnable against local dev, CI, and deployed environments; captures structured log output + from each service so Gherkin scenarios can assert on expected log events and the absence + of warnings or errors; pattern documented for reuse in subsequent tasks +- [ ] API integration tests cover `GET /layouts/compiled/active` and `GET /health`: + + ```gherkin + Given CompiledLayoutService is running + When a test client calls GET /layouts/compiled/active + Then the response is 200 OK + And the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext + And the CompiledLayout contains the expected hardcoded commands + And the service logs contain a request log entry for GET /layouts/compiled/active + And the service logs contain no warnings or errors + + Given CompiledLayoutService is running + When a test client calls GET /health + Then the response is 200 OK + And the body contains the service name and version + ``` + +- [ ] All existing headless E2E tests pass with the client reading from the service + +### Task 3 — Auth integration (Cognito) ([ADR-168](https://jodasoft.atlassian.net/browse/ADR-168)) + +Wire up JWT validation via AWS Cognito and API Gateway before any user-specific storage is built. Establishing auth at this stage surfaces Cognito unknowns (dev user pool setup, JWT issuance, JWKS validation) while the service count is still low, and ensures every subsequent task builds on a working auth layer from the start rather than retrofitting it across multiple services at once. + +- [ ] Cognito dev user pool created; JWKS endpoint configured in API Gateway +- [ ] API Gateway validates JWT bearer tokens on all external endpoints; unauthenticated requests return `401` +- [ ] `CompiledLayoutService` extracts the `sub` claim as `userId`; Task 2 API integration tests updated to include valid JWT headers +- [ ] Client app configured with `client_id` / `client_secret` via `appsettings.Development.json`; acquires and refreshes tokens automatically in the background +- [ ] Editor app auth flow (Authorization Code) documented with setup instructions for local dev +- [ ] Internal endpoints (Lambda Function URLs) are network-isolated and not exposed via API Gateway +- [ ] `GET /health` added to `CompiledLayoutService`; logging and metrics pattern from Task 2 verified under authenticated requests; API integration tests updated to assert no warnings or errors in service logs +- [ ] API integration tests cover authentication enforcement: + + ```gherkin + Given a request with no Authorization header + When a test client calls GET /layouts/compiled/active + Then the response is 401 Unauthorized + + Given a request with a valid Cognito JWT + When a test client calls GET /layouts/compiled/active + Then the response is 200 OK + + Given a request with an expired Cognito JWT + When a test client calls GET /layouts/compiled/active + Then the response is 401 Unauthorized + ``` + +### Task 4 — RawLayoutService + DynamoDB ([ADR-169](https://jodasoft.atlassian.net/browse/ADR-169)) + +Implement `AdaptiveRemote.Backend.RawLayoutService` with full CRUD backed by DynamoDB. + +- [ ] `AdaptiveRemote.Backend.RawLayoutService` project created; included in `backend.slnf` +- [ ] `IRawLayoutRepository` and `IRawLayoutStatusWriter` implemented against DynamoDB (LocalStack in dev) +- [ ] DynamoDB table created with partition key `UserId`, sort key `Id` (KSUID) +- [ ] All CRUD endpoints (`GET /layouts/raw`, `GET /layouts/raw/{id}`, `POST /layouts/raw`, `PUT /layouts/raw/{id}`, `DELETE /layouts/raw/{id}`) implemented and unit tested +- [ ] `docker-compose.yml` updated with LocalStack container; DynamoDB table provisioned on startup +- [ ] `ILayoutProcessingTrigger` stub (no-op) injected so save/update endpoints compile; SQS wiring deferred to Task 5 +- [ ] `INotificationPublisher` stub (no-op) injected; notification wiring deferred to Task 9 +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; API integration tests assert no warnings or errors in service logs during normal CRUD operations +- [ ] Unit tests cover repository logic against LocalStack or mocked DynamoDB client +- [ ] API integration tests cover all CRUD endpoints: + + ```gherkin + Given an authenticated user has no raw layouts + When a test client calls GET /layouts/raw + Then the response is 200 OK + And the body is an empty array + + Given an authenticated user + When a test client calls POST /layouts/raw with a valid RawLayout body + Then the response is 201 Created + And the body contains the created RawLayout with a generated Id + And GET /layouts/raw/{id} returns the same layout + + Given a raw layout exists with id {id} + When a test client calls PUT /layouts/raw/{id} with updated elements + Then the response is 200 OK + And GET /layouts/raw/{id} returns the updated elements + + Given a raw layout exists with id {id} + When a test client calls DELETE /layouts/raw/{id} + Then the response is 204 No Content + And GET /layouts/raw/{id} returns 404 Not Found + ``` + +### Task 5 — LayoutProcessingService (with stubs) ([ADR-170](https://jodasoft.atlassian.net/browse/ADR-170)) + +Implement `AdaptiveRemote.Backend.LayoutProcessingService` with SQS polling and the full +orchestration pipeline. `ILayoutCompilerClient` and `ILayoutValidationClient` are backed by +stub implementations that return hardcoded valid results, keeping the pipeline testable +end-to-end before the real Lambda functions are built in Tasks 6 and 7. + +- [ ] `AdaptiveRemote.Backend.LayoutProcessingService` project created; included in `backend.slnf` +- [ ] SQS queue and DLQ provisioned in `docker-compose` via LocalStack; max receive count = 3; DLQ retention = 14 days +- [ ] `ILayoutCompilerClient` stub returns a hardcoded `CompiledLayout` derived from the input `RawLayout` elements (names and labels passed through; no real CSS generation) +- [ ] `ILayoutValidationClient` stub returns `ValidationResult { IsValid = true, Issues = [] }` +- [ ] Service polls SQS queue and processes messages: fetch raw layout → compile → validate → store compiled → notify +- [ ] On validation failure: calls `IRawLayoutStatusWriter.UpdateValidationResultAsync`; does not store a compiled layout; does not notify client +- [ ] On success: calls `ICompiledLayoutRepository.SaveAsync` then `INotificationPublisher.PublishLayoutReadyAsync` +- [ ] Failed processing attempts are logged as errors; DLQ arrival is logged as an error +- [ ] `RawLayoutService` SQS trigger wired up (replaces no-op stub from Task 4) +- [ ] `INotificationPublisher` stub (no-op) injected; notification wiring deferred to Task 9 +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; structured log events emitted on each SQS message processed (success and failure); API integration tests assert expected log events and no unexpected warnings or errors +- [ ] Unit tests cover success path, validation failure path, and SQS message retry behaviour +- [ ] API integration tests cover the end-to-end processing pipeline (stub compiler and validator in use): + + ```gherkin + Given a raw layout with valid elements has been saved via POST /layouts/raw + When LayoutProcessingService dequeues and processes the SQS message + Then GET /layouts/compiled/active returns a CompiledLayout for the user + And the CompiledLayout.Elements match the commands from the raw layout + + Given a raw layout with a command missing a Label has been saved via POST /layouts/raw + When LayoutProcessingService dequeues and processes the SQS message + Then no compiled layout is stored for the user + And GET /layouts/raw/{id} returns a RawLayout with a non-null ValidationResult + And ValidationResult.IsValid is false + ``` + +### Task 6 — LayoutCompilerService (Lambda) ([ADR-171](https://jodasoft.atlassian.net/browse/ADR-171)) + +Implement `AdaptiveRemote.Backend.LayoutCompilerService` as a Native AOT Lambda, replacing +the stub injected in Task 5. + +- [ ] `AdaptiveRemote.Backend.LayoutCompilerService` project created as a .NET 10 Lambda function with Native AOT; included in `backend.slnf` +- [ ] `POST /compile` accepts `RawLayout`, returns `CompiledLayout`; grid positions and CSS overrides resolved into `CssDefinitions`; layout elements stripped of authoring properties +- [ ] `POST /compile/preview` accepts `IReadOnlyList`, returns `PreviewLayout` with rendered HTML and CSS +- [ ] All serialization uses `LayoutContractsJsonContext`; no reflection-based JSON +- [ ] Lambda runs locally via LocalStack; `LayoutProcessingService` `ILayoutCompilerClient` stub replaced with real Lambda-backed implementation +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; Lambda invocation events logged; API integration tests assert no warnings or errors during successful compilation +- [ ] Unit tests cover compilation logic for representative layout inputs +- [ ] API integration tests cover both endpoints (called directly via Lambda Function URL): + + ```gherkin + Given a valid RawLayout with one command element at grid position (1, 1) + When a test client calls POST /compile with the RawLayout + Then the response is 200 OK + And the body deserializes to a CompiledLayout + And CompiledLayout.Elements contains a CommandDefinitionDto matching the input command + And CompiledLayout.CssDefinitions contains a CSS rule for the element's grid position + And the CommandDefinitionDto does not contain grid or CSS authoring properties + + Given a valid list of RawLayoutElementDto + When a test client calls POST /compile/preview with the elements + Then the response is 200 OK + And the body deserializes to a PreviewLayout + And PreviewLayout.RenderedHtml is non-empty + And PreviewLayout.RenderedCss is non-empty + ``` + +### Task 7 — LayoutValidationService (Lambda) ([ADR-172](https://jodasoft.atlassian.net/browse/ADR-172)) + +Implement `AdaptiveRemote.Backend.LayoutValidationService` as a Native AOT Lambda, replacing +the stub injected in Task 5. + +- [ ] `AdaptiveRemote.Backend.LayoutValidationService` project created as a .NET 10 Lambda function with Native AOT; included in `backend.slnf` +- [ ] `POST /validate` accepts `CompiledLayout`, returns `ValidationResult` +- [ ] Validates that all `CommandDefinitionDto` entries have non-empty `Name`, `Label`, and `SpeakPhrase`; duplicate `CssId` values within a layout are flagged +- [ ] Additional validation rules deferred pending editor epic (see Open Questions) +- [ ] All serialization uses `LayoutContractsJsonContext`; no reflection-based JSON +- [ ] `LayoutProcessingService` `ILayoutValidationClient` stub replaced with real Lambda-backed implementation +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; validation outcome (pass/fail, issue count) emitted as a structured log event; API integration tests assert no unexpected warnings or errors +- [ ] Unit tests cover valid layout, missing required fields, and duplicate CSS IDs +- [ ] API integration tests cover both valid and invalid cases (called directly via Lambda Function URL): + + ```gherkin + Given a CompiledLayout where all commands have non-empty Name, Label, and SpeakPhrase + And all CssId values are unique + When a test client calls POST /validate with the CompiledLayout + Then the response is 200 OK + And ValidationResult.IsValid is true + And ValidationResult.Issues is empty + + Given a CompiledLayout where one command has an empty Label + When a test client calls POST /validate with the CompiledLayout + Then the response is 200 OK + And ValidationResult.IsValid is false + And ValidationResult.Issues contains one issue referencing the empty Label + + Given a CompiledLayout where two elements share the same CssId + When a test client calls POST /validate with the CompiledLayout + Then the response is 200 OK + And ValidationResult.IsValid is false + And ValidationResult.Issues contains one issue referencing the duplicate CssId + ``` + +### Task 8 — CompiledLayoutService with DynamoDB ([ADR-173](https://jodasoft.atlassian.net/browse/ADR-173)) + +Replace the static hardcoded response in `CompiledLayoutService` with real DynamoDB storage and active layout management. + +- [ ] `ICompiledLayoutRepository` implemented against DynamoDB +- [ ] `GetActiveForUserAsync`, `ListByUserAsync`, `GetByIdAsync`, `SaveAsync`, and `SetActiveAsync` all implemented and unit tested +- [ ] `SetActiveAsync` sets `IsActive = true` on the specified layout and clears it on all other layouts for the same user (via DynamoDB transaction or conditional writes) +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; API integration tests assert no warnings or errors during normal storage operations +- [ ] All compiled layout endpoints functional end-to-end with DynamoDB +- [ ] `PUT /layouts/compiled/{id}/active` endpoint implemented +- [ ] Previously hardcoded layout seeded into DynamoDB on first run so the client continues to work +- [ ] API integration tests cover the 404 case and active layout switching: + + ```gherkin + Given no compiled layout exists for the user + When a test client calls GET /layouts/compiled/active + Then the response is 404 Not Found + + Given a user has two compiled layouts and layout B is active + When a test client calls PUT /layouts/compiled/{A}/active + Then the response is 200 OK + And GET /layouts/compiled/active returns layout A + And layout B is no longer active + ``` + +### Task 9 — NotificationService (SSE) ([ADR-174](https://jodasoft.atlassian.net/browse/ADR-174)) + +Implement `AdaptiveRemote.Backend.NotificationService` with SSE push for `layout-saved` and `layout-ready` events. + +- [ ] `AdaptiveRemote.Backend.NotificationService` project created; included in `backend.slnf` +- [ ] `GET /notifications/layouts/stream` SSE endpoint implemented; connection is keyed to the authenticated user +- [ ] `INotificationPublisher` implementation sends `layout-saved` events to connected editors and `layout-ready` events to connected clients for the relevant user +- [ ] Standard SSE retry mechanism honoured; disconnected clients reconnect automatically +- [ ] `RawLayoutService` and `LayoutProcessingService` notification stubs replaced with real `INotificationPublisher` implementation +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; SSE connection lifecycle events (connect, disconnect, reconnect) emitted as structured log events +- [ ] Unit tests cover event publishing and per-user fan-out + + ```gherkin + Given a client is connected to the SSE stream + And the administrator publishes a new compiled layout + When LayoutProcessingService completes successfully + Then the client receives a layout-ready SSE event + And fetching GET /layouts/compiled/active returns the new layout + + Given two editor sessions are open for the same layout + When one editor saves the layout + Then both editors receive a layout-saved SSE event + ``` + +--- + +### [ADR-162](https://jodasoft.atlassian.net/browse/ADR-162): Client-side layout consumption + +Implement layout download, local caching, compiled layout application, and auto-update on `layout-ready` SSE event in the client app. Includes the mapping from `CommandDefinitionDto` → runtime `Command` types and the idle-detection policy for deferred layout application. + +### [ADR-163](https://jodasoft.atlassian.net/browse/ADR-163): Blazor WebAssembly editor + +Implement the administrator-facing editor application: text editor for raw layout JSON, live preview via `POST /layouts/raw/preview`, and layout management (create, update, delete, set active). + +### [ADR-164](https://jodasoft.atlassian.net/browse/ADR-164): AWS CI/CD deployment pipeline + +Containerize all ECS Fargate services; package Lambda functions; define infrastructure as code (ECS task definitions, API Gateway configuration, DynamoDB tables, SQS queues); automate deployment to AWS on merge to main. Includes wiring the CloudWatch metrics sink (replacing the local structured-log-based signal established in Task 2), CloudWatch alarms (DLQ depth > 0, error rate thresholds), and ECS health check integration. + +### [ADR-165](https://jodasoft.atlassian.net/browse/ADR-165): Stress testing and availability + +Define bot account provisioning via Cognito API; implement load generation scenarios; instrument availability and latency metrics; establish baseline SLOs. From 7f849600453ef71c68c868df5b21316e5a0a2545 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Tue, 7 Apr 2026 16:53:21 -0700 Subject: [PATCH 02/11] Add AdaptiveRemote.Contracts shared library for layout DTOs (#137) * ADR-166: Add AdaptiveRemote.Contracts shared library and solution filters - Add AdaptiveRemote.Contracts project (net10.0, no platform-specific dependencies) containing all layout definition DTOs, enums, and LayoutContractsJsonContext from the spec's Shared Contracts section: CommandType enum, ICommandProperties interface, LayoutElementDto hierarchy (compiled), RawLayoutElementDto hierarchy (raw), RawLayout/CompiledLayout/ PreviewLayout top-level records, ValidationIssue/ValidationResult records, and source-generated LayoutContractsJsonContext for consistent serialization across all consumers including Native AOT Lambda functions. - Add AdaptiveRemote.Contracts to AdaptiveRemote.sln with full build configurations. - Reference AdaptiveRemote.Contracts from AdaptiveRemote.App. - Add client.slnf and backend.slnf solution filters. - Update _doc_Projects.md to document the new shared contracts project. https://claude.ai/code/session_01T3tonn7C9F6TYbqH23KmG1 * Address PR review comments - Remove Action from CommandType enum (it's a WPF adapter, not a command type) - Change type discriminator from "type" to "\$type" on both LayoutElementDto and RawLayoutElementDto to avoid conflict with the behavioral Type property on CommandDefinitionDto and RawCommandDefinitionDto - Move RawLayout into RawLayoutElementDto.cs, CompiledLayout into LayoutElementDto.cs, PreviewLayout into its own PreviewLayout.cs; delete Layouts.cs https://claude.ai/code/session_01T3tonn7C9F6TYbqH23KmG1 --------- Co-authored-by: Claude --- AdaptiveRemote.sln | 14 ++++ backend.slnf | 8 +++ client.slnf | 19 ++++++ .../AdaptiveRemote.App.csproj | 4 ++ .../AdaptiveRemote.Contracts.csproj | 10 +++ src/AdaptiveRemote.Contracts/CommandType.cs | 9 +++ .../ICommandProperties.cs | 14 ++++ .../LayoutContractsJsonContext.cs | 14 ++++ .../LayoutElementDto.cs | 53 +++++++++++++++ src/AdaptiveRemote.Contracts/PreviewLayout.cs | 11 ++++ .../RawLayoutElementDto.cs | 64 +++++++++++++++++++ .../ValidationResult.cs | 5 ++ src/_doc_Projects.md | 8 +++ 13 files changed, 233 insertions(+) create mode 100644 backend.slnf create mode 100644 client.slnf create mode 100644 src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj create mode 100644 src/AdaptiveRemote.Contracts/CommandType.cs create mode 100644 src/AdaptiveRemote.Contracts/ICommandProperties.cs create mode 100644 src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs create mode 100644 src/AdaptiveRemote.Contracts/LayoutElementDto.cs create mode 100644 src/AdaptiveRemote.Contracts/PreviewLayout.cs create mode 100644 src/AdaptiveRemote.Contracts/RawLayoutElementDto.cs create mode 100644 src/AdaptiveRemote.Contracts/ValidationResult.cs diff --git a/AdaptiveRemote.sln b/AdaptiveRemote.sln index a63a24a4..c2ff52d3 100644 --- a/AdaptiveRemote.sln +++ b/AdaptiveRemote.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 18.0.11217.181 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.App", "src\AdaptiveRemote.App\AdaptiveRemote.App.csproj", "{6C7C380B-D7A4-412E-8487-2AFC89EA802F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Contracts", "src\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj", "{F81FEF3B-DB7A-4C04-9DF0-72E98382097A}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdaptiveRemote", "src\AdaptiveRemote\AdaptiveRemote.csproj", "{7BE31162-0D09-4F80-8CE5-978F7AECC1EF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Console", "src\AdaptiveRemote.Console\AdaptiveRemote.Console.csproj", "{345B73FC-07F9-490F-B566-2677D10B1834}" @@ -188,6 +190,18 @@ Global {54522D5A-CEB3-F5B9-2654-1005EF1C3262}.Release|x64.Build.0 = Release|Any CPU {54522D5A-CEB3-F5B9-2654-1005EF1C3262}.Release|x86.ActiveCfg = Release|Any CPU {54522D5A-CEB3-F5B9-2654-1005EF1C3262}.Release|x86.Build.0 = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x64.ActiveCfg = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x64.Build.0 = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x86.ActiveCfg = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x86.Build.0 = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|Any CPU.Build.0 = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x64.ActiveCfg = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x64.Build.0 = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x86.ActiveCfg = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/backend.slnf b/backend.slnf new file mode 100644 index 00000000..bda5c53b --- /dev/null +++ b/backend.slnf @@ -0,0 +1,8 @@ +{ + "solution": { + "path": "AdaptiveRemote.sln", + "projects": [ + "src\\AdaptiveRemote.Contracts\\AdaptiveRemote.Contracts.csproj" + ] + } +} diff --git a/client.slnf b/client.slnf new file mode 100644 index 00000000..1991596c --- /dev/null +++ b/client.slnf @@ -0,0 +1,19 @@ +{ + "solution": { + "path": "AdaptiveRemote.sln", + "projects": [ + "src\\AdaptiveRemote.Contracts\\AdaptiveRemote.Contracts.csproj", + "src\\AdaptiveRemote.App\\AdaptiveRemote.App.csproj", + "src\\AdaptiveRemote\\AdaptiveRemote.csproj", + "src\\AdaptiveRemote.Console\\AdaptiveRemote.Console.csproj", + "src\\AdaptiveRemote.Headless\\AdaptiveRemote.Headless.csproj", + "test\\AdaptiveRemote.App.Tests\\AdaptiveRemote.App.Tests.csproj", + "test\\AdaptiveRemote.Speech.Tests\\AdaptiveRemote.Speech.Tests.csproj", + "test\\AdaptiveRemote.EndtoEndTests.TestServices\\AdaptiveRemote.EndtoEndTests.TestServices.csproj", + "test\\AdaptiveRemote.EndToEndTests.Steps\\AdaptiveRemote.EndToEndTests.Steps.csproj", + "test\\AdaptiveRemote.EndToEndTests.Host.Headless\\AdaptiveRemote.EndToEndTests.Host.Headless.csproj", + "test\\AdaptiveRemote.EndToEndTests.Host.Wpf\\AdaptiveRemote.EndToEndTests.Host.Wpf.csproj", + "test\\AdaptiveRemote.EndtoEndTests.Host.Console\\AdaptiveRemote.EndToEndTests.Host.Console.csproj" + ] + } +} diff --git a/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj b/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj index 396f43ff..e104217b 100644 --- a/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj +++ b/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj @@ -12,6 +12,10 @@ + + + + diff --git a/src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj b/src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj new file mode 100644 index 00000000..d804afe1 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + enable + AdaptiveRemote.Contracts + + + diff --git a/src/AdaptiveRemote.Contracts/CommandType.cs b/src/AdaptiveRemote.Contracts/CommandType.cs new file mode 100644 index 00000000..5ea8a4f9 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/CommandType.cs @@ -0,0 +1,9 @@ +namespace AdaptiveRemote.Contracts; + +// Identifies the runtime command type. The client uses this to instantiate the correct +// App runtime type (TiVoCommand, IRCommand, LifecycleCommand). +// Type-specific execution parameters are resolved by the client from its own configuration: +// TiVo — CommandId = Name.ToUpperInvariant() (existing convention) +// IR — payload programmed via remote, stored in ProgrammaticSettings +// Others — keyed by Name +public enum CommandType { Lifecycle, TiVo, IR } diff --git a/src/AdaptiveRemote.Contracts/ICommandProperties.cs b/src/AdaptiveRemote.Contracts/ICommandProperties.cs new file mode 100644 index 00000000..51d7f273 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/ICommandProperties.cs @@ -0,0 +1,14 @@ +namespace AdaptiveRemote.Contracts; + +// Shared behavioral interface — prevents drift between the compiled and raw command types. +// Adding a new behavioral property means updating this interface first; the compiler +// will flag any implementing record that doesn't follow. +public interface ICommandProperties +{ + CommandType Type { get; } + string Name { get; } + string Label { get; } + string? Glyph { get; } + string SpeakPhrase { get; } + string? Reverse { get; } +} diff --git a/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs b/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs new file mode 100644 index 00000000..13684759 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace AdaptiveRemote.Contracts; + +// Source-generated JSON context — required for Native AOT Lambda functions; +// shared by all consumers to ensure consistent serialization behaviour. +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(RawLayout))] +[JsonSerializable(typeof(CompiledLayout))] +[JsonSerializable(typeof(PreviewLayout))] +[JsonSerializable(typeof(ValidationResult))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +public partial class LayoutContractsJsonContext : JsonSerializerContext { } diff --git a/src/AdaptiveRemote.Contracts/LayoutElementDto.cs b/src/AdaptiveRemote.Contracts/LayoutElementDto.cs new file mode 100644 index 00000000..92cf7d51 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/LayoutElementDto.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; + +namespace AdaptiveRemote.Contracts; + +// --------------------------------------------------------------------------- +// Compiled layout element DTOs +// Used in CompiledLayout.Elements. Deserialized directly by the client application. +// Contains only behavioral properties — grid positions and CSS overrides have been +// compiled into CssDefinitions and are not needed by the client. +// --------------------------------------------------------------------------- + +// "$type" avoids conflict with the behavioral Type property on CommandDefinitionDto. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(CommandDefinitionDto), "command")] +[JsonDerivedType(typeof(LayoutGroupDefinitionDto), "group")] +public abstract record LayoutElementDto(string CssId); + +// Maps to AdaptiveRemote.App.Models.Command at layout-apply time (client epic). +// Type carries the CommandType discriminator so the client knows which runtime type to instantiate. +// No subtype hierarchy is used — all behavioral properties are flat; type-specific execution +// parameters are resolved by the client from its own configuration (see CommandType above). +public record CommandDefinitionDto( + CommandType Type, + string Name, + string Label, + string? Glyph, + string SpeakPhrase, + string? Reverse, + string CssId +) : LayoutElementDto(CssId), ICommandProperties; + +// Maps to AdaptiveRemote.App.Models.LayoutGroup at layout-apply time (client epic). +public record LayoutGroupDefinitionDto( + string CssId, + IReadOnlyList Children +) : LayoutElementDto(CssId); + +// --------------------------------------------------------------------------- +// Client-consumable format produced by LayoutCompilerService. +// Deserialized directly by the client application — no intermediate parsing model needed. +// The client maps Elements → runtime Command objects at layout-apply time (client epic). +// --------------------------------------------------------------------------- + +public record CompiledLayout( + Guid Id, + Guid RawLayoutId, + string UserId, + bool IsActive, + int Version, + IReadOnlyList Elements, + string CssDefinitions, // global CSS for the layout grid + DateTimeOffset CompiledAt +); diff --git a/src/AdaptiveRemote.Contracts/PreviewLayout.cs b/src/AdaptiveRemote.Contracts/PreviewLayout.cs new file mode 100644 index 00000000..2e968d29 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/PreviewLayout.cs @@ -0,0 +1,11 @@ +namespace AdaptiveRemote.Contracts; + +// Editor-consumable preview format, produced by LayoutCompilerService. +public record PreviewLayout( + Guid RawLayoutId, + int Version, + string RenderedHtml, + string RenderedCss, + DateTimeOffset CompiledAt, + ValidationResult ValidationResult +); diff --git a/src/AdaptiveRemote.Contracts/RawLayoutElementDto.cs b/src/AdaptiveRemote.Contracts/RawLayoutElementDto.cs new file mode 100644 index 00000000..3bf30c8e --- /dev/null +++ b/src/AdaptiveRemote.Contracts/RawLayoutElementDto.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; + +namespace AdaptiveRemote.Contracts; + +// --------------------------------------------------------------------------- +// Raw layout element DTOs +// Shared between the editor application (serialization) and LayoutCompilerService +// (deserialization). Extends behavioral properties with authoring properties that +// the compiler resolves into CssDefinitions and strips from the compiled output. +// --------------------------------------------------------------------------- + +// "$type" avoids conflict with the behavioral Type property on RawCommandDefinitionDto. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(RawCommandDefinitionDto), "command")] +[JsonDerivedType(typeof(RawLayoutGroupDefinitionDto), "group")] +public abstract record RawLayoutElementDto( + string CssId, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null // per-element CSS overrides (e.g. red background for Power) +); + +public record RawCommandDefinitionDto( + CommandType Type, + string Name, + string Label, + string? Glyph, + string SpeakPhrase, + string? Reverse, + string CssId, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null +) : RawLayoutElementDto(CssId, GridRow, GridColumn, GridRowSpan, GridColumnSpan, AdditionalCss), + ICommandProperties; + +public record RawLayoutGroupDefinitionDto( + string CssId, + IReadOnlyList Children, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null +) : RawLayoutElementDto(CssId, GridRow, GridColumn, GridRowSpan, GridColumnSpan, AdditionalCss); + +// --------------------------------------------------------------------------- +// Administrator-editable source format. Elements are typed; no opaque JSON string. +// --------------------------------------------------------------------------- + +public record RawLayout( + Guid Id, + string UserId, + string Name, + IReadOnlyList Elements, + int Version, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + ValidationResult? ValidationResult // written by LayoutProcessingService via IRawLayoutStatusWriter +); diff --git a/src/AdaptiveRemote.Contracts/ValidationResult.cs b/src/AdaptiveRemote.Contracts/ValidationResult.cs new file mode 100644 index 00000000..81c3e7b5 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/ValidationResult.cs @@ -0,0 +1,5 @@ +namespace AdaptiveRemote.Contracts; + +public record ValidationIssue(string Code, string Message, string? Path); + +public record ValidationResult(bool IsValid, IReadOnlyList Issues); diff --git a/src/_doc_Projects.md b/src/_doc_Projects.md index b786872f..e81f19de 100644 --- a/src/_doc_Projects.md +++ b/src/_doc_Projects.md @@ -33,6 +33,14 @@ This document describes the high-level organization of the AdaptiveRemote reposi - Minimal code to launch the WPF app with console logging. - No business logic or features. +### AdaptiveRemote.Contracts +- **Purpose:** Shared class library containing layout definition DTOs, enums, interfaces, and the source-generated `LayoutContractsJsonContext` used by both the client application and backend services. +- **Guidance:** _No platform-specific dependencies._ Targets `net10.0` only. Contains pure data types (records, enums, interfaces) with no behavior. +- **Boundaries:** + - No WPF, Windows APIs, or Blazor dependencies. + - No MVVM or runtime behavior — DTOs only. + - Included in both `client.slnf` and `backend.slnf`. + ## Test Projects ### AdaptiveRemote.App.Tests From e8f2d070f934748532f9964ab9ef7d30dccb5ad6 Mon Sep 17 00:00:00 2001 From: Claude <242468646+Claude@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:39:46 -0700 Subject: [PATCH 03/11] Addressing PR comments (#139) * ADR-167: Create CompiledLayoutService with health and layout endpoints * ADR-167: Add docker-compose and API integration tests infrastructure * ADR-167: Fix API test path resolution and assertions - all API tests passing * ADR-167: Address code review comments - use repository pattern, nameof, error logging, proper test assertions Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- AdaptiveRemote.sln | 58 ++++-- backend.slnf | 4 +- docker-compose.yml | 18 ++ ...emote.Backend.CompiledLayoutService.csproj | 14 ++ .../Dockerfile | 24 +++ .../Endpoints/HealthEndpoints.cs | 34 ++++ .../Endpoints/LayoutEndpoints.cs | 38 ++++ .../Logging/MessageLogger.cs | 36 ++++ .../Program.cs | 29 +++ .../Services/HardcodedLayoutProvider.cs | 89 +++++++++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../HealthResponse.cs | 10 + .../ICompiledLayoutRepository.cs | 12 ++ .../LayoutContractsJsonContext.cs | 1 + .../AdaptiveRemote.Backend.ApiTests.csproj | 27 +++ .../Features/CompiledLayoutEndpoints.feature | 10 + .../CompiledLayoutEndpoints.feature.cs | 171 ++++++++++++++++++ .../Features/HealthEndpoints.feature | 7 + .../Features/HealthEndpoints.feature.cs | 162 +++++++++++++++++ .../StepDefinitions/CommonSteps.cs | 128 +++++++++++++ .../Support/ServiceFixture.cs | 142 +++++++++++++++ 22 files changed, 1018 insertions(+), 13 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Dockerfile create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json create mode 100644 src/AdaptiveRemote.Contracts/HealthResponse.cs create mode 100644 src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs diff --git a/AdaptiveRemote.sln b/AdaptiveRemote.sln index c2ff52d3..61c872f4 100644 --- a/AdaptiveRemote.sln +++ b/AdaptiveRemote.sln @@ -48,6 +48,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.EndToEndTest EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.EndToEndTests.Host.Wpf", "test\AdaptiveRemote.EndToEndTests.Host.Wpf\AdaptiveRemote.EndToEndTests.Host.Wpf.csproj", "{54522D5A-CEB3-F5B9-2654-1005EF1C3262}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.CompiledLayoutService", "src\AdaptiveRemote.Backend.CompiledLayoutService\AdaptiveRemote.Backend.CompiledLayoutService.csproj", "{ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.ApiTests", "test\AdaptiveRemote.Backend.ApiTests\AdaptiveRemote.Backend.ApiTests.csproj", "{E581823B-8EA9-4C54-A05E-859632CE1B78}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -70,6 +78,18 @@ Global {6C7C380B-D7A4-412E-8487-2AFC89EA802F}.Release|x64.Build.0 = Release|Any CPU {6C7C380B-D7A4-412E-8487-2AFC89EA802F}.Release|x86.ActiveCfg = Release|Any CPU {6C7C380B-D7A4-412E-8487-2AFC89EA802F}.Release|x86.Build.0 = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x64.ActiveCfg = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x64.Build.0 = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x86.ActiveCfg = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x86.Build.0 = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|Any CPU.Build.0 = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x64.ActiveCfg = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x64.Build.0 = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x86.ActiveCfg = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x86.Build.0 = Release|Any CPU {7BE31162-0D09-4F80-8CE5-978F7AECC1EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7BE31162-0D09-4F80-8CE5-978F7AECC1EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {7BE31162-0D09-4F80-8CE5-978F7AECC1EF}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -190,18 +210,30 @@ Global {54522D5A-CEB3-F5B9-2654-1005EF1C3262}.Release|x64.Build.0 = Release|Any CPU {54522D5A-CEB3-F5B9-2654-1005EF1C3262}.Release|x86.ActiveCfg = Release|Any CPU {54522D5A-CEB3-F5B9-2654-1005EF1C3262}.Release|x86.Build.0 = Release|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x64.ActiveCfg = Debug|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x64.Build.0 = Debug|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x86.ActiveCfg = Debug|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x86.Build.0 = Debug|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|Any CPU.Build.0 = Release|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x64.ActiveCfg = Release|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x64.Build.0 = Release|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x86.ActiveCfg = Release|Any CPU - {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x86.Build.0 = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|x64.ActiveCfg = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|x64.Build.0 = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|x86.ActiveCfg = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|x86.Build.0 = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|Any CPU.Build.0 = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|x64.ActiveCfg = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|x64.Build.0 = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|x86.ActiveCfg = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|x86.Build.0 = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|x64.ActiveCfg = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|x64.Build.0 = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|x86.ActiveCfg = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|x86.Build.0 = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|Any CPU.Build.0 = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x64.ActiveCfg = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x64.Build.0 = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x86.ActiveCfg = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -216,6 +248,8 @@ Global {72062D2E-6FDF-42F8-8360-98130E2A9861} = {CC3DAD92-6D91-40F5-B57A-C5620CF4F1C7} {F631ED02-DB0B-4CE4-8462-89BA239AFB3A} = {CC3DAD92-6D91-40F5-B57A-C5620CF4F1C7} {54522D5A-CEB3-F5B9-2654-1005EF1C3262} = {CC3DAD92-6D91-40F5-B57A-C5620CF4F1C7} + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E581823B-8EA9-4C54-A05E-859632CE1B78} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {556A11E4-2F89-4600-9831-8162F067EC3E} diff --git a/backend.slnf b/backend.slnf index bda5c53b..db1add99 100644 --- a/backend.slnf +++ b/backend.slnf @@ -2,7 +2,9 @@ "solution": { "path": "AdaptiveRemote.sln", "projects": [ - "src\\AdaptiveRemote.Contracts\\AdaptiveRemote.Contracts.csproj" + "src\\AdaptiveRemote.Contracts\\AdaptiveRemote.Contracts.csproj", + "src\\AdaptiveRemote.Backend.CompiledLayoutService\\AdaptiveRemote.Backend.CompiledLayoutService.csproj", + "test\\AdaptiveRemote.Backend.ApiTests\\AdaptiveRemote.Backend.ApiTests.csproj" ] } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..8060fb6e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + compiledlayoutservice: + build: + context: . + dockerfile: src/AdaptiveRemote.Backend.CompiledLayoutService/Dockerfile + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + networks: + - backend + +networks: + backend: + driver: bridge diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj new file mode 100644 index 00000000..b83f9e8f --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + AdaptiveRemote.Backend.CompiledLayoutService + + + + + + + diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Dockerfile b/src/AdaptiveRemote.Backend.CompiledLayoutService/Dockerfile new file mode 100644 index 00000000..9c700ab7 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Dockerfile @@ -0,0 +1,24 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy csproj files and restore +COPY ["src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj", "AdaptiveRemote.Contracts/"] +COPY ["src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj", "AdaptiveRemote.Backend.CompiledLayoutService/"] +COPY ["Directory.Build.props", "./"] +COPY ["Directory.Packages.props", "./"] +RUN dotnet restore "AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj" + +# Copy source and build +COPY ["src/AdaptiveRemote.Contracts/", "AdaptiveRemote.Contracts/"] +COPY ["src/AdaptiveRemote.Backend.CompiledLayoutService/", "AdaptiveRemote.Backend.CompiledLayoutService/"] +WORKDIR "/src/AdaptiveRemote.Backend.CompiledLayoutService" +RUN dotnet build "AdaptiveRemote.Backend.CompiledLayoutService.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "AdaptiveRemote.Backend.CompiledLayoutService.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=publish /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "AdaptiveRemote.Backend.CompiledLayoutService.dll"] diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs new file mode 100644 index 00000000..7a69c4a7 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using AdaptiveRemote.Backend.CompiledLayoutService.Logging; +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; + +public static class HealthEndpoints +{ + public static void MapHealthEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/health", GetHealth) + .WithName(nameof(GetHealth)) + .Produces(StatusCodes.Status200OK); + } + + private static IResult GetHealth(ILogger logger) + { + logger.HealthCheckRequested(); + + string? version = Assembly.GetExecutingAssembly() + .GetCustomAttribute() + ?.InformationalVersion ?? "unknown"; + + HealthResponse response = new HealthResponse( + ServiceName: "CompiledLayoutService", + Version: version, + Status: "healthy" + ); + + logger.HealthCheckSuccessful(); + + return Results.Ok(response); + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs new file mode 100644 index 00000000..665016a3 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs @@ -0,0 +1,38 @@ +using AdaptiveRemote.Backend.CompiledLayoutService.Logging; +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; + +public static class LayoutEndpoints +{ + public static void MapLayoutEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/layouts/compiled/active", GetActiveLayout) + .WithName(nameof(GetActiveLayout)) + .Produces(StatusCodes.Status200OK); + } + + private static async Task GetActiveLayout( + ILogger logger, + ICompiledLayoutRepository repository, + CancellationToken cancellationToken) + { + logger.GetActiveLayoutRequested(); + + // For MVP, we use a hardcoded userId. Auth will provide real userId in ADR-168. + string userId = "mvp-user"; + CompiledLayout? layout = await repository.GetActiveForUserAsync(userId, cancellationToken); + + if (layout == null) + { + return Results.NotFound(); + } + + logger.ReturningActiveLayout(layout.Id); + + // Use the LayoutContractsJsonContext for serialization + return Results.Json( + layout, + LayoutContractsJsonContext.Default.CompiledLayout); + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs new file mode 100644 index 00000000..6f926c09 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.Backend.CompiledLayoutService.Logging; + +/// +/// Centralized logging messages for CompiledLayoutService. +/// All log messages MUST be defined here as [LoggerMessage] source-generated methods. +/// Event ID ranges: +/// 1100-1199: CompiledLayoutService +/// +public static partial class MessageLogger +{ + [LoggerMessage(EventId = 1100, Level = LogLevel.Information, Message = "CompiledLayoutService starting")] + public static partial void ServiceStarting(this ILogger logger); + + [LoggerMessage(EventId = 1101, Level = LogLevel.Information, Message = "CompiledLayoutService started successfully on {ListenAddress}")] + public static partial void ServiceStarted(this ILogger logger, string listenAddress); + + [LoggerMessage(EventId = 1102, Level = LogLevel.Information, Message = "GET /layouts/compiled/active request received")] + public static partial void GetActiveLayoutRequested(this ILogger logger); + + [LoggerMessage(EventId = 1103, Level = LogLevel.Information, Message = "Returning active compiled layout Id={LayoutId}")] + public static partial void ReturningActiveLayout(this ILogger logger, Guid layoutId); + + [LoggerMessage(EventId = 1104, Level = LogLevel.Information, Message = "GET /health request received")] + public static partial void HealthCheckRequested(this ILogger logger); + + [LoggerMessage(EventId = 1105, Level = LogLevel.Information, Message = "Health check successful")] + public static partial void HealthCheckSuccessful(this ILogger logger); + + [LoggerMessage(EventId = 1106, Level = LogLevel.Error, Message = "Error retrieving active layout for userId={UserId}")] + public static partial void ErrorRetrievingActiveLayout(this ILogger logger, string userId, Exception exception); + + [LoggerMessage(EventId = 1107, Level = LogLevel.Error, Message = "Error processing health check request")] + public static partial void ErrorProcessingHealthCheck(this ILogger logger, Exception exception); +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs new file mode 100644 index 00000000..0c642748 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -0,0 +1,29 @@ +using AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; +using AdaptiveRemote.Backend.CompiledLayoutService.Logging; +using AdaptiveRemote.Backend.CompiledLayoutService.Services; +using AdaptiveRemote.Contracts; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Register services +builder.Services.AddSingleton(); + +WebApplication app = builder.Build(); + +ILogger logger = app.Services.GetRequiredService>(); +logger.ServiceStarting(); + +// Map endpoints +app.MapHealthEndpoints(); +app.MapLayoutEndpoints(); + +string listenAddress = app.Urls.FirstOrDefault() ?? "http://localhost:5000"; +logger.ServiceStarted(listenAddress); + +app.Run(); + +// Make Program visible for testing +public partial class Program { } diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs new file mode 100644 index 00000000..2fca7dc8 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs @@ -0,0 +1,89 @@ +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.CompiledLayoutService.Services; + +/// +/// Hardcoded implementation of ICompiledLayoutRepository for ADR-167 Static layout MVP. +/// Returns a fixed layout matching the current StaticCommandGroupProvider. +/// Will be replaced with real DynamoDB storage in ADR-173. +/// +public class HardcodedLayoutProvider : ICompiledLayoutRepository +{ + // Hardcoded layout ID and user ID for MVP + private static readonly Guid LayoutId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + private static readonly Guid RawLayoutId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + + public Task GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default) + { + List elements = new List + { + new LayoutGroupDefinitionDto( + CssId: "DPAD", + Children: new List + { + new CommandDefinitionDto(CommandType.TiVo, "Up", "Up", null, "Sent Up", "Down", "UP"), + new CommandDefinitionDto(CommandType.TiVo, "Down", "Down", null, "Sent Down", "Up", "DOWN"), + new CommandDefinitionDto(CommandType.TiVo, "Left", "Left", null, "Sent Left", "Right", "LEFT"), + new CommandDefinitionDto(CommandType.TiVo, "Right", "Right", null, "Sent Right", "Left", "RIGHT"), + new CommandDefinitionDto(CommandType.TiVo, "Select", "Select", null, "Sent Select", null, "SELECT"), + new CommandDefinitionDto(CommandType.TiVo, "Back", "Back", null, "Sent Back", null, "BACK"), + new CommandDefinitionDto(CommandType.IR, "Power", "Power", null, "Sent Power", "Power", "POWER"), + new CommandDefinitionDto(CommandType.IR, "PowerOn", "PowerOn", null, "Sent PowerOn", "PowerOff", "POWERON"), + new CommandDefinitionDto(CommandType.IR, "PowerOff", "PowerOff", null, "Sent PowerOff", "PowerOn", "POWEROFF"), + }.AsReadOnly() + ), + new LayoutGroupDefinitionDto( + CssId: "WELL", + Children: new List + { + new CommandDefinitionDto(CommandType.TiVo, "TiVo", "TiVo", null, "Sent TiVo", null, "TIVO"), + new CommandDefinitionDto(CommandType.TiVo, "Netflix", "Netflix", null, "Sent Netflix", null, "NETFLIX"), + new CommandDefinitionDto(CommandType.TiVo, "Guide", "Guide", null, "Sent Guide", null, "GUIDE"), + }.AsReadOnly() + ), + new LayoutGroupDefinitionDto( + CssId: "PLAYBACK", + Children: new List + { + new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "PLAY"), + new CommandDefinitionDto(CommandType.TiVo, "Pause", "Pause", null, "Sent Pause", "Play", "PAUSE"), + new CommandDefinitionDto(CommandType.TiVo, "Record", "Record", null, "Sent Record", null, "RECORD"), + new CommandDefinitionDto(CommandType.TiVo, "Skip", "Skip", null, "Sent Skip", "Replay", "SKIP"), + new CommandDefinitionDto(CommandType.TiVo, "Replay", "Replay", null, "Sent Replay", "Skip", "REPLAY"), + }.AsReadOnly() + ), + new LayoutGroupDefinitionDto( + CssId: "CHANNELANDVOLUME", + Children: new List + { + new CommandDefinitionDto(CommandType.TiVo, "ChannelUp", "Up", null, "Sent Channel Up", "ChannelDown", "CHANNELUP"), + new CommandDefinitionDto(CommandType.TiVo, "ChannelDown", "Down", null, "Sent Channel Down", "ChannelUp", "CHANNELDOWN"), + new CommandDefinitionDto(CommandType.IR, "VolumeUp", "Up", null, "Sent Volume Up", "VolumeDown", "VOLUMEUP"), + new CommandDefinitionDto(CommandType.IR, "VolumeDown", "Down", null, "Sent Volume Down", "VolumeUp", "VOLUMEDOWN"), + new CommandDefinitionDto(CommandType.IR, "Mute", "Mute", null, "Sent Mute", "Mute", "MUTE"), + }.AsReadOnly() + ), + new LayoutGroupDefinitionDto( + CssId: "GUTTER", + Children: new List + { + new CommandDefinitionDto(CommandType.Lifecycle, "Learn", "Learn", null, "Sent Learn", null, "LEARN"), + new CommandDefinitionDto(CommandType.Lifecycle, "Exit", "Exit", null, "Goodbye", null, "EXIT"), + }.AsReadOnly() + ), + }; + + CompiledLayout layout = new CompiledLayout( + Id: LayoutId, + RawLayoutId: RawLayoutId, + UserId: userId, + IsActive: true, + Version: 1, + Elements: elements.AsReadOnly(), + CssDefinitions: "/* Placeholder CSS - real CSS generation in ADR-171 */", + CompiledAt: DateTimeOffset.UtcNow + ); + + return Task.FromResult(layout); + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json new file mode 100644 index 00000000..34f00ef1 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/AdaptiveRemote.Contracts/HealthResponse.cs b/src/AdaptiveRemote.Contracts/HealthResponse.cs new file mode 100644 index 00000000..bc9da912 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/HealthResponse.cs @@ -0,0 +1,10 @@ +namespace AdaptiveRemote.Contracts; + +/// +/// Standard health check response for all backend services. +/// +public record HealthResponse( + string ServiceName, + string Version, + string Status +); diff --git a/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs b/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs new file mode 100644 index 00000000..80cd7f7d --- /dev/null +++ b/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs @@ -0,0 +1,12 @@ +namespace AdaptiveRemote.Contracts; + +/// +/// Repository interface for compiled layout storage and retrieval. +/// +public interface ICompiledLayoutRepository +{ + /// + /// Gets the active compiled layout for the specified user. + /// + Task GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default); +} diff --git a/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs b/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs index 13684759..b165db23 100644 --- a/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs +++ b/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs @@ -9,6 +9,7 @@ namespace AdaptiveRemote.Contracts; [JsonSerializable(typeof(CompiledLayout))] [JsonSerializable(typeof(PreviewLayout))] [JsonSerializable(typeof(ValidationResult))] +[JsonSerializable(typeof(HealthResponse))] [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(IReadOnlyList))] public partial class LayoutContractsJsonContext : JsonSerializerContext { } diff --git a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj new file mode 100644 index 00000000..d8f66699 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + true + AdaptiveRemote.Backend.ApiTests + + + + + + + + + + + + + + + + + + diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature new file mode 100644 index 00000000..392864ad --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature @@ -0,0 +1,10 @@ +Feature: CompiledLayoutService Endpoints + +Scenario: Get active compiled layout + Given CompiledLayoutService is running + When a test client calls GET /layouts/compiled/active + Then the response is 200 OK + And the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext + And the CompiledLayout contains the expected hardcoded commands + And the service logs contain a request log entry for GET /layouts/compiled/active + And the service logs contain no warnings or errors diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs new file mode 100644 index 00000000..72246dc2 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs @@ -0,0 +1,171 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace AdaptiveRemote.Backend.ApiTests.Features +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class CompiledLayoutServiceEndpointsFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = ((string[])(null)); + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "CompiledLayoutService Endpoints", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "CompiledLayoutEndpoints.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute(Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupBehavior.EndOfClass)] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/CompiledLayoutEndpoints.feature.ndjson", 3); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get active compiled layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get active compiled layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Endpoints")] + public async global::System.Threading.Tasks.Task GetActiveCompiledLayout() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get active compiled layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 3 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 5 + await testRunner.WhenAsync("a test client calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 6 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 7 + await testRunner.AndAsync("the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 8 + await testRunner.AndAsync("the CompiledLayout contains the expected hardcoded commands", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 9 + await testRunner.AndAsync("the service logs contain a request log entry for GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync("the service logs contain no warnings or errors", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature new file mode 100644 index 00000000..0437ee86 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature @@ -0,0 +1,7 @@ +Feature: Health Endpoints + +Scenario: Get service health status + Given CompiledLayoutService is running + When a test client calls GET /health + Then the response is 200 OK + And the body contains the service name and version diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs new file mode 100644 index 00000000..fb6375e0 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs @@ -0,0 +1,162 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace AdaptiveRemote.Backend.ApiTests.Features +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class HealthEndpointsFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = ((string[])(null)); + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "Health Endpoints", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "HealthEndpoints.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute(Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupBehavior.EndOfClass)] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/HealthEndpoints.feature.ndjson", 3); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get service health status")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get service health status")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Health Endpoints")] + public async global::System.Threading.Tasks.Task GetServiceHealthStatus() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get service health status", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 3 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 5 + await testRunner.WhenAsync("a test client calls GET /health", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 6 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 7 + await testRunner.AndAsync("the body contains the service name and version", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs new file mode 100644 index 00000000..1481184b --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Text.Json; +using AdaptiveRemote.Backend.ApiTests.Support; +using AdaptiveRemote.Contracts; +using FluentAssertions; +using Reqnroll; + +namespace AdaptiveRemote.Backend.ApiTests.StepDefinitions; + +[Binding] +public class CommonSteps : IDisposable +{ + private readonly ServiceFixture _fixture = new(); + private HttpResponseMessage? _response; + private string? _responseBody; + + [Given(@"CompiledLayoutService is running")] + public void GivenCompiledLayoutServiceIsRunning() + { + _fixture.StartService(); + } + + [When(@"a test client calls GET (.*)")] + public async Task WhenATestClientCallsGet(string endpoint) + { + _response = await _fixture.HttpClient.GetAsync(endpoint); + _responseBody = await _response.Content.ReadAsStringAsync(); + } + + [Then(@"the response is (\d+) OK")] + public void ThenTheResponseIsOk(int statusCode) + { + _response.Should().NotBeNull(); + ((int)_response!.StatusCode).Should().Be(statusCode); + } + + [Then(@"the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext")] + public void ThenTheBodyDeserializesToValidCompiledLayout() + { + _responseBody.Should().NotBeNullOrEmpty(); + + CompiledLayout? layout = JsonSerializer.Deserialize( + _responseBody!, + LayoutContractsJsonContext.Default.CompiledLayout); + + layout.Should().NotBeNull(); + layout!.Id.Should().NotBeEmpty(); + layout.Elements.Should().NotBeEmpty(); + } + + [Then(@"the CompiledLayout contains the expected hardcoded commands")] + public void ThenTheCompiledLayoutContainsExpectedCommands() + { + _responseBody.Should().NotBeNullOrEmpty(); + + CompiledLayout? layout = JsonSerializer.Deserialize( + _responseBody!, + LayoutContractsJsonContext.Default.CompiledLayout); + + layout.Should().NotBeNull(); + + // Verify key commands from StaticCommandGroupProvider exist + List commands = ExtractAllCommands(layout!.Elements); + + commands.Should().Contain(c => c.Name == "Up" && c.Type == CommandType.TiVo); + commands.Should().Contain(c => c.Name == "Select" && c.Type == CommandType.TiVo); + commands.Should().Contain(c => c.Name == "Power" && c.Type == CommandType.IR); + commands.Should().Contain(c => c.Name == "Learn" && c.Type == CommandType.Lifecycle); + commands.Should().Contain(c => c.Name == "Exit" && c.Type == CommandType.Lifecycle); + } + + [Then(@"the service logs contain a request log entry for GET (.*)")] + public void ThenTheServiceLogsContainRequestLogEntry(string endpoint) + { + string logs = _fixture.GetLogs(); + logs.Should().Contain(endpoint); + } + + [Then(@"the service logs contain no warnings or errors")] + public void ThenTheServiceLogsContainNoWarningsOrErrors() + { + string logs = _fixture.GetLogs(); + logs.Should().NotContain("WARNING", "service should not log warnings"); + logs.Should().NotContain("ERROR", "service should not log errors"); + logs.Should().NotContain("Exception", "service should not log exceptions"); + } + + [Then(@"the body contains the service name and version")] + public void ThenTheBodyContainsServiceNameAndVersion() + { + _responseBody.Should().NotBeNullOrEmpty(); + + HealthResponse? healthResponse = JsonSerializer.Deserialize( + _responseBody!, + LayoutContractsJsonContext.Default.HealthResponse); + + healthResponse.Should().NotBeNull(); + healthResponse!.ServiceName.Should().Be("CompiledLayoutService"); + healthResponse.Version.Should().NotBeNullOrEmpty(); + healthResponse.Status.Should().Be("healthy"); + } + + private static List ExtractAllCommands(IReadOnlyList elements) + { + List commands = new(); + + foreach (LayoutElementDto element in elements) + { + if (element is CommandDefinitionDto command) + { + commands.Add(command); + } + else if (element is LayoutGroupDefinitionDto group) + { + commands.AddRange(ExtractAllCommands(group.Children)); + } + } + + return commands; + } + + public void Dispose() + { + _response?.Dispose(); + _fixture.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs new file mode 100644 index 00000000..a0bde82a --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs @@ -0,0 +1,142 @@ +using System.Diagnostics; +using System.Text; + +namespace AdaptiveRemote.Backend.ApiTests.Support; + +/// +/// Manages the lifecycle of CompiledLayoutService for API integration tests. +/// Starts the service process and captures structured log output. +/// +public class ServiceFixture : IDisposable +{ + private Process? _serviceProcess; + private readonly StringBuilder _logOutput = new(); + private readonly object _logLock = new(); + + public string ServiceUrl { get; private set; } = "http://localhost:5000"; + public HttpClient HttpClient { get; private set; } = null!; + + public void StartService() + { + if (_serviceProcess != null) + { + return; // Already started + } + + // Find the repository root by looking for the .git directory + string currentDir = Directory.GetCurrentDirectory(); + string? repoRoot = currentDir; + while (repoRoot != null && !Directory.Exists(Path.Combine(repoRoot, ".git"))) + { + repoRoot = Directory.GetParent(repoRoot)?.FullName; + } + + if (repoRoot == null) + { + throw new InvalidOperationException("Could not find repository root (no .git directory found)"); + } + + string projectPath = Path.Combine( + repoRoot, + "src", "AdaptiveRemote.Backend.CompiledLayoutService", + "AdaptiveRemote.Backend.CompiledLayoutService.csproj"); + + if (!File.Exists(projectPath)) + { + throw new InvalidOperationException($"Project file not found at: {projectPath}"); + } + + ProcessStartInfo startInfo = new() + { + FileName = "dotnet", + Arguments = $"run --project \"{projectPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + Environment = + { + ["ASPNETCORE_ENVIRONMENT"] = "Development", + ["ASPNETCORE_URLS"] = ServiceUrl + } + }; + + _serviceProcess = new Process { StartInfo = startInfo }; + + _serviceProcess.OutputDataReceived += (sender, args) => + { + if (args.Data != null) + { + lock (_logLock) + { + _logOutput.AppendLine(args.Data); + } + } + }; + + _serviceProcess.ErrorDataReceived += (sender, args) => + { + if (args.Data != null) + { + lock (_logLock) + { + _logOutput.AppendLine($"ERROR: {args.Data}"); + } + } + }; + + _serviceProcess.Start(); + _serviceProcess.BeginOutputReadLine(); + _serviceProcess.BeginErrorReadLine(); + + // Wait for service to be ready - poll for health endpoint + HttpClient = new HttpClient { BaseAddress = new Uri(ServiceUrl) }; + + bool isReady = false; + for (int i = 0; i < 30; i++) + { + try + { + HttpResponseMessage response = HttpClient.GetAsync("/health").Result; + if (response.IsSuccessStatusCode) + { + isReady = true; + break; + } + } + catch + { + // Service not ready yet + } + + Thread.Sleep(1000); + } + + if (!isReady) + { + string logs = GetLogs(); + throw new InvalidOperationException($"Service failed to start within 30 seconds. Logs:\n{logs}"); + } + } + + public string GetLogs() + { + lock (_logLock) + { + return _logOutput.ToString(); + } + } + + public void Dispose() + { + if (_serviceProcess != null && !_serviceProcess.HasExited) + { + _serviceProcess.Kill(entireProcessTree: true); + _serviceProcess.WaitForExit(5000); + _serviceProcess.Dispose(); + } + + HttpClient?.Dispose(); + GC.SuppressFinalize(this); + } +} From 4b7ef97eb00688d59c9770cd59042a5c5c976b13 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Fri, 10 Apr 2026 08:43:54 -0700 Subject: [PATCH 04/11] Fix tests failures caused by unnecessary build from `dotnet run`. Also fixed the timeout so it doesn't take 2.5 minutes to wait 30 seconds. --- .../Support/ServiceFixture.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs index a0bde82a..ea39cfa4 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs @@ -49,7 +49,7 @@ public void StartService() ProcessStartInfo startInfo = new() { FileName = "dotnet", - Arguments = $"run --project \"{projectPath}\"", + Arguments = $"run --project \"{projectPath}\" --no-build", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -93,8 +93,9 @@ public void StartService() HttpClient = new HttpClient { BaseAddress = new Uri(ServiceUrl) }; bool isReady = false; - for (int i = 0; i < 30; i++) + for (int i = 0; i < 30 && !_serviceProcess.HasExited; i++) { + DateTime startTime = DateTime.Now; try { HttpResponseMessage response = HttpClient.GetAsync("/health").Result; @@ -109,7 +110,11 @@ public void StartService() // Service not ready yet } - Thread.Sleep(1000); + TimeSpan sleepTime = TimeSpan.FromSeconds(1000) - (DateTime.Now - startTime); + if (sleepTime > TimeSpan.Zero) + { + Thread.Sleep(sleepTime); + } } if (!isReady) From 037ea09d82d917516f62ce90c54d205072ffae24 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Mon, 13 Apr 2026 14:53:14 -0700 Subject: [PATCH 05/11] Add JWT Bearer authentication backed by AWS Cognito (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ADR-168] Wire JWT Bearer auth via Cognito; add CognitoTokenService and auth API tests - CompiledLayoutService: add JWT Bearer authentication backed by AWS Cognito. GET /layouts/compiled/active now requires a valid bearer token; GET /health remains unauthenticated. The sub claim is extracted as userId. CognitoSettings reads Authority and Audience from appsettings/env vars. - Client app: add BackendSettings, ICognitoTokenService, CognitoTokenService. CognitoTokenService acquires and caches tokens via the OAuth2 Client Credentials flow (lazy acquire, 60-second expiry buffer). Log messages in range 1600-1699. - API tests: add TestJwtAuthority (local OIDC/JWKS server) so auth can be tested end-to-end without a real Cognito user pool. ServiceFixture now starts the authority first and configures Cognito__Authority on the service process. CommonSteps updated to use Reqnroll context injection (ServiceContext) for shared fixture state. AuthenticationEndpoints.feature covers 401 for no-auth and expired tokens, 200 for valid JWT, and unauthenticated /health access. - Docs: _doc_Auth.md documents Cognito setup for local dev, client credentials config, editor Authorization Code flow, and the test JWT authority pattern. https://claude.ai/code/session_01LLWQBraEp7n7uLVy7M4PFp * Fix build failures * [ADR-168] Wire JWT Bearer auth via Cognito; add CognitoTokenService and auth API tests - CompiledLayoutService: add JWT Bearer authentication backed by AWS Cognito. GET /layouts/compiled/active now requires a valid bearer token; GET /health remains unauthenticated. The sub claim is extracted as userId. CognitoSettings reads Authority and Audience from appsettings/env vars. - Client app: add BackendSettings, ICognitoTokenService, CognitoTokenService. CognitoTokenService acquires and caches tokens via the OAuth2 Client Credentials flow (lazy acquire, 60-second expiry buffer). Log messages in range 1600-1699. - API tests: add TestJwtAuthority (local OIDC/JWKS server) so auth can be tested end-to-end without a real Cognito user pool. ServiceFixture now starts the authority first and configures Cognito__Authority on the service process. CommonSteps updated to use Reqnroll context injection (ServiceContext) for shared fixture state. AuthenticationEndpoints.feature covers 401 for no-auth and expired tokens, 200 for valid JWT, and unauthenticated /health access. - Docs: _doc_Auth.md documents Cognito setup for local dev, client credentials config, editor Authorization Code flow, and the test JWT authority pattern. https://claude.ai/code/session_01LLWQBraEp7n7uLVy7M4PFp * Fix API test hanging, build errors, and missing package Three categories of fixes: 1. API tests hanging: ServiceFixture.StartService() was blocking the async test thread with Thread.Sleep() and HttpClient.GetAsync().Result, risking a deadlock when called from Reqnroll's async step dispatcher. Renamed to StartServiceAsync(), switched to await/Task.Delay, added a 5-second per- request timeout on the health-check client, and made the [Given] step binding async. The service URL is now allocated on a dynamic free port instead of hardcoded :5000, eliminating port-conflict failures between consecutive scenarios. 2. Build errors: - Removed 'using System.Net.Http;' from CognitoTokenService.cs — the namespace is already a global implicit using for Microsoft.NET.Sdk.Razor and the redundant directive triggers a warning/error with /warnaserror. - Changed to in AdaptiveRemote.csproj so we only set CopyToOutputDirectory metadata on items the SDK already includes automatically, instead of adding duplicate Content items. 3. Missing package: Added Microsoft.NET.Test.Sdk to AdaptiveRemote.Backend. ApiTests.csproj — required by Reqnroll.MSTest for proper test-host infrastructure (consistent with all other test projects in the solution). https://claude.ai/code/session_01LLWQBraEp7n7uLVy7M4PFp * Fix build errors and workflow: package mismatch, CA1816, and E2E test gating Three build fixes: 1. Package mismatch (NU1608): Replace MSTest meta-package with MSTest.TestAdapter + MSTest.TestFramework in ApiTests project. MSTest 3.1.1 meta-package requires Microsoft.NET.Test.Sdk = 17.6.0 exactly, which conflicts with the 18.0.1 version in Directory.Packages.props. Other test projects already use the individual packages to avoid this. 2. Code analysis error (CA1816): Add GC.SuppressFinalize(this) to ServiceContext.Dispose() as required by CA1816. 3. Workflow: Add id: build to the Build step and change E2E Tests from if: always() to if: always() && steps.build.outcome == 'success'. This ensures E2E tests skip when the build fails, but still run when only unit tests fail (as was the intent of always()). Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/d7976913-3a8a-4c5f-b6ce-5f024fe07d01 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> * Fix API test URL mismatch: add --no-launch-profile to prevent launchSettings from overriding ASPNETCORE_URLS Root cause: dotnet run reads launchSettings.json and overwrites ASPNETCORE_URLS with applicationUrl (https://localhost:54433;http://localhost:54434), ignoring the dynamic port set in ProcessStartInfo.Environment. The health check polls the dynamic port but the service listens on 54434, so it always times out. Changes: 1. ServiceFixture.cs: Added --no-launch-profile to dotnet run so ASPNETCORE_URLS from the test fixture's environment is respected. 2. ServiceFixture.cs: Added per-attempt diagnostic logging in the health check loop showing the URL polled and the status/exception per attempt. 3. Program.cs: Fixed misleading ServiceStarted log — app.Urls is always empty before Run(), so read ASPNETCORE_URLS from IConfiguration instead. Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/ba6750ee-d018-4f28-b4d0-7d4e309d6f02 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --------- Co-authored-by: Claude Co-authored-by: Joe Davis Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .github/workflows/build-and-test.yml | 3 +- Directory.Packages.props | 3 + docker-compose.yml | 4 + .../AppHostBuilderExtensions.cs | 1 + .../BackendHostBuilderExtensions.cs | 20 ++ .../Configuration/SettingsKeys.cs | 5 + .../Logging/MessageLogger.cs | 11 + .../Services/Backend/BackendSettings.cs | 52 ++++ .../Services/Backend/CognitoTokenService.cs | 133 +++++++++ .../Services/Backend/ICognitoTokenService.cs | 15 + ...emote.Backend.CompiledLayoutService.csproj | 4 + .../Configuration/CognitoSettings.cs | 19 ++ .../Endpoints/LayoutEndpoints.cs | 18 +- .../Logging/MessageLogger.cs | 4 +- .../Program.cs | 40 ++- .../Properties/launchSettings.json | 12 + .../_doc_Auth.md | 90 ++++++ .../appsettings.Development.json | 4 + .../appsettings.json | 6 +- src/AdaptiveRemote/AdaptiveRemote.csproj | 5 + .../appsettings.Development.json | 11 + src/AdaptiveRemote/appsettings.json | 11 + src/_doc_Projects.md | 9 + .../AdaptiveRemote.Backend.ApiTests.csproj | 5 +- .../Features/AuthenticationEndpoints.feature | 21 ++ .../AuthenticationEndpoints.feature.cs | 261 ++++++++++++++++++ .../StepDefinitions/AuthenticationSteps.cs | 57 ++++ .../StepDefinitions/CommonSteps.cs | 48 ++-- .../Support/ServiceContext.cs | 27 ++ .../Support/ServiceFixture.cs | 143 ++++++++-- .../Support/TestJwtAuthority.cs | 188 +++++++++++++ 31 files changed, 1180 insertions(+), 50 deletions(-) create mode 100644 src/AdaptiveRemote.App/Configuration/BackendHostBuilderExtensions.cs create mode 100644 src/AdaptiveRemote.App/Services/Backend/BackendSettings.cs create mode 100644 src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs create mode 100644 src/AdaptiveRemote.App/Services/Backend/ICognitoTokenService.cs create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Configuration/CognitoSettings.cs create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md create mode 100644 src/AdaptiveRemote/appsettings.Development.json create mode 100644 src/AdaptiveRemote/appsettings.json create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 16b90c77..67dbb52d 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -32,6 +32,7 @@ jobs: run: dotnet restore - name: Build + id: build run: dotnet build --no-restore /warnaserror - name: Install Chromium browser for Playwright headless tests @@ -43,7 +44,7 @@ jobs: - name: E2E Tests run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" ./scripts/validate-e2e-tests.proj -m:1 - if: always() + if: always() && steps.build.outcome == 'success' - name: Upload E2E Test Logs uses: actions/upload-artifact@v6 diff --git a/Directory.Packages.props b/Directory.Packages.props index 6dfb016f..55dfb32b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + @@ -30,6 +31,8 @@ + + diff --git a/docker-compose.yml b/docker-compose.yml index 8060fb6e..bba4ad9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,10 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:8080 + # Set Cognito dev user pool values here or via a .env file. + # See _doc_Auth.md for Cognito dev user pool setup instructions. + - Cognito__Authority=${COGNITO_AUTHORITY:-} + - Cognito__Audience=${COGNITO_AUDIENCE:-} networks: - backend diff --git a/src/AdaptiveRemote.App/AppHostBuilderExtensions.cs b/src/AdaptiveRemote.App/AppHostBuilderExtensions.cs index 5f6b9ca4..980c2daa 100644 --- a/src/AdaptiveRemote.App/AppHostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/AppHostBuilderExtensions.cs @@ -15,6 +15,7 @@ public static IHostBuilder ConfigureApp(this IHostBuilder hostBuilder) .AddTiVoSupport() .AddConversationSystem() .AddSystemWrapperServices() + .AddBackendSupport() .OptionallyAddTestHookEndpoint(); public static IHostBuilder ConfigureAppSettings(this IHostBuilder hostBuilder, string[] args) diff --git a/src/AdaptiveRemote.App/Configuration/BackendHostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/BackendHostBuilderExtensions.cs new file mode 100644 index 00000000..dd5f9fdf --- /dev/null +++ b/src/AdaptiveRemote.App/Configuration/BackendHostBuilderExtensions.cs @@ -0,0 +1,20 @@ +using AdaptiveRemote.Services.Backend; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace AdaptiveRemote.Configuration; + +internal static class BackendHostBuilderExtensions +{ + public static IHostBuilder AddBackendSupport(this IHostBuilder builder) + => builder.ConfigureServices((context, services) => + services.AddBackendServices(context.Configuration)); + + private static IServiceCollection AddBackendServices( + this IServiceCollection services, + IConfiguration configuration) + => services + .Configure(configuration.GetSection(SettingsKeys.Backend)) + .AddSingleton(); +} diff --git a/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs b/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs index ec364b45..2fcf1cf3 100644 --- a/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs +++ b/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs @@ -41,4 +41,9 @@ internal class SettingsKeys /// Configuration section for IR command payloads. /// public const string IRData = "irdata"; + + /// + /// Configuration section for backend service settings. + /// + public const string Backend = "backend"; } diff --git a/src/AdaptiveRemote.App/Logging/MessageLogger.cs b/src/AdaptiveRemote.App/Logging/MessageLogger.cs index d8b19c83..9e3b3224 100644 --- a/src/AdaptiveRemote.App/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.App/Logging/MessageLogger.cs @@ -346,4 +346,15 @@ public MessageLogger(ILogger logger) [LoggerMessage(EventId = 1516, Level = LogLevel.Information, Message = "Registering test service {ServiceName} implementing {ContractType} in DI container")] public partial void TestEndpointHooksService_RegisteringTestServiceInDI(string serviceName, string contractType); + + // 1600–1699: CognitoTokenService + + [LoggerMessage(EventId = 1600, Level = LogLevel.Information, Message = "Acquiring Cognito access token via Client Credentials flow")] + public partial void CognitoTokenService_AcquiringToken(); + + [LoggerMessage(EventId = 1601, Level = LogLevel.Information, Message = "Cognito access token acquired successfully")] + public partial void CognitoTokenService_TokenAcquired(); + + [LoggerMessage(EventId = 1602, Level = LogLevel.Error, Message = "Failed to acquire Cognito access token")] + public partial void CognitoTokenService_AcquireTokenFailed(Exception exception); } diff --git a/src/AdaptiveRemote.App/Services/Backend/BackendSettings.cs b/src/AdaptiveRemote.App/Services/Backend/BackendSettings.cs new file mode 100644 index 00000000..849878d4 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Backend/BackendSettings.cs @@ -0,0 +1,52 @@ +namespace AdaptiveRemote.Services.Backend; + +/// +/// Configuration for the AdaptiveRemote backend services. +/// Maps to the "backend" section in appsettings.json. +/// +public class BackendSettings +{ + /// + /// Base URL of the backend API (e.g. https://api.adaptiveremote.example.com for + /// production, or http://localhost:8080 for local development). + /// + public string BaseUrl { get; set; } = string.Empty; + + /// + /// Cognito OAuth2 client credentials for the client application. + /// + public CognitoClientSettings Cognito { get; set; } = new(); +} + +/// +/// AWS Cognito Client Credentials flow settings for the client application. +/// The client application authenticates as a machine client — no interactive login occurs. +/// Sensitive values (ClientSecret) should be stored in user secrets or environment +/// variables, not checked in to source control. +/// +public class CognitoClientSettings +{ + /// + /// The Cognito user pool authority URL, e.g. + /// https://cognito-idp.{region}.amazonaws.com/{userPoolId} + /// Used to discover the token endpoint via OIDC configuration. + /// + public string Authority { get; set; } = string.Empty; + + /// + /// The OAuth2 client ID registered in the Cognito user pool for the client application. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// The OAuth2 client secret. Store in user secrets or environment variables — + /// never commit to source control. + /// + public string ClientSecret { get; set; } = string.Empty; + + /// + /// OAuth2 scope(s) to request, space-separated (e.g. "adaptiveremote/layouts.read"). + /// Leave empty to omit the scope parameter. + /// + public string? Scope { get; set; } +} diff --git a/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs b/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs new file mode 100644 index 00000000..3bcc3742 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using AdaptiveRemote.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.Backend; + +/// +/// Acquires and caches OAuth2 access tokens from AWS Cognito using the +/// Client Credentials flow. Token refresh is lazy: the cached token is +/// returned until it is within of expiring, +/// at which point a new token is acquired. +/// +internal sealed class CognitoTokenService : ICognitoTokenService, IDisposable +{ + // Refresh the token this many seconds before it actually expires. + private static readonly TimeSpan ExpiryBuffer = TimeSpan.FromSeconds(60); + + private readonly BackendSettings _settings; + private readonly HttpClient _httpClient; + private readonly MessageLogger _log; + + private string? _cachedToken; + private DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + private string? _tokenEndpoint; + private readonly SemaphoreSlim _lock = new(1, 1); + + public CognitoTokenService( + IOptions settings, + ILogger logger) + { + _settings = settings.Value; + _httpClient = new HttpClient(); + _log = new MessageLogger(logger); + } + + public async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (_cachedToken is not null && DateTimeOffset.UtcNow < _tokenExpiry - ExpiryBuffer) + { + return _cachedToken; + } + + _log.CognitoTokenService_AcquiringToken(); + try + { + string endpoint = await GetTokenEndpointAsync(cancellationToken); + (_cachedToken, _tokenExpiry) = await AcquireTokenAsync(endpoint, cancellationToken); + _log.CognitoTokenService_TokenAcquired(); + return _cachedToken; + } + catch (Exception ex) + { + _log.CognitoTokenService_AcquireTokenFailed(ex); + throw; + } + } + finally + { + _lock.Release(); + } + } + + private async Task GetTokenEndpointAsync(CancellationToken cancellationToken) + { + if (_tokenEndpoint is not null) + { + return _tokenEndpoint; + } + + CognitoClientSettings cognito = _settings.Cognito; + string discoveryUrl = $"{cognito.Authority.TrimEnd('/')}/.well-known/openid-configuration"; + + using HttpResponseMessage discoveryResponse = + await _httpClient.GetAsync(discoveryUrl, cancellationToken); + + discoveryResponse.EnsureSuccessStatusCode(); + + string json = await discoveryResponse.Content.ReadAsStringAsync(cancellationToken); + using JsonDocument doc = JsonDocument.Parse(json); + _tokenEndpoint = doc.RootElement.GetProperty("token_endpoint").GetString() + ?? throw new InvalidOperationException( + "token_endpoint not found in OIDC discovery document"); + + return _tokenEndpoint; + } + + private async Task<(string Token, DateTimeOffset Expiry)> AcquireTokenAsync( + string endpoint, + CancellationToken cancellationToken) + { + CognitoClientSettings cognito = _settings.Cognito; + + List> parameters = + [ + new("grant_type", "client_credentials"), + new("client_id", cognito.ClientId), + new("client_secret", cognito.ClientSecret), + ]; + + if (!string.IsNullOrEmpty(cognito.Scope)) + { + parameters.Add(new("scope", cognito.Scope)); + } + + using FormUrlEncodedContent content = new(parameters); + using HttpResponseMessage response = + await _httpClient.PostAsync(endpoint, content, cancellationToken); + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(cancellationToken); + using JsonDocument doc = JsonDocument.Parse(json); + + string accessToken = doc.RootElement.GetProperty("access_token").GetString() + ?? throw new InvalidOperationException("access_token not found in token response"); + + int expiresIn = doc.RootElement.TryGetProperty("expires_in", out JsonElement expiresInElement) + ? expiresInElement.GetInt32() + : 3600; + + return (accessToken, DateTimeOffset.UtcNow.AddSeconds(expiresIn)); + } + + public void Dispose() + { + _httpClient.Dispose(); + _lock.Dispose(); + } +} diff --git a/src/AdaptiveRemote.App/Services/Backend/ICognitoTokenService.cs b/src/AdaptiveRemote.App/Services/Backend/ICognitoTokenService.cs new file mode 100644 index 00000000..d9431e27 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Backend/ICognitoTokenService.cs @@ -0,0 +1,15 @@ +namespace AdaptiveRemote.Services.Backend; + +/// +/// Acquires and caches OAuth2 access tokens from AWS Cognito using the +/// Client Credentials flow. The client application calls this to obtain a +/// bearer token before making requests to backend services. +/// +public interface ICognitoTokenService +{ + /// + /// Returns a valid access token, acquiring or refreshing it from Cognito + /// as needed. The returned token is safe to use as a Bearer token immediately. + /// + Task GetAccessTokenAsync(CancellationToken cancellationToken); +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj index b83f9e8f..a2b8d2e2 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj @@ -7,6 +7,10 @@ AdaptiveRemote.Backend.CompiledLayoutService + + + + diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Configuration/CognitoSettings.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Configuration/CognitoSettings.cs new file mode 100644 index 00000000..659b87d8 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Configuration/CognitoSettings.cs @@ -0,0 +1,19 @@ +namespace AdaptiveRemote.Backend.CompiledLayoutService.Configuration; + +/// +/// Configuration for AWS Cognito JWT validation. +/// Maps to the "Cognito" section in appsettings.json. +/// +public class CognitoSettings +{ + /// + /// The Cognito user pool authority URL, e.g. + /// https://cognito-idp.{region}.amazonaws.com/{userPoolId} + /// + public string Authority { get; set; } = string.Empty; + + /// + /// The OAuth2 audience (app client ID). If empty, audience validation is skipped. + /// + public string? Audience { get; set; } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs index 665016a3..e18b116a 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using AdaptiveRemote.Backend.CompiledLayoutService.Logging; using AdaptiveRemote.Contracts; @@ -9,18 +10,26 @@ public static void MapLayoutEndpoints(this IEndpointRouteBuilder app) { app.MapGet("/layouts/compiled/active", GetActiveLayout) .WithName(nameof(GetActiveLayout)) - .Produces(StatusCodes.Status200OK); + .Produces(StatusCodes.Status200OK) + .RequireAuthorization(); } private static async Task GetActiveLayout( + ClaimsPrincipal user, ILogger logger, ICompiledLayoutRepository repository, CancellationToken cancellationToken) { - logger.GetActiveLayoutRequested(); + string? userId = user.FindFirst("sub")?.Value; + if (userId is null) + { + // Should not happen when RequireAuthorization() is in effect and the token + // is a valid Cognito JWT, but guard defensively. + return Results.Unauthorized(); + } + + logger.GetActiveLayoutRequested(userId); - // For MVP, we use a hardcoded userId. Auth will provide real userId in ADR-168. - string userId = "mvp-user"; CompiledLayout? layout = await repository.GetActiveForUserAsync(userId, cancellationToken); if (layout == null) @@ -30,7 +39,6 @@ private static async Task GetActiveLayout( logger.ReturningActiveLayout(layout.Id); - // Use the LayoutContractsJsonContext for serialization return Results.Json( layout, LayoutContractsJsonContext.Default.CompiledLayout); diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs index 6f926c09..761f67ff 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs @@ -16,8 +16,8 @@ public static partial class MessageLogger [LoggerMessage(EventId = 1101, Level = LogLevel.Information, Message = "CompiledLayoutService started successfully on {ListenAddress}")] public static partial void ServiceStarted(this ILogger logger, string listenAddress); - [LoggerMessage(EventId = 1102, Level = LogLevel.Information, Message = "GET /layouts/compiled/active request received")] - public static partial void GetActiveLayoutRequested(this ILogger logger); + [LoggerMessage(EventId = 1102, Level = LogLevel.Information, Message = "GET /layouts/compiled/active request received for userId={UserId}")] + public static partial void GetActiveLayoutRequested(this ILogger logger, string userId); [LoggerMessage(EventId = 1103, Level = LogLevel.Information, Message = "Returning active compiled layout Id={LayoutId}")] public static partial void ReturningActiveLayout(this ILogger logger, Guid layoutId); diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index 0c642748..e03e5bb2 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -1,7 +1,9 @@ +using AdaptiveRemote.Backend.CompiledLayoutService.Configuration; using AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; using AdaptiveRemote.Backend.CompiledLayoutService.Logging; using AdaptiveRemote.Backend.CompiledLayoutService.Services; using AdaptiveRemote.Contracts; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -11,16 +13,52 @@ // Register services builder.Services.AddSingleton(); +// Configure JWT Bearer authentication with AWS Cognito +CognitoSettings cognitoSettings = builder.Configuration + .GetSection("Cognito") + .Get() ?? new CognitoSettings(); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = cognitoSettings.Authority; + + if (!string.IsNullOrEmpty(cognitoSettings.Audience)) + { + options.Audience = cognitoSettings.Audience; + } + else + { + // When no audience is configured, skip audience validation. + options.TokenValidationParameters.ValidateAudience = false; + } + + // Preserve original claim names from the JWT (don't remap to .NET claim types). + options.MapInboundClaims = false; + + // Allow HTTP metadata endpoints in non-production environments (local dev and tests). + options.RequireHttpsMetadata = builder.Environment.IsProduction(); + }); + +builder.Services.AddAuthorization(); + WebApplication app = builder.Build(); ILogger logger = app.Services.GetRequiredService>(); logger.ServiceStarting(); +app.UseAuthentication(); +app.UseAuthorization(); + // Map endpoints app.MapHealthEndpoints(); app.MapLayoutEndpoints(); -string listenAddress = app.Urls.FirstOrDefault() ?? "http://localhost:5000"; +// Log the configured listen address; fall back to Kestrel's default. +// ASPNETCORE_URLS is the standard env-var; "urls" is the equivalent command-line key. +string listenAddress = app.Configuration["ASPNETCORE_URLS"] + ?? app.Configuration["urls"] + ?? "http://localhost:5000"; logger.ServiceStarted(listenAddress); app.Run(); diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json new file mode 100644 index 00000000..665343d2 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "AdaptiveRemote.Backend.CompiledLayoutService": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54433;http://localhost:54434" + } + } +} \ No newline at end of file diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md b/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md new file mode 100644 index 00000000..843ce8e5 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md @@ -0,0 +1,90 @@ +# Authentication — CompiledLayoutService (ADR-168) + +## Overview + +External API endpoints are protected by JWT Bearer authentication backed by **AWS Cognito**. +The service validates bearer tokens on every protected request; `/health` is unauthenticated. + +The `sub` claim from the validated JWT is used as the `userId` throughout the service. + +## Authentication flows + +| Client | Flow | Trigger | +|--------|------|---------| +| Client application (WPF) | OAuth2 **Client Credentials** | Unattended machine; acquires token at startup, refreshes automatically | +| Editor application (Blazor WASM) | OAuth2 **Authorization Code** | Browser-based interactive login via Cognito Hosted UI | + +## Cognito dev user pool setup + +1. Create a Cognito user pool in your AWS dev account. +2. Under **App clients**, create two app clients: + - `adaptiveremote-client` — enable Client Credentials flow; note `client_id` and `client_secret`. + - `adaptiveremote-editor` — enable Authorization Code flow; configure allowed callback URL. +3. Create a resource server (custom scope), e.g. `adaptiveremote/layouts.read`. +4. Note the user pool's **Issuer URL** (shown in the pool's details page): + `https://cognito-idp.{region}.amazonaws.com/{userPoolId}` + +## Configuring the backend service + +Set these environment variables (or values in `appsettings.Development.json` — never commit secrets): + +| Variable | Example | +|----------|---------| +| `Cognito__Authority` | `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123` | +| `Cognito__Audience` | `` (optional; leave empty to skip audience validation) | + +For local development via `docker-compose`, set `COGNITO_AUTHORITY` and `COGNITO_AUDIENCE` in a +`.env` file at the repository root (excluded from source control by `.gitignore`). + +## Configuring the client application (Client Credentials) + +Set in `appsettings.Development.json` (non-secret values) and user secrets (secrets): + +```json +{ + "backend": { + "baseUrl": "http://localhost:8080", + "cognito": { + "authority": "https://cognito-idp.{region}.amazonaws.com/{userPoolId}", + "clientId": "YOUR_CLIENT_ID", + "scope": "adaptiveremote/layouts.read" + } + } +} +``` + +Add `clientSecret` to user secrets only: +```bash +dotnet user-secrets set "backend:cognito:clientSecret" "YOUR_CLIENT_SECRET" \ + --project src/AdaptiveRemote/AdaptiveRemote.csproj +``` + +The `CognitoTokenService` in `src/AdaptiveRemote.App/Services/Backend/` discovers the token +endpoint from the OIDC configuration document and acquires/refreshes tokens automatically. + +## Configuring the editor application (Authorization Code) + +The editor application (Blazor WASM — separate epic) uses the Cognito Hosted UI for login. +The required configuration is: + +1. Set the `adaptiveremote-editor` app client's callback URL to the editor's redirect URI. +2. Configure the editor app with `cognitoAuthorizeUrl`, `clientId`, and `redirectUri` (no + client secret — public client, PKCE required). +3. On sign-in, the Cognito Hosted UI redirects back with an authorization code; the editor + exchanges it for tokens using PKCE. + +Full setup instructions will be added to the editor epic's documentation when implemented. + +## Internal endpoints + +`LayoutCompilerService` and `LayoutValidationService` are hosted as **AWS Lambda functions** +with **Lambda Function URLs**. These URLs are not exposed via API Gateway and are accessible +only from within the ECS cluster (network isolation via VPC/security groups). No bearer token +validation is required or expected on internal Lambda endpoints. + +## API integration tests + +Tests use a `TestJwtAuthority` (`test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs`), +a minimal local OIDC/JWKS server started per scenario. The service is configured with +`Cognito__Authority` pointing at this server, so JWT validation runs end-to-end without a +real Cognito user pool. See `AuthenticationEndpoints.feature` for the test scenarios. diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json index 34f00ef1..3ae5de58 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json @@ -4,5 +4,9 @@ "Default": "Debug", "Microsoft.AspNetCore": "Information" } + }, + "Cognito": { + "Authority": "", + "Audience": "" } } diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json index 10f68b8c..a013ae07 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json @@ -5,5 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Cognito": { + "Authority": "", + "Audience": "" + } } diff --git a/src/AdaptiveRemote/AdaptiveRemote.csproj b/src/AdaptiveRemote/AdaptiveRemote.csproj index bd80892e..a9085492 100644 --- a/src/AdaptiveRemote/AdaptiveRemote.csproj +++ b/src/AdaptiveRemote/AdaptiveRemote.csproj @@ -20,6 +20,11 @@ + + + + + diff --git a/src/AdaptiveRemote/appsettings.Development.json b/src/AdaptiveRemote/appsettings.Development.json new file mode 100644 index 00000000..27804e97 --- /dev/null +++ b/src/AdaptiveRemote/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "backend": { + "baseUrl": "http://localhost:8080", + "cognito": { + "authority": "", + "clientId": "", + "clientSecret": "", + "scope": "" + } + } +} diff --git a/src/AdaptiveRemote/appsettings.json b/src/AdaptiveRemote/appsettings.json new file mode 100644 index 00000000..a262e854 --- /dev/null +++ b/src/AdaptiveRemote/appsettings.json @@ -0,0 +1,11 @@ +{ + "backend": { + "baseUrl": "", + "cognito": { + "authority": "", + "clientId": "", + "clientSecret": "", + "scope": "" + } + } +} diff --git a/src/_doc_Projects.md b/src/_doc_Projects.md index e81f19de..97fb460a 100644 --- a/src/_doc_Projects.md +++ b/src/_doc_Projects.md @@ -41,6 +41,15 @@ This document describes the high-level organization of the AdaptiveRemote reposi - No MVVM or runtime behavior — DTOs only. - Included in both `client.slnf` and `backend.slnf`. +## Backend Projects + +Backend services live under `src/` alongside client projects. Use `backend.slnf` to build only the backend set. See [`_spec_LayoutCustomizationService.md`](_spec_LayoutCustomizationService.md) for the full architecture. + +### AdaptiveRemote.Backend.CompiledLayoutService +- **Purpose:** Serves compiled layouts to the client application via REST API. +- **Authentication:** JWT Bearer via AWS Cognito. See [`AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md`](AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md). +- **Pattern:** All backend services follow the logging, health endpoint, and structured log patterns established here (see ADR-167/ADR-168). + ## Test Projects ### AdaptiveRemote.App.Tests diff --git a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj index d8f66699..f42778e2 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj +++ b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj @@ -10,10 +10,13 @@ - + + + + diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature new file mode 100644 index 00000000..61d24d7b --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature @@ -0,0 +1,21 @@ +Feature: CompiledLayoutService Authentication + +Scenario: Unauthenticated request is rejected + Given CompiledLayoutService is running + When a test client with no Authorization header calls GET /layouts/compiled/active + Then the response is 401 Unauthorized + +Scenario: Request with valid JWT is accepted + Given CompiledLayoutService is running + When a test client with a valid JWT calls GET /layouts/compiled/active + Then the response is 200 OK + +Scenario: Request with expired JWT is rejected + Given CompiledLayoutService is running + When a test client with an expired JWT calls GET /layouts/compiled/active + Then the response is 401 Unauthorized + +Scenario: Health endpoint is accessible without authentication + Given CompiledLayoutService is running + When a test client with no Authorization header calls GET /health + Then the response is 200 OK diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs new file mode 100644 index 00000000..2fc44389 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs @@ -0,0 +1,261 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace AdaptiveRemote.Backend.ApiTests.Features +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class CompiledLayoutServiceAuthenticationFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = ((string[])(null)); + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "CompiledLayoutService Authentication", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "AuthenticationEndpoints.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute(Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupBehavior.EndOfClass)] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/AuthenticationEndpoints.feature.ndjson", 6); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Unauthenticated request is rejected")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Unauthenticated request is rejected")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Authentication")] + public async global::System.Threading.Tasks.Task UnauthenticatedRequestIsRejected() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Unauthenticated request is rejected", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 3 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 5 + await testRunner.WhenAsync("a test client with no Authorization header calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 6 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Request with valid JWT is accepted")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Request with valid JWT is accepted")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Authentication")] + public async global::System.Threading.Tasks.Task RequestWithValidJWTIsAccepted() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Request with valid JWT is accepted", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 8 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 9 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 10 + await testRunner.WhenAsync("a test client with a valid JWT calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 11 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Request with expired JWT is rejected")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Request with expired JWT is rejected")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Authentication")] + public async global::System.Threading.Tasks.Task RequestWithExpiredJWTIsRejected() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "2"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Request with expired JWT is rejected", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 13 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 14 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 15 + await testRunner.WhenAsync("a test client with an expired JWT calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 16 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Health endpoint is accessible without authentication")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Health endpoint is accessible without authentication")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Authentication")] + public async global::System.Threading.Tasks.Task HealthEndpointIsAccessibleWithoutAuthentication() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "3"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Health endpoint is accessible without authentication", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 18 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 19 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 20 + await testRunner.WhenAsync("a test client with no Authorization header calls GET /health", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 21 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs new file mode 100644 index 00000000..7def9989 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs @@ -0,0 +1,57 @@ +using System.Net; +using AdaptiveRemote.Backend.ApiTests.Support; +using FluentAssertions; +using Reqnroll; + +namespace AdaptiveRemote.Backend.ApiTests.StepDefinitions; + +[Binding] +public class AuthenticationSteps : IDisposable +{ + private readonly ServiceContext _context; + + public AuthenticationSteps(ServiceContext context) + { + _context = context; + } + + [When(@"a test client with no Authorization header calls GET (.*)")] + public async Task WhenAnonymousClientCallsGet(string endpoint) + { + using HttpClient client = _context.Fixture.CreateAnonymousHttpClient(); + _context.LastResponse = await client.GetAsync(endpoint); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client with a valid JWT calls GET (.*)")] + public async Task WhenAuthenticatedClientCallsGet(string endpoint) + { + string token = _context.Fixture.CreateToken(); + using HttpClient client = _context.Fixture.CreateBearerHttpClient(token); + _context.LastResponse = await client.GetAsync(endpoint); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client with an expired JWT calls GET (.*)")] + public async Task WhenExpiredJwtClientCallsGet(string endpoint) + { + string token = _context.Fixture.CreateExpiredToken(); + using HttpClient client = _context.Fixture.CreateBearerHttpClient(token); + _context.LastResponse = await client.GetAsync(endpoint); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [Then(@"the response is (\d+) Unauthorized")] + public void ThenTheResponseIsUnauthorized(int statusCode) + { + _context.LastResponse.Should().NotBeNull(); + ((int)_context.LastResponse!.StatusCode).Should().Be(statusCode); + _context.LastResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + public void Dispose() + { + // ServiceContext owns LastResponse and Fixture; nothing to dispose here. + GC.SuppressFinalize(this); + } +} diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs index 1481184b..a05241fb 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs @@ -10,37 +10,40 @@ namespace AdaptiveRemote.Backend.ApiTests.StepDefinitions; [Binding] public class CommonSteps : IDisposable { - private readonly ServiceFixture _fixture = new(); - private HttpResponseMessage? _response; - private string? _responseBody; + private readonly ServiceContext _context; + + public CommonSteps(ServiceContext context) + { + _context = context; + } [Given(@"CompiledLayoutService is running")] - public void GivenCompiledLayoutServiceIsRunning() + public async Task GivenCompiledLayoutServiceIsRunning() { - _fixture.StartService(); + await _context.Fixture.StartServiceAsync(); } [When(@"a test client calls GET (.*)")] public async Task WhenATestClientCallsGet(string endpoint) { - _response = await _fixture.HttpClient.GetAsync(endpoint); - _responseBody = await _response.Content.ReadAsStringAsync(); + _context.LastResponse = await _context.Fixture.HttpClient.GetAsync(endpoint); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); } [Then(@"the response is (\d+) OK")] public void ThenTheResponseIsOk(int statusCode) { - _response.Should().NotBeNull(); - ((int)_response!.StatusCode).Should().Be(statusCode); + _context.LastResponse.Should().NotBeNull(); + ((int)_context.LastResponse!.StatusCode).Should().Be(statusCode); } [Then(@"the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext")] public void ThenTheBodyDeserializesToValidCompiledLayout() { - _responseBody.Should().NotBeNullOrEmpty(); + _context.LastResponseBody.Should().NotBeNullOrEmpty(); CompiledLayout? layout = JsonSerializer.Deserialize( - _responseBody!, + _context.LastResponseBody!, LayoutContractsJsonContext.Default.CompiledLayout); layout.Should().NotBeNull(); @@ -51,17 +54,17 @@ public void ThenTheBodyDeserializesToValidCompiledLayout() [Then(@"the CompiledLayout contains the expected hardcoded commands")] public void ThenTheCompiledLayoutContainsExpectedCommands() { - _responseBody.Should().NotBeNullOrEmpty(); + _context.LastResponseBody.Should().NotBeNullOrEmpty(); CompiledLayout? layout = JsonSerializer.Deserialize( - _responseBody!, + _context.LastResponseBody!, LayoutContractsJsonContext.Default.CompiledLayout); layout.Should().NotBeNull(); - + // Verify key commands from StaticCommandGroupProvider exist List commands = ExtractAllCommands(layout!.Elements); - + commands.Should().Contain(c => c.Name == "Up" && c.Type == CommandType.TiVo); commands.Should().Contain(c => c.Name == "Select" && c.Type == CommandType.TiVo); commands.Should().Contain(c => c.Name == "Power" && c.Type == CommandType.IR); @@ -72,14 +75,14 @@ public void ThenTheCompiledLayoutContainsExpectedCommands() [Then(@"the service logs contain a request log entry for GET (.*)")] public void ThenTheServiceLogsContainRequestLogEntry(string endpoint) { - string logs = _fixture.GetLogs(); + string logs = _context.Fixture.GetLogs(); logs.Should().Contain(endpoint); } [Then(@"the service logs contain no warnings or errors")] public void ThenTheServiceLogsContainNoWarningsOrErrors() { - string logs = _fixture.GetLogs(); + string logs = _context.Fixture.GetLogs(); logs.Should().NotContain("WARNING", "service should not log warnings"); logs.Should().NotContain("ERROR", "service should not log errors"); logs.Should().NotContain("Exception", "service should not log exceptions"); @@ -88,10 +91,10 @@ public void ThenTheServiceLogsContainNoWarningsOrErrors() [Then(@"the body contains the service name and version")] public void ThenTheBodyContainsServiceNameAndVersion() { - _responseBody.Should().NotBeNullOrEmpty(); + _context.LastResponseBody.Should().NotBeNullOrEmpty(); HealthResponse? healthResponse = JsonSerializer.Deserialize( - _responseBody!, + _context.LastResponseBody!, LayoutContractsJsonContext.Default.HealthResponse); healthResponse.Should().NotBeNull(); @@ -103,7 +106,7 @@ public void ThenTheBodyContainsServiceNameAndVersion() private static List ExtractAllCommands(IReadOnlyList elements) { List commands = new(); - + foreach (LayoutElementDto element in elements) { if (element is CommandDefinitionDto command) @@ -115,14 +118,13 @@ private static List ExtractAllCommands(IReadOnlyList +/// Reqnroll context-injection container shared across all step definition classes +/// within a single scenario. +/// +/// Holds: +/// - : the running service instance +/// - / : the most recent +/// HTTP response, set by When steps and read by Then steps. +/// +/// Reqnroll creates one instance per scenario and disposes it at scenario end. +/// +public class ServiceContext : IDisposable +{ + public ServiceFixture Fixture { get; } = new(); + + public HttpResponseMessage? LastResponse { get; set; } + public string? LastResponseBody { get; set; } + + public void Dispose() + { + LastResponse?.Dispose(); + Fixture.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs index ea39cfa4..25b9f59b 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs @@ -1,4 +1,7 @@ using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; using System.Text; namespace AdaptiveRemote.Backend.ApiTests.Support; @@ -6,23 +9,44 @@ namespace AdaptiveRemote.Backend.ApiTests.Support; /// /// Manages the lifecycle of CompiledLayoutService for API integration tests. /// Starts the service process and captures structured log output. +/// +/// A is started before the service so that the +/// service can be configured with a real (but local) JWT authority. The +/// exposed to tests automatically includes a valid +/// bearer token. For authentication-specific tests, use +/// and to build +/// tokens, and send them via or +/// directly. /// public class ServiceFixture : IDisposable { private Process? _serviceProcess; private readonly StringBuilder _logOutput = new(); private readonly object _logLock = new(); + private TestJwtAuthority? _jwtAuthority; - public string ServiceUrl { get; private set; } = "http://localhost:5000"; + public string ServiceUrl { get; } + + /// + /// HttpClient pre-configured with a valid bearer token for the test user. + /// public HttpClient HttpClient { get; private set; } = null!; - public void StartService() + public ServiceFixture() + { + ServiceUrl = $"http://localhost:{GetFreePort()}"; + } + + public async Task StartServiceAsync() { if (_serviceProcess != null) { return; // Already started } + // Start the JWT authority first so its URL is available for service configuration. + _jwtAuthority = new TestJwtAuthority(); + // Find the repository root by looking for the .git directory string currentDir = Directory.GetCurrentDirectory(); string? repoRoot = currentDir; @@ -49,7 +73,9 @@ public void StartService() ProcessStartInfo startInfo = new() { FileName = "dotnet", - Arguments = $"run --project \"{projectPath}\" --no-build", + // --no-launch-profile prevents launchSettings.json from overriding + // ASPNETCORE_URLS with its applicationUrl setting. + Arguments = $"run --project \"{projectPath}\" --no-build --no-launch-profile", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -57,7 +83,9 @@ public void StartService() Environment = { ["ASPNETCORE_ENVIRONMENT"] = "Development", - ["ASPNETCORE_URLS"] = ServiceUrl + ["ASPNETCORE_URLS"] = ServiceUrl, + // Point the service at the local test JWT authority. + ["Cognito__Authority"] = _jwtAuthority.Authority, } }; @@ -89,41 +117,92 @@ public void StartService() _serviceProcess.BeginOutputReadLine(); _serviceProcess.BeginErrorReadLine(); - // Wait for service to be ready - poll for health endpoint - HttpClient = new HttpClient { BaseAddress = new Uri(ServiceUrl) }; + // Poll /health with a temporary unauthenticated client (/health is open). + // Use a short per-request timeout so a slow/stuck response doesn't block the loop. + using HttpClient healthClient = new() + { + BaseAddress = new Uri(ServiceUrl), + Timeout = TimeSpan.FromSeconds(5), + }; bool isReady = false; for (int i = 0; i < 30 && !_serviceProcess.HasExited; i++) { - DateTime startTime = DateTime.Now; try { - HttpResponseMessage response = HttpClient.GetAsync("/health").Result; + HttpResponseMessage response = await healthClient + .GetAsync("/health") + .ConfigureAwait(false); if (response.IsSuccessStatusCode) { isReady = true; break; } + + lock (_logLock) + { + _logOutput.AppendLine($"[HealthCheck attempt {i + 1}] HTTP {(int)response.StatusCode} from {ServiceUrl}/health"); + } } - catch + catch (Exception ex) { - // Service not ready yet + lock (_logLock) + { + _logOutput.AppendLine($"[HealthCheck attempt {i + 1}] Exception polling {ServiceUrl}/health: {ex.GetType().Name}: {ex.Message}"); + } } - TimeSpan sleepTime = TimeSpan.FromSeconds(1000) - (DateTime.Now - startTime); - if (sleepTime > TimeSpan.Zero) - { - Thread.Sleep(sleepTime); - } + await Task.Delay(1000).ConfigureAwait(false); } if (!isReady) { string logs = GetLogs(); - throw new InvalidOperationException($"Service failed to start within 30 seconds. Logs:\n{logs}"); + throw new InvalidOperationException($"Service failed to start within 30 seconds (polling {ServiceUrl}/health). Logs:\n{logs}"); } + + // Default HttpClient includes a valid bearer token for the standard test user. + HttpClient = CreateBearerHttpClient(CreateToken()); } + /// + /// Creates a valid JWT for the given subject (default: "test-user"). + /// + public string CreateToken(string sub = "test-user") + { + if (_jwtAuthority is null) + { + throw new InvalidOperationException("StartServiceAsync() must be called before CreateToken()"); + } + + return _jwtAuthority.CreateToken(sub); + } + + /// + /// Creates an expired JWT. + /// + public string CreateExpiredToken() + { + if (_jwtAuthority is null) + { + throw new InvalidOperationException("StartServiceAsync() must be called before CreateExpiredToken()"); + } + + return _jwtAuthority.CreateExpiredToken(); + } + + /// + /// Creates an HttpClient with no Authorization header (for testing 401 responses). + /// + public HttpClient CreateAnonymousHttpClient() + => new() { BaseAddress = new Uri(ServiceUrl) }; + + /// + /// Creates an HttpClient that sends the given bearer token on every request. + /// + public HttpClient CreateBearerHttpClient(string token) + => new(new BearerTokenHandler(token)) { BaseAddress = new Uri(ServiceUrl) }; + public string GetLogs() { lock (_logLock) @@ -142,6 +221,38 @@ public void Dispose() } HttpClient?.Dispose(); + _jwtAuthority?.Dispose(); GC.SuppressFinalize(this); } + + private static int GetFreePort() + { + using TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// + /// Adds a bearer token to every outgoing request. + /// + private sealed class BearerTokenHandler : DelegatingHandler + { + private readonly string _token; + + public BearerTokenHandler(string token) + : base(new HttpClientHandler()) + { + _token = token; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + return base.SendAsync(request, cancellationToken); + } + } } diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs new file mode 100644 index 00000000..226f948a --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs @@ -0,0 +1,188 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.IdentityModel.Tokens; + +namespace AdaptiveRemote.Backend.ApiTests.Support; + +/// +/// A minimal local OIDC/JWKS authority used by API integration tests to issue and +/// validate JWTs without a real Cognito user pool. +/// +/// Exposes two endpoints on a dynamically-assigned localhost port: +/// GET /.well-known/openid-configuration — OIDC discovery document +/// GET /.well-known/jwks.json — RSA public key in JWK format +/// +/// The service under test is configured to use this authority via the +/// Cognito__Authority environment variable so that bearer token validation +/// is exercised end-to-end without external dependencies. +/// +public sealed class TestJwtAuthority : IDisposable +{ + private const string TestAudience = "api-tests"; + + private readonly RSA _rsa; + private readonly RsaSecurityKey _signingKey; + private readonly string _keyId; + private readonly HttpListener _listener; + private readonly Thread _listenerThread; + private volatile bool _stopping; + + /// The authority URL the service under test should be configured with. + public string Authority { get; } + + public TestJwtAuthority() + { + int port = GetFreePort(); + Authority = $"http://localhost:{port}"; + + _rsa = RSA.Create(2048); + _keyId = Guid.NewGuid().ToString("N")[..8]; + _signingKey = new RsaSecurityKey(_rsa) { KeyId = _keyId }; + + _listener = new HttpListener(); + _listener.Prefixes.Add($"{Authority}/"); + _listener.Start(); + + _listenerThread = new Thread(HandleRequests) { IsBackground = true, Name = "TestJwtAuthority" }; + _listenerThread.Start(); + } + + /// + /// Creates a signed JWT with the given subject claim, valid for one hour. + /// + public string CreateToken(string sub) + => CreateTokenCore(sub, expired: false); + + /// + /// Creates a signed JWT that is already expired (issued/expiry in the past). + /// + public string CreateExpiredToken(string sub = "test-user") + => CreateTokenCore(sub, expired: true); + + private string CreateTokenCore(string sub, bool expired) + { + DateTime now = DateTime.UtcNow; + DateTime notBefore = expired ? now.AddHours(-2) : now.AddSeconds(-5); + DateTime expires = expired ? now.AddHours(-1) : now.AddHours(1); + + JwtSecurityTokenHandler handler = new(); + JwtSecurityToken token = handler.CreateJwtSecurityToken( + issuer: Authority, + audience: TestAudience, + subject: new ClaimsIdentity([new Claim("sub", sub)]), + notBefore: notBefore, + expires: expires, + signingCredentials: new SigningCredentials(_signingKey, SecurityAlgorithms.RsaSha256)); + + return handler.WriteToken(token); + } + + private void HandleRequests() + { + while (!_stopping) + { + HttpListenerContext context; + try + { + context = _listener.GetContext(); + } + catch (HttpListenerException) when (_stopping) + { + return; + } + catch (ObjectDisposedException) + { + return; + } + + try + { + HandleRequest(context); + } + catch + { + // Do not crash the listener thread on individual request errors. + } + } + } + + private void HandleRequest(HttpListenerContext context) + { + string path = context.Request.Url?.AbsolutePath ?? string.Empty; + context.Response.ContentType = "application/json"; + + byte[] body = path switch + { + "/.well-known/openid-configuration" => BuildDiscoveryDocument(), + "/.well-known/jwks.json" => BuildJwks(), + _ => BuildNotFound(context), + }; + + context.Response.ContentLength64 = body.Length; + context.Response.OutputStream.Write(body, 0, body.Length); + context.Response.Close(); + } + + private byte[] BuildDiscoveryDocument() + { + string json = JsonSerializer.Serialize(new + { + issuer = Authority, + jwks_uri = $"{Authority}/.well-known/jwks.json", + token_endpoint = $"{Authority}/oauth2/token", + }); + return System.Text.Encoding.UTF8.GetBytes(json); + } + + private byte[] BuildJwks() + { + RSAParameters rsaParams = _rsa.ExportParameters(includePrivateParameters: false); + + string n = Base64UrlEncoder.Encode(rsaParams.Modulus!); + string e = Base64UrlEncoder.Encode(rsaParams.Exponent!); + + string json = JsonSerializer.Serialize(new + { + keys = new[] + { + new + { + kty = "RSA", + use = "sig", + alg = "RS256", + kid = _keyId, + n, + e, + } + } + }); + return System.Text.Encoding.UTF8.GetBytes(json); + } + + private static byte[] BuildNotFound(HttpListenerContext context) + { + context.Response.StatusCode = 404; + return System.Text.Encoding.UTF8.GetBytes("{}"); + } + + private static int GetFreePort() + { + using System.Net.Sockets.TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public void Dispose() + { + _stopping = true; + _listener.Stop(); + _listenerThread.Join(timeout: TimeSpan.FromSeconds(2)); + _listener.Close(); + _rsa.Dispose(); + } +} From 308de70e9d24dcbd8eb3c6cea694197238505023 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Sat, 18 Apr 2026 12:11:54 -0700 Subject: [PATCH 06/11] Add task ADR-187 to the spec, creating a consistent local development experience for backend services. Also added guidance for adding a task to a spec, for future additions. --- .claude/commands/add-to-spec.md | 126 ++++++++++++++++++++++++ CLAUDE.md | 1 + src/_doc_BackendDevelopment.md | 18 ++++ src/_spec_LayoutCustomizationService.md | 98 +++++++++++++++++- 4 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 .claude/commands/add-to-spec.md create mode 100644 src/_doc_BackendDevelopment.md diff --git a/.claude/commands/add-to-spec.md b/.claude/commands/add-to-spec.md new file mode 100644 index 00000000..8a506cdf --- /dev/null +++ b/.claude/commands/add-to-spec.md @@ -0,0 +1,126 @@ +--- +description: Add a new requirement or task to an existing _spec_*.md file, iterating with the user until the content is ready +argument-hint: +--- + +## Requirement to add + +Jira Task $ARGUMENTS + +## Available spec files + +!`find . -name "_spec_*.md" -not -path "./.git/*" | sort` + +## Available architecture docs + +!`find . -name "_doc_*.md" -not -path "./.git/*" | sort` + +## Workflow + +This is an iterative process. Follow the phases below. Never skip ahead — +always wait for user input at each pause point before continuing. + +--- + +### Phase 1 — Orient and gather context + +1. Identify the target spec file. If ARGUMENTS contains a Jira key, fetch the issue. + The target spec should be determined by the Jira work item's parent. + If the target spec is not obvious, ask the user which spec to add to. +2. Read the spec file in full. Note: existing tasks, task numbering, insertion point, + and patterns (checklist format, Gherkin scenarios, exit criteria structure). +3. Read any `_doc_*.md` files relevant to the area the new requirement touches. + +Then ask the user a focused set of questions to fill gaps the Jira description and spec +don't answer. Good questions cover: + +- Scope boundaries (what this task owns vs. defers) +- Integration points with existing tasks or services +- Key design choices where multiple reasonable approaches exist +- Whether new patterns established here must be retrofitted to earlier tasks + +Ask all your questions at once. Skip anything you can already answer. + +**PAUSE — wait for the user's answers before continuing.** + +If answers raise new ambiguities that materially affect the content, ask one more targeted +follow-up round. Otherwise proceed to Phase 2. + +--- + +### Phase 2 — Draft the new content + +1. Draft any new spec sections and update existing sections to reflect new decisions. +2. Draft the new task section using the existing spec's task format: + - `### Task N — ([ADR-XXX](<url>))` + - One-paragraph description + - Checklist items (`- [ ] ...`) + - Gherkin-style acceptance scenarios for any items that describe observable behavior +3. Identify any exit criteria that should be added to other tasks: + - Patterns this task establishes that all future tasks must follow + - Stubs or placeholders in earlier tasks that this task replaces +4. Determine the new task number and which existing tasks (if any) need renumbering. + +Write the draft to the spec file: +- Insert the new task at the correct position +- Update any other tasks with new exit criteria +- Renumber task headings if needed (ADR links and checklist content are never changed + by renumbering — only the `### Task N —` prefix) + +After writing, tell the user: + +> Draft written to `<path>`. Please review it — edit any section directly and add +> `> **Review:** your comment or question` anywhere you want a change made. Tell me +> when you're ready for the next pass. + +**PAUSE — wait for the user to review and signal readiness.** + +--- + +### Phase 3 — Iterative refinement + +When the user signals they're ready: + +1. Re-read the spec file. +2. Collect all `> **Review:** ...` markers and any direct edits. +3. Address review comments **one at a time** in document order: + a. Present your analysis — trade-offs and recommendation. + b. **PAUSE — wait for the user's decision before editing.** + c. Update the spec; remove the review marker. + d. State what changed; move to the next comment. +4. After all comments are resolved, invite another review pass. + +Repeat Phase 3 until the user says the content is ready. + +--- + +### Phase 4 — Implementation readiness review + +Read the requirements from the perspective of a code agent assigned to implement them with +no additional context. Ask: + +- Can you implement every checklist item without guessing? +- Can you write unit tests and Gherkin scenarios without guessing at expected behavior? +- Are there missing error cases, unspecified interfaces, or ambiguous behavior? +- Does new content interact with existing tasks in ways that need to be reflected there + (e.g., a new pattern that earlier tasks must also adopt, or a stub that a later task + will replace)? + +If you find gaps, list them all and ask clarifying questions (all at once). + +**PAUSE if you asked questions — wait for answers before continuing.** + +Otherwise proceed to Phase 5. + +--- + +### Phase 5 — Jira update + +Update the Jira task's description: replace initial design notes with a concise summary of +the finalized spec content. Include: +- A one-paragraph overview of what this task implements +- A bulleted list of key decisions and their outcomes +- A reference to the spec file: `See spec: <relative path>` + +The original Jira description may contain early "initial thoughts" that are now superseded; +replace it entirely rather than appending. diff --git a/CLAUDE.md b/CLAUDE.md index 05f7ac74..af48d4d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ Read the `_doc_*.md` file for any area you plan to modify: | UI components | `src/AdaptiveRemote.App/Components/_doc_UI.md` | | E2E test architecture | `test/_doc_EndToEndTests.md` | | Simulated devices | `test/AdaptiveRemote.EndToEndTests.TestServices/_doc_SimulatedDevices.md` | +| Backend services | `src/_doc_BackendDevelopment.md` | ## Tech Stack diff --git a/src/_doc_BackendDevelopment.md b/src/_doc_BackendDevelopment.md new file mode 100644 index 00000000..867b0807 --- /dev/null +++ b/src/_doc_BackendDevelopment.md @@ -0,0 +1,18 @@ +# Backend Development Guide + +> **Status:** Stub — to be populated during Task 5 ([ADR-187](https://jodasoft.atlassian.net/browse/ADR-187)) +> +> See `src/_spec_LayoutCustomizationService.md` Task 5 for the full exit criteria. + +## Agent Verification Step + +After every change to a backend service, verify the development environment still works: + +1. **With LocalStack running:** `dotnet run` (or F5 in VS) → confirm the service starts cleanly, log output appears in a console window, and `/scalar` is reachable in a browser +2. **With LocalStack stopped:** `dotnet run` → confirm the process exits with a non-zero code and the console names LocalStack as the missing dependency with a reference to `docs/local-dev.md` + +For Lambda functions: +1. Confirm F5 in VS opens the Lambda Test Tool UI +2. Confirm `aws lambda invoke --endpoint-url http://localhost:4566` returns a valid response + +> This section will be expanded with full setup details and patterns once Task 5 is implemented. diff --git a/src/_spec_LayoutCustomizationService.md b/src/_spec_LayoutCustomizationService.md index 246fd0d6..283c2cfd 100644 --- a/src/_spec_LayoutCustomizationService.md +++ b/src/_spec_LayoutCustomizationService.md @@ -749,7 +749,77 @@ Implement `AdaptiveRemote.Backend.RawLayoutService` with full CRUD backed by Dyn And GET /layouts/raw/{id} returns 404 Not Found ``` -### Task 5 — LayoutProcessingService (with stubs) ([ADR-170](https://jodasoft.atlassian.net/browse/ADR-170)) +### Task 5 — Development environment support ([ADR-187](https://jodasoft.atlassian.net/browse/ADR-187)) + +Establish a consistent developer experience across all backend services: local launch with a +separate console window, an interactive API browser (Scalar), startup dependency health checks +with actionable error messages, and a debuggable local invocation story for Lambda functions. +Applied retroactively to all services built in Tasks 1–4; required for all subsequent services. + +**ECS Fargate services** (CompiledLayoutService, RawLayoutService; pattern required for all +future Fargate services): + +- [ ] `Microsoft.AspNetCore.OpenApi` and `Scalar.AspNetCore` packages added; Scalar UI + registered via `app.MapScalarApiReference()` guarded by `app.Environment.IsDevelopment()`; + accessible at `/scalar` when running locally; **not** reachable in staging or production +- [ ] `launchSettings.json` includes a `Development` launch profile with + `"outputCapture": "None"` so F5 in VS opens a separate console window (not the VS Output + pane); `dotnet run` already outputs to the console natively — no extra config needed +- [ ] On startup, each service pings `/_localstack/health` on the configured LocalStack base + URL; if the request fails or returns a non-`running` status, a `[LoggerMessage]`-defined + `Error`-level message is emitted that names LocalStack as the missing dependency and + includes `"See docs/local-dev.md for setup instructions"`; `Environment.Exit(1)` is called + immediately after +- [ ] `docs/local-dev.md` created at the repo root; covers: Docker and Docker Compose + installation and the `docker-compose up -d` start command, confirming LocalStack is healthy + at `/_localstack/health`, and Cognito dev user pool credential setup; referenced from the + startup error message above + +**Lambda functions** (LayoutCompilerService, LayoutValidationService; pattern required for all +future Lambda services): + +- [ ] `amazon-lambda-testtool` (latest version supporting .NET 10) installed globally; + `launchSettings.json` includes a profile that launches the test tool so F5 in VS opens + the Lambda Test Tool UI for interactive invocation and debugging +- [ ] LocalStack Lambda emulation verified: function is deployed to LocalStack via + `docker-compose` on `up`, and invokable with + `aws lambda invoke --endpoint-url http://localhost:4566 --function-name <name> --payload '<json>' response.json` +- [ ] Sample invocation commands for each Lambda function (with minimal valid payloads) + documented in `docs/local-dev.md` + +**Shared — standing pattern for all future tasks:** + +- [ ] `src/_doc_BackendDevelopment.md` created; documents the agent verification step: + after every change to a backend service, run the service with LocalStack running (confirm + clean start) and with LocalStack stopped (confirm the startup error message and non-zero + exit); this doc is added to the CLAUDE.md "Read Before Making Changes" table under + "Backend services" +- [ ] All existing backend services (Tasks 1–4 outputs) retrofitted to meet the above + checklist; `dotnet build /warnaserror` passes; existing API integration tests pass + +```gherkin +Given LocalStack is not running +When a developer runs dotnet run for any ECS Fargate backend service +Then the process exits with a non-zero exit code +And the console output names LocalStack as the missing dependency +And the console output includes "See docs/local-dev.md for setup instructions" + +Given LocalStack is running +When a developer runs dotnet run for any ECS Fargate backend service +Then the service starts successfully +And navigating to /scalar in a browser shows the Scalar API UI +And log output is visible in a separate console window + +Given LocalStack is running with the Lambda function deployed +When a developer invokes the Lambda via aws cli with --endpoint-url http://localhost:4566 +Then the Lambda returns a valid response without error + +Given the Lambda Test Tool is installed +When a developer launches the Lambda project with F5 in Visual Studio +Then the Lambda Test Tool UI opens in a browser for interactive invocation +``` + +### Task 6 — LayoutProcessingService (with stubs) ([ADR-170](https://jodasoft.atlassian.net/browse/ADR-170)) Implement `AdaptiveRemote.Backend.LayoutProcessingService` with SQS polling and the full orchestration pipeline. `ILayoutCompilerClient` and `ILayoutValidationClient` are backed by @@ -782,8 +852,12 @@ end-to-end before the real Lambda functions are built in Tasks 6 and 7. And GET /layouts/raw/{id} returns a RawLayout with a non-null ValidationResult And ValidationResult.IsValid is false ``` +- [ ] Follows the dev environment pattern from Task 5: Scalar UI configured and guarded by + `IsDevelopment()`, console window launch profile present, LocalStack startup health check + implemented, and agent verification step completed (start with LocalStack running; start + with LocalStack stopped and confirm startup error) -### Task 6 — LayoutCompilerService (Lambda) ([ADR-171](https://jodasoft.atlassian.net/browse/ADR-171)) +### Task 7 — LayoutCompilerService (Lambda) ([ADR-171](https://jodasoft.atlassian.net/browse/ADR-171)) Implement `AdaptiveRemote.Backend.LayoutCompilerService` as a Native AOT Lambda, replacing the stub injected in Task 5. @@ -813,8 +887,11 @@ the stub injected in Task 5. And PreviewLayout.RenderedHtml is non-empty And PreviewLayout.RenderedCss is non-empty ``` +- [ ] Follows the Lambda dev environment pattern from Task 5: Lambda Test Tool launch profile + present, LocalStack deployment verified via `aws lambda invoke`, and agent verification step + completed -### Task 7 — LayoutValidationService (Lambda) ([ADR-172](https://jodasoft.atlassian.net/browse/ADR-172)) +### Task 8 — LayoutValidationService (Lambda) ([ADR-172](https://jodasoft.atlassian.net/browse/ADR-172)) Implement `AdaptiveRemote.Backend.LayoutValidationService` as a Native AOT Lambda, replacing the stub injected in Task 5. @@ -849,8 +926,11 @@ the stub injected in Task 5. And ValidationResult.IsValid is false And ValidationResult.Issues contains one issue referencing the duplicate CssId ``` +- [ ] Follows the Lambda dev environment pattern from Task 5: Lambda Test Tool launch profile + present, LocalStack deployment verified via `aws lambda invoke`, and agent verification step + completed -### Task 8 — CompiledLayoutService with DynamoDB ([ADR-173](https://jodasoft.atlassian.net/browse/ADR-173)) +### Task 9 — CompiledLayoutService with DynamoDB ([ADR-173](https://jodasoft.atlassian.net/browse/ADR-173)) Replace the static hardcoded response in `CompiledLayoutService` with real DynamoDB storage and active layout management. @@ -874,8 +954,12 @@ Replace the static hardcoded response in `CompiledLayoutService` with real Dynam And GET /layouts/compiled/active returns layout A And layout B is no longer active ``` +- [ ] Follows the dev environment pattern from Task 5: Scalar UI configured and guarded by + `IsDevelopment()`, console window launch profile present, LocalStack startup health check + implemented, and agent verification step completed (start with LocalStack running; start + with LocalStack stopped and confirm startup error) -### Task 9 — NotificationService (SSE) ([ADR-174](https://jodasoft.atlassian.net/browse/ADR-174)) +### Task 10 — NotificationService (SSE) ([ADR-174](https://jodasoft.atlassian.net/browse/ADR-174)) Implement `AdaptiveRemote.Backend.NotificationService` with SSE push for `layout-saved` and `layout-ready` events. @@ -898,6 +982,10 @@ Implement `AdaptiveRemote.Backend.NotificationService` with SSE push for `layout When one editor saves the layout Then both editors receive a layout-saved SSE event ``` +- [ ] Follows the dev environment pattern from Task 5: Scalar UI configured and guarded by + `IsDevelopment()`, console window launch profile present, LocalStack startup health check + implemented, and agent verification step completed (start with LocalStack running; start + with LocalStack stopped and confirm startup error) --- From f79eb6c516c2b79e7a4c3b5ecd5e9de90a81b6a4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:36:09 -0700 Subject: [PATCH 07/11] ADR-187: Add backend local-dev guardrails (Scalar, LocalStack startup checks, and docs) and unblock Linux full-solution builds (#145) * Update global.json to allow installed .NET SDKs in different environments * Implement ADR-187 backend dev environment foundations Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/b1ffa40a-c569-45a6-ad54-bf0f1a3681b0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> * Enable windows targeting for non-headless E2E host projects Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/b1ffa40a-c569-45a6-ad54-bf0f1a3681b0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> * Refine LocalStack health check diagnostics in backend startup Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/b1ffa40a-c569-45a6-ad54-bf0f1a3681b0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> * Fix LocalStack health parsing for dotnet run startup verification Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/d15fa3e9-7842-4324-aade-379b3c18dde0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> * Clarify LocalStack health response docs Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/d15fa3e9-7842-4324-aade-379b3c18dde0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> * Add LocalStack startup retries and exception-aware dependency logging Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/af738fc3-eb45-4437-8bb9-b96b3afb41f0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> * Address reviewer nits in LocalStack startup checks Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/af738fc3-eb45-4437-8bb9-b96b3afb41f0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> * Refine LocalStack timeout constants and naming consistency Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/af738fc3-eb45-4437-8bb9-b96b3afb41f0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> * Update _doc_Auth.md with details on how to get an access token for scalar testing. --------- Co-authored-by: Joe Davis <ElwoodMoves@hotmail.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- Directory.Packages.props | 2 + docker-compose.yml | 22 ++++ docs/local-dev.md | 98 ++++++++++++++ ...emote.Backend.CompiledLayoutService.csproj | 3 + .../Logging/MessageLogger.cs | 6 + .../Program.cs | 122 ++++++++++++++++++ .../Properties/launchSettings.json | 13 +- .../_doc_Auth.md | 36 +++++- .../appsettings.Development.json | 3 + src/_doc_BackendDevelopment.md | 42 ++++-- .../Support/ServiceFixture.cs | 4 +- .../Support/TestJwtAuthority.cs | 13 +- ...aptiveRemote.EndToEndTests.Host.Wpf.csproj | 1 + ...veRemote.EndToEndTests.Host.Console.csproj | 1 + 14 files changed, 348 insertions(+), 18 deletions(-) create mode 100644 docs/local-dev.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 55dfb32b..1651a0c7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ <PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.22" /> <PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Wpf" Version="8.0.100" /> <!-- Microsoft Extensions --> + <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Ini" Version="10.0.0" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.3" /> <!-- OpenTelemetry --> @@ -21,6 +22,7 @@ <!-- Third-party Libraries --> <PackageVersion Include="I8Beef.TiVo" Version="1.0.0.14" /> <PackageVersion Include="Microsoft.Playwright" Version="1.58.0" /> + <PackageVersion Include="Scalar.AspNetCore" Version="2.14.1" /> <PackageVersion Include="StreamJsonRpc" Version="2.24.84" /> <PackageVersion Include="System.Speech" Version="8.0.0" /> <PackageVersion Include="System.Text.Json" Version="8.0.5" /> diff --git a/docker-compose.yml b/docker-compose.yml index bba4ad9c..c06c3a06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,22 @@ version: '3.8' services: + localstack: + image: localstack/localstack:4.8 + container_name: adaptiveremote-localstack + ports: + - "4566:4566" + environment: + - SERVICES=lambda,dynamodb,sqs + - DEBUG=1 + - LAMBDA_EXECUTOR=docker + - AWS_DEFAULT_REGION=us-east-1 + volumes: + - localstack-data:/var/lib/localstack + - /var/run/docker.sock:/var/run/docker.sock + networks: + - backend + compiledlayoutservice: build: context: . @@ -14,9 +30,15 @@ services: # See _doc_Auth.md for Cognito dev user pool setup instructions. - Cognito__Authority=${COGNITO_AUTHORITY:-} - Cognito__Audience=${COGNITO_AUDIENCE:-} + - LocalStack__BaseUrl=http://localstack:4566 + depends_on: + - localstack networks: - backend networks: backend: driver: bridge + +volumes: + localstack-data: diff --git a/docs/local-dev.md b/docs/local-dev.md new file mode 100644 index 00000000..06622d97 --- /dev/null +++ b/docs/local-dev.md @@ -0,0 +1,98 @@ +# Local Backend Development + +This guide covers local backend dependencies for AdaptiveRemote backend services. + +> Current repository state: `AdaptiveRemote.Backend.CompiledLayoutService` is the only +> backend API service currently implemented in `src/`. Apply the same startup and `/scalar` +> checks to additional backend services as they are added. + +## Prerequisites + +1. Install Docker Desktop (or Docker Engine + Docker Compose plugin). +2. Verify tools: + - `docker --version` + - `docker compose version` +3. From the repository root, start local dependencies: + + ```bash + docker compose up -d + ``` + +## Confirm LocalStack health + +LocalStack health endpoint must be reachable: + +```bash +curl http://localhost:4566/_localstack/health +``` + +Expected response contains LocalStack health JSON with either: + +```json +{ "status": "running" } +``` + +or service entries showing required services as available/running, for example: + +```json +{ + "services": { + "dynamodb": "available", + "lambda": "available", + "sqs": "available" + } +} +``` + +## Cognito development credentials + +Set Cognito values for backend services (for `docker-compose` these map to +`COGNITO_AUTHORITY` and `COGNITO_AUDIENCE`): + +- `Cognito__Authority` / `COGNITO_AUTHORITY` +- `Cognito__Audience` / `COGNITO_AUDIENCE` (optional) + +See `src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md` +for full Cognito dev user pool setup. + +## Scalar API browser + +When running backend API services in development, Scalar is available at: + +- `http://localhost:<port>/scalar` + +Scalar is development-only and is not mapped in non-development environments. + +## Lambda local debugging + +Install the Lambda test tool globally (latest .NET 10-compatible package): + +```bash +dotnet tool install -g Amazon.Lambda.TestTool-10.0 +``` + +Use a launch profile that starts the test tool for interactive invocation/debugging. + +## LocalStack Lambda invocation samples + +Use `--endpoint-url http://localhost:4566` for local invocation. + +### LayoutCompilerService + +```bash +aws lambda invoke \ + --endpoint-url http://localhost:4566 \ + --function-name adaptiveremote-layout-compiler-dev \ + --payload '{"id":"00000000-0000-0000-0000-000000000001","userId":"test-user","elements":[]}' \ + response-layout-compiler.json +``` + +### LayoutValidationService + +```bash +aws lambda invoke \ + --endpoint-url http://localhost:4566 \ + --function-name adaptiveremote-layout-validation-dev \ + --payload '{"id":"00000000-0000-0000-0000-000000000001","userId":"test-user","elements":[],"cssDefinitions":[]}' \ + response-layout-validation.json +``` diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj index a2b8d2e2..8199e544 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj @@ -5,10 +5,13 @@ <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <RootNamespace>AdaptiveRemote.Backend.CompiledLayoutService</RootNamespace> + <UserSecretsId>3b8e930e-a235-49e8-81b1-db01bf4f9540</UserSecretsId> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> + <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> + <PackageReference Include="Scalar.AspNetCore" /> </ItemGroup> <ItemGroup> diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs index 761f67ff..a1ff5f46 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs @@ -33,4 +33,10 @@ public static partial class MessageLogger [LoggerMessage(EventId = 1107, Level = LogLevel.Error, Message = "Error processing health check request")] public static partial void ErrorProcessingHealthCheck(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 1108, + Level = LogLevel.Error, + Message = "LocalStack dependency check failed at {HealthUrl}: {FailureReason}. LocalStack is required for local development. See docs/local-dev.md for setup instructions")] + public static partial void LocalStackDependencyUnavailable(this ILogger logger, string healthUrl, string failureReason, Exception? exception); } diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index e03e5bb2..df0627e8 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -7,6 +7,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Scalar.AspNetCore; +using System.Net.Http; +using System.Text.Json; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -41,15 +44,28 @@ }); builder.Services.AddAuthorization(); +builder.Services.AddOpenApi(); WebApplication app = builder.Build(); ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>(); logger.ServiceStarting(); +if (app.Environment.IsDevelopment()) +{ + await EnsureLocalStackRunningAsync(app, logger).ConfigureAwait(false); +} + app.UseAuthentication(); app.UseAuthorization(); +app.MapOpenApi(); + +if (app.Environment.IsDevelopment()) +{ + app.MapScalarApiReference(); +} + // Map endpoints app.MapHealthEndpoints(); app.MapLayoutEndpoints(); @@ -63,5 +79,111 @@ app.Run(); +static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logger) +{ + const int LocalStackHealthCheckTimeoutSeconds = 5; + const int LocalStackStartupWaitTimeoutSeconds = 30; + const int LocalStackRetryDelaySeconds = 2; + TimeSpan localStackStartupWaitTimeout = TimeSpan.FromSeconds(LocalStackStartupWaitTimeoutSeconds); + TimeSpan localStackRetryDelay = TimeSpan.FromSeconds(LocalStackRetryDelaySeconds); + string[] requiredServices = ["dynamodb", "lambda", "sqs"]; + + string baseUrl = app.Configuration["LocalStack:BaseUrl"] ?? "http://localhost:4566"; + + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? baseUri)) + { + logger.LocalStackDependencyUnavailable(baseUrl, "configuration value is not a valid absolute URL", exception: null); + Environment.Exit(1); + } + + Uri healthUri = new(baseUri, "/_localstack/health"); + + using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(LocalStackHealthCheckTimeoutSeconds) }; + Exception? lastException = null; + string? lastFailureReason = null; + DateTime deadlineUtc = DateTime.UtcNow.Add(localStackStartupWaitTimeout); + + while (DateTime.UtcNow < deadlineUtc) + { + try + { + using HttpResponseMessage response = await client.GetAsync(healthUri).ConfigureAwait(false); + string body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + lastFailureReason = $"HTTP {(int)response.StatusCode}"; + } + else + { + using JsonDocument json = JsonDocument.Parse(body); + if (IsLocalStackRunning(json.RootElement, requiredServices, out string failureReason)) + { + return; + } + + lastFailureReason = failureReason; + } + + lastException = null; + } + catch (Exception ex) + { + lastException = ex; + lastFailureReason = ex.Message; + } + + await Task.Delay(localStackRetryDelay).ConfigureAwait(false); + } + + logger.LocalStackDependencyUnavailable( + healthUri.ToString(), + $"did not become healthy within {LocalStackStartupWaitTimeoutSeconds}s; last check result: {lastFailureReason ?? "unknown health check failure"}", + lastException); + Environment.Exit(1); +} + +static bool IsLocalStackRunning(JsonElement root, IReadOnlyList<string> requiredServices, out string failureReason) +{ + if (root.TryGetProperty("status", out JsonElement statusElement)) + { + string status = statusElement.GetString() ?? string.Empty; + if (string.Equals(status, "running", StringComparison.OrdinalIgnoreCase)) + { + failureReason = string.Empty; + return true; + } + + failureReason = $"status='{status}'"; + return false; + } + + if (!root.TryGetProperty("services", out JsonElement servicesElement) || servicesElement.ValueKind != JsonValueKind.Object) + { + failureReason = "health response did not contain a running status or services object"; + return false; + } + + foreach (string service in requiredServices) + { + if (!servicesElement.TryGetProperty(service, out JsonElement serviceStatusElement)) + { + failureReason = $"service '{service}' was missing from health response"; + return false; + } + + string serviceStatus = serviceStatusElement.GetString() ?? string.Empty; + if (!string.Equals(serviceStatus, "available", StringComparison.OrdinalIgnoreCase) && + !string.Equals(serviceStatus, "running", StringComparison.OrdinalIgnoreCase)) + { + failureReason = $"service '{service}' status was '{serviceStatus}'"; + return false; + } + } + + failureReason = string.Empty; + return true; +} + // Make Program visible for testing public partial class Program { } diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json index 665343d2..6c3e8839 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json @@ -1,12 +1,23 @@ { "profiles": { + "Development": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "scalar", + "outputCapture": "None", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54433;http://localhost:54434" + }, "AdaptiveRemote.Backend.CompiledLayoutService": { "commandName": "Project", "launchBrowser": true, + "launchUrl": "scalar", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, "applicationUrl": "https://localhost:54433;http://localhost:54434" } } -} \ No newline at end of file +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md b/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md index 843ce8e5..ca4d63f2 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md @@ -22,7 +22,7 @@ The `sub` claim from the validated JWT is used as the `userId` throughout the se - `adaptiveremote-editor` — enable Authorization Code flow; configure allowed callback URL. 3. Create a resource server (custom scope), e.g. `adaptiveremote/layouts.read`. 4. Note the user pool's **Issuer URL** (shown in the pool's details page): - `https://cognito-idp.{region}.amazonaws.com/{userPoolId}` + `https://cognito-idp.us-east-2.amazonaws.com/us-east-2_65NKvrlha` ## Configuring the backend service @@ -30,7 +30,7 @@ Set these environment variables (or values in `appsettings.Development.json` — | Variable | Example | |----------|---------| -| `Cognito__Authority` | `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123` | +| `Cognito__Authority` | `https://cognito-idp.us-east-2.amazonaws.com/us-east-2_65NKvrlha` | | `Cognito__Audience` | `<app-client-id>` (optional; leave empty to skip audience validation) | For local development via `docker-compose`, set `COGNITO_AUTHORITY` and `COGNITO_AUDIENCE` in a @@ -45,8 +45,8 @@ Set in `appsettings.Development.json` (non-secret values) and user secrets (secr "backend": { "baseUrl": "http://localhost:8080", "cognito": { - "authority": "https://cognito-idp.{region}.amazonaws.com/{userPoolId}", - "clientId": "YOUR_CLIENT_ID", + "authority": "https://cognito-idp.us-east-2.amazonaws.com/us-east-2_65NKvrlha", + "clientId": "5g6eqq1v1o7lju703enelssl89", "scope": "adaptiveremote/layouts.read" } } @@ -82,6 +82,34 @@ with **Lambda Function URLs**. These URLs are not exposed via API Gateway and ar only from within the ECS cluster (network isolation via VPC/security groups). No bearer token validation is required or expected on internal Lambda endpoints. +## Getting a test token (manual testing / Scalar) + +To test protected endpoints manually (e.g. via the Scalar UI), you need a bearer token from +the `adaptiveremote-client` app client. + +**Option 1 — curl** +```bash +curl -X POST https://us-east-265nkvrlha.auth.us-east-2.amazoncognito.com/oauth2/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=44qanfe7hvaeumffnt5hsk0ojr&client_secret=YOUR_CLIENT_SECRET&scope=adaptiveremote/layouts.read" +``` + +**Option 2 — browser console (no install required)** +```javascript +const resp = await fetch("https://us-east-265nkvrlha.auth.us-east-2.amazoncognito.com/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "grant_type=client_credentials&client_id=44qanfe7hvaeumffnt5hsk0ojr&client_secret=YOUR_CLIENT_SECRET&scope=adaptiveremote/layouts.read" +}); +console.log(await resp.json()); +``` + +Both return a JSON object containing `access_token`. Set it as `Authorization: Bearer <token>` +in the request headers. + +If you don't know the token endpoint URL, discover it from the OIDC metadata document: +`https://cognito-idp.us-east-2.amazonaws.com/us-east-2_65NKvrlha/.well-known/openid-configuration` — look for the `token_endpoint` field. + ## API integration tests Tests use a `TestJwtAuthority` (`test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs`), diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json index 3ae5de58..fc09110a 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json @@ -8,5 +8,8 @@ "Cognito": { "Authority": "", "Audience": "" + }, + "LocalStack": { + "BaseUrl": "http://localhost:4566" } } diff --git a/src/_doc_BackendDevelopment.md b/src/_doc_BackendDevelopment.md index 867b0807..e3bb6090 100644 --- a/src/_doc_BackendDevelopment.md +++ b/src/_doc_BackendDevelopment.md @@ -1,18 +1,38 @@ # Backend Development Guide -> **Status:** Stub — to be populated during Task 5 ([ADR-187](https://jodasoft.atlassian.net/browse/ADR-187)) -> -> See `src/_spec_LayoutCustomizationService.md` Task 5 for the full exit criteria. +This document defines the standing development pattern for backend services introduced by +Task 5 ([ADR-187](https://jodasoft.atlassian.net/browse/ADR-187)). -## Agent Verification Step +## ECS/Fargate-style API services -After every change to a backend service, verify the development environment still works: +All backend API services must follow this local development pattern: -1. **With LocalStack running:** `dotnet run` (or F5 in VS) → confirm the service starts cleanly, log output appears in a console window, and `/scalar` is reachable in a browser -2. **With LocalStack stopped:** `dotnet run` → confirm the process exits with a non-zero code and the console names LocalStack as the missing dependency with a reference to `docs/local-dev.md` +1. Register OpenAPI and map Scalar UI only in development (`/scalar`). +2. Include a `Development` launch profile with `"outputCapture": "None"` so F5 opens a + separate console window in Visual Studio. +3. On startup (development), check `/_localstack/health` on the configured LocalStack base URL. + If unavailable or not `running`, log an error that names LocalStack and references + `docs/local-dev.md`, then exit non-zero immediately. -For Lambda functions: -1. Confirm F5 in VS opens the Lambda Test Tool UI -2. Confirm `aws lambda invoke --endpoint-url http://localhost:4566` returns a valid response +## Lambda services -> This section will be expanded with full setup details and patterns once Task 5 is implemented. +All backend Lambda projects must include: + +1. A launch profile that starts the Lambda Test Tool for interactive local debugging. +2. LocalStack deployment support through `docker-compose`. +3. Documented `aws lambda invoke --endpoint-url http://localhost:4566` sample commands. + +## Agent Verification Step (required after backend changes) + +After every backend service change: + +1. **With LocalStack running:** run the service and confirm clean startup plus `/scalar` availability. +2. **With LocalStack stopped:** run the service and confirm non-zero exit with the LocalStack + dependency error message that includes `docs/local-dev.md`. + +For Lambda services: + +1. Confirm the Lambda Test Tool profile launches successfully. +2. Confirm `aws lambda invoke --endpoint-url http://localhost:4566` returns a valid response. + +See `docs/local-dev.md` for setup and invocation details. diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs index 25b9f59b..959e73e6 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs @@ -86,6 +86,8 @@ public async Task StartServiceAsync() ["ASPNETCORE_URLS"] = ServiceUrl, // Point the service at the local test JWT authority. ["Cognito__Authority"] = _jwtAuthority.Authority, + // Use the same local test authority host for LocalStack health checks. + ["LocalStack__BaseUrl"] = _jwtAuthority.Authority, } }; @@ -148,7 +150,7 @@ public async Task StartServiceAsync() { lock (_logLock) { - _logOutput.AppendLine($"[HealthCheck attempt {i + 1}] Exception polling {ServiceUrl}/health: {ex.GetType().Name}: {ex.Message}"); + _logOutput.AppendLine($"[HealthCheck attempt {i + 1}] Request failed polling {ServiceUrl}/health: {ex.Message}"); } } diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs index 226f948a..dcba9a7c 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs @@ -11,9 +11,10 @@ namespace AdaptiveRemote.Backend.ApiTests.Support; /// A minimal local OIDC/JWKS authority used by API integration tests to issue and /// validate JWTs without a real Cognito user pool. /// -/// Exposes two endpoints on a dynamically-assigned localhost port: +/// Exposes three endpoints on a dynamically-assigned localhost port: /// GET /.well-known/openid-configuration — OIDC discovery document /// GET /.well-known/jwks.json — RSA public key in JWK format +/// GET /_localstack/health — LocalStack-compatible health response /// /// The service under test is configured to use this authority via the /// Cognito__Authority environment variable so that bearer token validation @@ -118,6 +119,7 @@ private void HandleRequest(HttpListenerContext context) { "/.well-known/openid-configuration" => BuildDiscoveryDocument(), "/.well-known/jwks.json" => BuildJwks(), + "/_localstack/health" => BuildLocalStackHealth(), _ => BuildNotFound(context), }; @@ -168,6 +170,15 @@ private static byte[] BuildNotFound(HttpListenerContext context) return System.Text.Encoding.UTF8.GetBytes("{}"); } + private static byte[] BuildLocalStackHealth() + { + string json = JsonSerializer.Serialize(new + { + status = "running", + }); + return System.Text.Encoding.UTF8.GetBytes(json); + } + private static int GetFreePort() { using System.Net.Sockets.TcpListener listener = new(IPAddress.Loopback, 0); diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/AdaptiveRemote.EndToEndTests.Host.Wpf.csproj b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/AdaptiveRemote.EndToEndTests.Host.Wpf.csproj index 808f3c5d..f80a6b55 100644 --- a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/AdaptiveRemote.EndToEndTests.Host.Wpf.csproj +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/AdaptiveRemote.EndToEndTests.Host.Wpf.csproj @@ -6,6 +6,7 @@ <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <OutputType>Exe</OutputType> + <EnableWindowsTargeting>true</EnableWindowsTargeting> </PropertyGroup> <ItemGroup> diff --git a/test/AdaptiveRemote.EndtoEndTests.Host.Console/AdaptiveRemote.EndToEndTests.Host.Console.csproj b/test/AdaptiveRemote.EndtoEndTests.Host.Console/AdaptiveRemote.EndToEndTests.Host.Console.csproj index b7ccd9b2..6e2f5b6e 100644 --- a/test/AdaptiveRemote.EndtoEndTests.Host.Console/AdaptiveRemote.EndToEndTests.Host.Console.csproj +++ b/test/AdaptiveRemote.EndtoEndTests.Host.Console/AdaptiveRemote.EndToEndTests.Host.Console.csproj @@ -6,6 +6,7 @@ <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <OutputType>Exe</OutputType> + <EnableWindowsTargeting>true</EnableWindowsTargeting> </PropertyGroup> <ItemGroup> From 5ff955c67c35ff48f1ef60e7b361713b13443f83 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:02:17 +0000 Subject: [PATCH 08/11] Add RawLayoutService project structure and interfaces Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/55ecbd6c-f4b7-4260-bc3e-6377f6e360fb Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .github/workflows/build-and-test.yml | 39 +- AdaptiveRemote.sln | 45 + Directory.Packages.props | 2 + backend.slnf | 5 +- client.slnf | 3 +- docker-compose.yml | 32 +- localstack-init/01-create-tables.sh | 15 + ...tiveRemote.Backend.RawLayoutService.csproj | 22 + .../Configuration/CognitoSettings.cs | 19 + .../Configuration/DynamoDbSettings.cs | 24 + .../Dockerfile | 24 + .../Endpoints/HealthEndpoints.cs | 33 + .../Endpoints/LayoutEndpoints.cs | 259 ++++++ .../Logging/MessageLogger.cs | 72 ++ .../Program.cs | 245 ++++++ .../Properties/launchSettings.json | 23 + .../DynamoDbRawLayoutRepository.cs | 173 ++++ .../Services/StubLayoutProcessingTrigger.cs | 16 + .../Services/StubNotificationPublisher.cs | 22 + .../appsettings.Development.json | 16 + .../appsettings.json | 18 + .../ILayoutProcessingTrigger.cs | 9 + .../INotificationPublisher.cs | 13 + .../IRawLayoutRepository.cs | 12 + .../IRawLayoutStatusWriter.cs | 10 + .../LayoutContractsJsonContext.cs | 1 + .../AdaptiveRemote.App.Tests.csproj | 1 + .../AdaptiveRemote.Backend.ApiTests.csproj | 1 + .../Features/RawLayoutEndpoints.feature | 68 ++ .../Features/RawLayoutEndpoints.feature.cs | 544 ++++++++++++ .../StepDefinitions/AuthenticationSteps.cs | 8 - .../StepDefinitions/CommonSteps.cs | 25 +- .../StepDefinitions/RawLayoutSteps.cs | 303 +++++++ .../Support/LocalStackFixture.cs | 222 +++++ .../Support/ServiceFixture.cs | 74 +- ...mote.Backend.RawLayoutService.Tests.csproj | 32 + .../DynamoDbRawLayoutRepositoryTests.cs | 830 ++++++++++++++++++ .../AdaptiveRemote.TestUtilities.csproj | 25 + .../MemoryAssert.cs | 8 +- .../MockEndPoint.cs | 2 +- .../MockExtensions.cs | 24 +- .../StringExtensions.cs | 4 +- .../TaskAssert.cs | 2 +- 43 files changed, 3279 insertions(+), 46 deletions(-) create mode 100755 localstack-init/01-create-tables.sh create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Configuration/CognitoSettings.cs create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Configuration/DynamoDbSettings.cs create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Dockerfile create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/HealthEndpoints.cs create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/LayoutEndpoints.cs create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Logging/MessageLogger.cs create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Program.cs create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Properties/launchSettings.json create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Repositories/DynamoDbRawLayoutRepository.cs create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Services/StubLayoutProcessingTrigger.cs create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Services/StubNotificationPublisher.cs create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/appsettings.Development.json create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/appsettings.json create mode 100644 src/AdaptiveRemote.Contracts/ILayoutProcessingTrigger.cs create mode 100644 src/AdaptiveRemote.Contracts/INotificationPublisher.cs create mode 100644 src/AdaptiveRemote.Contracts/IRawLayoutRepository.cs create mode 100644 src/AdaptiveRemote.Contracts/IRawLayoutStatusWriter.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/RawLayoutSteps.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Support/LocalStackFixture.cs create mode 100644 test/AdaptiveRemote.Backend.RawLayoutService.Tests/AdaptiveRemote.Backend.RawLayoutService.Tests.csproj create mode 100644 test/AdaptiveRemote.Backend.RawLayoutService.Tests/Repositories/DynamoDbRawLayoutRepositoryTests.cs create mode 100644 test/AdaptiveRemote.TestUtilities/AdaptiveRemote.TestUtilities.csproj rename test/{AdaptiveRemote.App.Tests/TestUtilities => AdaptiveRemote.TestUtilities}/MemoryAssert.cs (84%) rename test/{AdaptiveRemote.App.Tests/TestUtilities => AdaptiveRemote.TestUtilities}/MockEndPoint.cs (63%) rename test/{AdaptiveRemote.App.Tests/TestUtilities => AdaptiveRemote.TestUtilities}/MockExtensions.cs (85%) rename test/{AdaptiveRemote.App.Tests/TestUtilities => AdaptiveRemote.TestUtilities}/StringExtensions.cs (83%) rename test/{AdaptiveRemote.App.Tests/TestUtilities => AdaptiveRemote.TestUtilities}/TaskAssert.cs (99%) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 67dbb52d..9e929d71 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -40,7 +40,7 @@ jobs: run: pwsh ./src/AdaptiveRemote.Headless/bin/Debug/net10.0/playwright.ps1 install chromium - name: Unit Tests - run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" ./scripts/validate-unit-tests.proj + run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" ./scripts/validate-unit-tests.proj --filter "TestCategory!=ApiIntegrationTest" - name: E2E Tests run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" ./scripts/validate-e2e-tests.proj -m:1 @@ -61,3 +61,40 @@ jobs: with: files: | TestResults/**/*.trx + + backend-api-tests: + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore backend.slnf + + - name: Build + run: dotnet build backend.slnf --no-restore /warnaserror + + - name: Pull LocalStack image + run: docker pull localstack/localstack:3.0 + + - name: API Integration Tests + run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" ./scripts/validate-unit-tests.proj --filter "TestCategory=ApiIntegrationTest" -m:1 + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: | + TestResults/**/*.trx diff --git a/AdaptiveRemote.sln b/AdaptiveRemote.sln index 61c872f4..28846c9a 100644 --- a/AdaptiveRemote.sln +++ b/AdaptiveRemote.sln @@ -56,6 +56,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.ApiTests", "test\AdaptiveRemote.Backend.ApiTests\AdaptiveRemote.Backend.ApiTests.csproj", "{E581823B-8EA9-4C54-A05E-859632CE1B78}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.RawLayoutService", "src\AdaptiveRemote.Backend.RawLayoutService\AdaptiveRemote.Backend.RawLayoutService.csproj", "{B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.RawLayoutService.Tests", "test\AdaptiveRemote.Backend.RawLayoutService.Tests\AdaptiveRemote.Backend.RawLayoutService.Tests.csproj", "{E13EF56D-99C3-40A7-A55E-C25C4906CD44}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.TestUtilities", "test\AdaptiveRemote.TestUtilities\AdaptiveRemote.TestUtilities.csproj", "{352E5981-CC33-4474-8203-9CE241F42281}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -234,6 +240,42 @@ Global {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x64.Build.0 = Release|Any CPU {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x86.ActiveCfg = Release|Any CPU {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x86.Build.0 = Release|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Debug|x64.Build.0 = Debug|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Debug|x86.Build.0 = Debug|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Release|Any CPU.Build.0 = Release|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Release|x64.ActiveCfg = Release|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Release|x64.Build.0 = Release|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Release|x86.ActiveCfg = Release|Any CPU + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD}.Release|x86.Build.0 = Release|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Debug|x64.ActiveCfg = Debug|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Debug|x64.Build.0 = Debug|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Debug|x86.ActiveCfg = Debug|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Debug|x86.Build.0 = Debug|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Release|Any CPU.Build.0 = Release|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Release|x64.ActiveCfg = Release|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Release|x64.Build.0 = Release|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Release|x86.ActiveCfg = Release|Any CPU + {E13EF56D-99C3-40A7-A55E-C25C4906CD44}.Release|x86.Build.0 = Release|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Debug|Any CPU.Build.0 = Debug|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Debug|x64.ActiveCfg = Debug|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Debug|x64.Build.0 = Debug|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Debug|x86.ActiveCfg = Debug|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Debug|x86.Build.0 = Debug|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Release|Any CPU.ActiveCfg = Release|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Release|Any CPU.Build.0 = Release|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Release|x64.ActiveCfg = Release|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Release|x64.Build.0 = Release|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Release|x86.ActiveCfg = Release|Any CPU + {352E5981-CC33-4474-8203-9CE241F42281}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -250,6 +292,9 @@ Global {54522D5A-CEB3-F5B9-2654-1005EF1C3262} = {CC3DAD92-6D91-40F5-B57A-C5620CF4F1C7} {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {E581823B-8EA9-4C54-A05E-859632CE1B78} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E13EF56D-99C3-40A7-A55E-C25C4906CD44} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {352E5981-CC33-4474-8203-9CE241F42281} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {556A11E4-2F89-4600-9831-8162F067EC3E} diff --git a/Directory.Packages.props b/Directory.Packages.props index 1651a0c7..18035c3c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,8 @@ <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> </PropertyGroup> <ItemGroup Label="Application Packages"> + <!-- AWS SDK --> + <PackageVersion Include="AWSSDK.DynamoDBv2" Version="3.7.406.21" /> <!-- Azure and Cloud Services --> <PackageVersion Include="Azure.Identity" Version="1.17.1" /> <PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.6.0" /> diff --git a/backend.slnf b/backend.slnf index db1add99..7fecc9f9 100644 --- a/backend.slnf +++ b/backend.slnf @@ -4,7 +4,10 @@ "projects": [ "src\\AdaptiveRemote.Contracts\\AdaptiveRemote.Contracts.csproj", "src\\AdaptiveRemote.Backend.CompiledLayoutService\\AdaptiveRemote.Backend.CompiledLayoutService.csproj", - "test\\AdaptiveRemote.Backend.ApiTests\\AdaptiveRemote.Backend.ApiTests.csproj" + "src\\AdaptiveRemote.Backend.RawLayoutService\\AdaptiveRemote.Backend.RawLayoutService.csproj", + "test\\AdaptiveRemote.Backend.ApiTests\\AdaptiveRemote.Backend.ApiTests.csproj", + "test\\AdaptiveRemote.Backend.RawLayoutService.Tests\\AdaptiveRemote.Backend.RawLayoutService.Tests.csproj", + "test\\AdaptiveRemote.TestUtilities\\AdaptiveRemote.TestUtilities.csproj" ] } } diff --git a/client.slnf b/client.slnf index 1991596c..760a249c 100644 --- a/client.slnf +++ b/client.slnf @@ -13,7 +13,8 @@ "test\\AdaptiveRemote.EndToEndTests.Steps\\AdaptiveRemote.EndToEndTests.Steps.csproj", "test\\AdaptiveRemote.EndToEndTests.Host.Headless\\AdaptiveRemote.EndToEndTests.Host.Headless.csproj", "test\\AdaptiveRemote.EndToEndTests.Host.Wpf\\AdaptiveRemote.EndToEndTests.Host.Wpf.csproj", - "test\\AdaptiveRemote.EndtoEndTests.Host.Console\\AdaptiveRemote.EndToEndTests.Host.Console.csproj" + "test\\AdaptiveRemote.EndtoEndTests.Host.Console\\AdaptiveRemote.EndToEndTests.Host.Console.csproj", + "test\\AdaptiveRemote.TestUtilities\\AdaptiveRemote.TestUtilities.csproj" ] } } diff --git a/docker-compose.yml b/docker-compose.yml index c06c3a06..a368782c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,18 +2,19 @@ version: '3.8' services: localstack: - image: localstack/localstack:4.8 - container_name: adaptiveremote-localstack + image: localstack/localstack:latest ports: - "4566:4566" environment: - - SERVICES=lambda,dynamodb,sqs + - SERVICES=dynamodb,sqs,lambda - DEBUG=1 + - DATA_DIR=/tmp/localstack/data + - DOCKER_HOST=unix:///var/run/docker.sock - LAMBDA_EXECUTOR=docker - AWS_DEFAULT_REGION=us-east-1 volumes: - - localstack-data:/var/lib/localstack - - /var/run/docker.sock:/var/run/docker.sock + - "./localstack-init:/etc/localstack/init/ready.d" + - "/var/run/docker.sock:/var/run/docker.sock" networks: - backend @@ -30,6 +31,27 @@ services: # See _doc_Auth.md for Cognito dev user pool setup instructions. - Cognito__Authority=${COGNITO_AUTHORITY:-} - Cognito__Audience=${COGNITO_AUDIENCE:-} + networks: + - backend + + rawlayoutservice: + build: + context: . + dockerfile: src/AdaptiveRemote.Backend.RawLayoutService/Dockerfile + ports: + - "8081:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + # Set Cognito dev user pool values here or via a .env file. + - Cognito__Authority=${COGNITO_AUTHORITY:-} + - Cognito__Audience=${COGNITO_AUDIENCE:-} + # DynamoDB settings for LocalStack + - DynamoDB__ServiceUrl=http://localstack:4566 + - DynamoDB__TableName=RawLayouts + - DynamoDB__Region=us-east-1 + - AWS_ACCESS_KEY_ID=test + - AWS_SECRET_ACCESS_KEY=test - LocalStack__BaseUrl=http://localstack:4566 depends_on: - localstack diff --git a/localstack-init/01-create-tables.sh b/localstack-init/01-create-tables.sh new file mode 100755 index 00000000..de32f24a --- /dev/null +++ b/localstack-init/01-create-tables.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Create DynamoDB table for RawLayouts +awslocal dynamodb create-table \ + --table-name RawLayouts \ + --attribute-definitions \ + AttributeName=UserId,AttributeType=S \ + AttributeName=Id,AttributeType=S \ + --key-schema \ + AttributeName=UserId,KeyType=HASH \ + AttributeName=Id,KeyType=RANGE \ + --billing-mode PAY_PER_REQUEST \ + --region us-east-1 + +echo "DynamoDB table 'RawLayouts' created successfully" diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj b/src/AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj new file mode 100644 index 00000000..ca5c8ff6 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj @@ -0,0 +1,22 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <RootNamespace>AdaptiveRemote.Backend.RawLayoutService</RootNamespace> + <UserSecretsId>7c4a1f2e-b839-4d6a-9e0c-f521da3b8c47</UserSecretsId> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> + <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> + <PackageReference Include="Scalar.AspNetCore" /> + <PackageReference Include="AWSSDK.DynamoDBv2" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Configuration/CognitoSettings.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Configuration/CognitoSettings.cs new file mode 100644 index 00000000..bf8c178f --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Configuration/CognitoSettings.cs @@ -0,0 +1,19 @@ +namespace AdaptiveRemote.Backend.RawLayoutService.Configuration; + +/// <summary> +/// Configuration for AWS Cognito JWT validation. +/// Maps to the "Cognito" section in appsettings.json. +/// </summary> +public class CognitoSettings +{ + /// <summary> + /// The Cognito user pool authority URL, e.g. + /// https://cognito-idp.{region}.amazonaws.com/{userPoolId} + /// </summary> + public string Authority { get; set; } = string.Empty; + + /// <summary> + /// The OAuth2 audience (app client ID). If empty, audience validation is skipped. + /// </summary> + public string? Audience { get; set; } +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Configuration/DynamoDbSettings.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Configuration/DynamoDbSettings.cs new file mode 100644 index 00000000..33aa6c01 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Configuration/DynamoDbSettings.cs @@ -0,0 +1,24 @@ +namespace AdaptiveRemote.Backend.RawLayoutService.Configuration; + +/// <summary> +/// Configuration for AWS DynamoDB connection. +/// Maps to the "DynamoDB" section in appsettings.json. +/// </summary> +public class DynamoDbSettings +{ + /// <summary> + /// The DynamoDB service URL. For LocalStack: http://localhost:4566 + /// For AWS: leave empty to use default AWS endpoint. + /// </summary> + public string? ServiceUrl { get; set; } + + /// <summary> + /// The name of the DynamoDB table for raw layouts. + /// </summary> + public string TableName { get; set; } = "RawLayouts"; + + /// <summary> + /// AWS region (e.g. "us-east-1"). Required for both LocalStack and AWS. + /// </summary> + public string Region { get; set; } = "us-east-1"; +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Dockerfile b/src/AdaptiveRemote.Backend.RawLayoutService/Dockerfile new file mode 100644 index 00000000..1fef8e37 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Dockerfile @@ -0,0 +1,24 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy csproj files and restore +COPY ["src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj", "AdaptiveRemote.Contracts/"] +COPY ["src/AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj", "AdaptiveRemote.Backend.RawLayoutService/"] +COPY ["Directory.Build.props", "./"] +COPY ["Directory.Packages.props", "./"] +RUN dotnet restore "AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj" + +# Copy source and build +COPY ["src/AdaptiveRemote.Contracts/", "AdaptiveRemote.Contracts/"] +COPY ["src/AdaptiveRemote.Backend.RawLayoutService/", "AdaptiveRemote.Backend.RawLayoutService/"] +WORKDIR "/src/AdaptiveRemote.Backend.RawLayoutService" +RUN dotnet build "AdaptiveRemote.Backend.RawLayoutService.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "AdaptiveRemote.Backend.RawLayoutService.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=publish /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "AdaptiveRemote.Backend.RawLayoutService.dll"] diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/HealthEndpoints.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/HealthEndpoints.cs new file mode 100644 index 00000000..14f8dcd8 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/HealthEndpoints.cs @@ -0,0 +1,33 @@ +using AdaptiveRemote.Backend.RawLayoutService.Logging; +using AdaptiveRemote.Contracts; +using System.Reflection; + +namespace AdaptiveRemote.Backend.RawLayoutService.Endpoints; + +public static class HealthEndpoints +{ + public static void MapHealthEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/health", GetHealth) + .WithName(nameof(GetHealth)) + .Produces<HealthResponse>(StatusCodes.Status200OK) + .AllowAnonymous(); + } + + private static IResult GetHealth(ILogger<Program> logger) + { + logger.HealthCheckRequested(); + + HealthResponse response = new( + ServiceName: "RawLayoutService", + Version: Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown", + Status: "Healthy" + ); + + logger.HealthCheckSuccessful(); + + return Results.Json( + response, + LayoutContractsJsonContext.Default.HealthResponse); + } +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/LayoutEndpoints.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/LayoutEndpoints.cs new file mode 100644 index 00000000..3ce3655e --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/LayoutEndpoints.cs @@ -0,0 +1,259 @@ +using System.Security.Claims; +using AdaptiveRemote.Backend.RawLayoutService.Logging; +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.RawLayoutService.Endpoints; + +public static class LayoutEndpoints +{ + public static void MapLayoutEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/layouts/raw", ListRawLayouts) + .WithName(nameof(ListRawLayouts)) + .Produces<IReadOnlyList<RawLayout>>(StatusCodes.Status200OK) + .RequireAuthorization(); + + app.MapGet("/layouts/raw/{id:guid}", GetRawLayout) + .WithName(nameof(GetRawLayout)) + .Produces<RawLayout>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); + + app.MapPost("/layouts/raw", CreateRawLayout) + .WithName(nameof(CreateRawLayout)) + .Produces<RawLayout>(StatusCodes.Status201Created) + .RequireAuthorization(); + + app.MapPut("/layouts/raw/{id:guid}", UpdateRawLayout) + .WithName(nameof(UpdateRawLayout)) + .Produces<RawLayout>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); + + app.MapDelete("/layouts/raw/{id:guid}", DeleteRawLayout) + .WithName(nameof(DeleteRawLayout)) + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); + } + + private static async Task<IResult> ListRawLayouts( + ClaimsPrincipal user, + ILogger<Program> logger, + IRawLayoutRepository repository, + CancellationToken cancellationToken) + { + string? userId = user.FindFirst("sub")?.Value; + if (userId is null) + { + return Results.Unauthorized(); + } + + logger.ListRawLayoutsRequested(userId); + + try + { + IReadOnlyList<RawLayout> layouts = await repository.ListByUserAsync(userId, cancellationToken); + + return Results.Json( + layouts, + LayoutContractsJsonContext.Default.IReadOnlyListRawLayout); + } + catch (Exception ex) + { + logger.ErrorRetrievingRawLayouts(userId, ex); + return Results.Problem("Error retrieving raw layouts"); + } + } + + private static async Task<IResult> GetRawLayout( + Guid id, + ClaimsPrincipal user, + ILogger<Program> logger, + IRawLayoutRepository repository, + CancellationToken cancellationToken) + { + string? userId = user.FindFirst("sub")?.Value; + if (userId is null) + { + return Results.Unauthorized(); + } + + logger.GetRawLayoutRequested(userId, id); + + try + { + RawLayout? layout = await repository.GetAsync(id, cancellationToken); + + if (layout == null || layout.UserId != userId) + { + return Results.NotFound(); + } + + return Results.Json( + layout, + LayoutContractsJsonContext.Default.RawLayout); + } + catch (Exception ex) + { + logger.ErrorRetrievingRawLayout(id, userId, ex); + return Results.Problem("Error retrieving raw layout"); + } + } + + private static async Task<IResult> CreateRawLayout( + RawLayout layout, + ClaimsPrincipal user, + ILogger<Program> logger, + IRawLayoutRepository repository, + ILayoutProcessingTrigger processingTrigger, + INotificationPublisher notificationPublisher, + CancellationToken cancellationToken) + { + string? userId = user.FindFirst("sub")?.Value; + if (userId is null) + { + return Results.Unauthorized(); + } + + logger.CreateRawLayoutRequested(userId); + + // Validate required fields + if (string.IsNullOrWhiteSpace(layout.Name)) + { + return Results.BadRequest(new { error = "Name is required" }); + } + + if (layout.Elements is null) + { + return Results.BadRequest(new { error = "Elements is required" }); + } + + try + { + // Generate new ID and timestamps if not provided + RawLayout newLayout = layout with + { + Id = layout.Id == Guid.Empty ? Guid.NewGuid() : layout.Id, + UserId = userId, // Always override with authenticated user + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + Version = 1, + ValidationResult = null // Clear any validation result from the request + }; + + RawLayout savedLayout = await repository.SaveAsync(newLayout, cancellationToken); + + logger.RawLayoutCreated(savedLayout.Id); + + // Publish notification (stub for now) + await notificationPublisher.PublishLayoutSavedAsync(userId, savedLayout.Id, cancellationToken); + + // Trigger processing (stub for now) + await processingTrigger.TriggerAsync(savedLayout.Id, cancellationToken); + + return Results.Created($"/layouts/raw/{savedLayout.Id}", savedLayout); + } + catch (Exception ex) + { + logger.ErrorCreatingRawLayout(userId, ex); + return Results.Problem("Error creating raw layout"); + } + } + + private static async Task<IResult> UpdateRawLayout( + Guid id, + RawLayout layout, + ClaimsPrincipal user, + ILogger<Program> logger, + IRawLayoutRepository repository, + ILayoutProcessingTrigger processingTrigger, + INotificationPublisher notificationPublisher, + CancellationToken cancellationToken) + { + string? userId = user.FindFirst("sub")?.Value; + if (userId is null) + { + return Results.Unauthorized(); + } + + logger.UpdateRawLayoutRequested(userId, id); + + try + { + // Verify the layout exists and belongs to the user + RawLayout? existingLayout = await repository.GetAsync(id, cancellationToken); + if (existingLayout == null || existingLayout.UserId != userId) + { + return Results.NotFound(); + } + + // Update with new data, preserving system fields + RawLayout updatedLayout = layout with + { + Id = id, + UserId = userId, // Always override with authenticated user + CreatedAt = existingLayout.CreatedAt, + UpdatedAt = DateTimeOffset.UtcNow, + Version = existingLayout.Version + 1, + ValidationResult = null // Clear validation result on update + }; + + RawLayout savedLayout = await repository.SaveAsync(updatedLayout, cancellationToken); + + logger.RawLayoutUpdated(savedLayout.Id); + + // Publish notification (stub for now) + await notificationPublisher.PublishLayoutSavedAsync(userId, savedLayout.Id, cancellationToken); + + // Trigger processing (stub for now) + await processingTrigger.TriggerAsync(savedLayout.Id, cancellationToken); + + return Results.Json( + savedLayout, + LayoutContractsJsonContext.Default.RawLayout); + } + catch (Exception ex) + { + logger.ErrorUpdatingRawLayout(id, userId, ex); + return Results.Problem("Error updating raw layout"); + } + } + + private static async Task<IResult> DeleteRawLayout( + Guid id, + ClaimsPrincipal user, + ILogger<Program> logger, + IRawLayoutRepository repository, + CancellationToken cancellationToken) + { + string? userId = user.FindFirst("sub")?.Value; + if (userId is null) + { + return Results.Unauthorized(); + } + + logger.DeleteRawLayoutRequested(userId, id); + + try + { + // Verify the layout exists and belongs to the user + RawLayout? existingLayout = await repository.GetAsync(id, cancellationToken); + if (existingLayout == null || existingLayout.UserId != userId) + { + return Results.NotFound(); + } + + await repository.DeleteAsync(id, cancellationToken); + + logger.RawLayoutDeleted(id); + + return Results.NoContent(); + } + catch (Exception ex) + { + logger.ErrorDeletingRawLayout(id, userId, ex); + return Results.Problem("Error deleting raw layout"); + } + } +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Logging/MessageLogger.cs new file mode 100644 index 00000000..93b9a54d --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Logging/MessageLogger.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.Backend.RawLayoutService.Logging; + +/// <summary> +/// Centralized logging messages for RawLayoutService. +/// All log messages MUST be defined here as [LoggerMessage] source-generated methods. +/// Event ID ranges: +/// 1200-1299: RawLayoutService +/// </summary> +public static partial class MessageLogger +{ + [LoggerMessage(EventId = 1200, Level = LogLevel.Information, Message = "RawLayoutService starting")] + public static partial void ServiceStarting(this ILogger logger); + + [LoggerMessage(EventId = 1201, Level = LogLevel.Information, Message = "RawLayoutService started successfully on {ListenAddress}")] + public static partial void ServiceStarted(this ILogger logger, string listenAddress); + + [LoggerMessage(EventId = 1202, Level = LogLevel.Information, Message = "GET /layouts/raw request received for userId={UserId}")] + public static partial void ListRawLayoutsRequested(this ILogger logger, string userId); + + [LoggerMessage(EventId = 1203, Level = LogLevel.Information, Message = "GET /layouts/raw/{LayoutId} request received for userId={UserId}")] + public static partial void GetRawLayoutRequested(this ILogger logger, string userId, Guid layoutId); + + [LoggerMessage(EventId = 1204, Level = LogLevel.Information, Message = "POST /layouts/raw request received for userId={UserId}")] + public static partial void CreateRawLayoutRequested(this ILogger logger, string userId); + + [LoggerMessage(EventId = 1205, Level = LogLevel.Information, Message = "PUT /layouts/raw/{LayoutId} request received for userId={UserId}")] + public static partial void UpdateRawLayoutRequested(this ILogger logger, string userId, Guid layoutId); + + [LoggerMessage(EventId = 1206, Level = LogLevel.Information, Message = "DELETE /layouts/raw/{LayoutId} request received for userId={UserId}")] + public static partial void DeleteRawLayoutRequested(this ILogger logger, string userId, Guid layoutId); + + [LoggerMessage(EventId = 1207, Level = LogLevel.Information, Message = "Raw layout created successfully: Id={LayoutId}")] + public static partial void RawLayoutCreated(this ILogger logger, Guid layoutId); + + [LoggerMessage(EventId = 1208, Level = LogLevel.Information, Message = "Raw layout updated successfully: Id={LayoutId}")] + public static partial void RawLayoutUpdated(this ILogger logger, Guid layoutId); + + [LoggerMessage(EventId = 1209, Level = LogLevel.Information, Message = "Raw layout deleted successfully: Id={LayoutId}")] + public static partial void RawLayoutDeleted(this ILogger logger, Guid layoutId); + + [LoggerMessage(EventId = 1210, Level = LogLevel.Information, Message = "GET /health request received")] + public static partial void HealthCheckRequested(this ILogger logger); + + [LoggerMessage(EventId = 1211, Level = LogLevel.Information, Message = "Health check successful")] + public static partial void HealthCheckSuccessful(this ILogger logger); + + [LoggerMessage(EventId = 1212, Level = LogLevel.Error, Message = "Error retrieving raw layouts for userId={UserId}")] + public static partial void ErrorRetrievingRawLayouts(this ILogger logger, string userId, Exception exception); + + [LoggerMessage(EventId = 1213, Level = LogLevel.Error, Message = "Error retrieving raw layout Id={LayoutId} for userId={UserId}")] + public static partial void ErrorRetrievingRawLayout(this ILogger logger, Guid layoutId, string userId, Exception exception); + + [LoggerMessage(EventId = 1214, Level = LogLevel.Error, Message = "Error creating raw layout for userId={UserId}")] + public static partial void ErrorCreatingRawLayout(this ILogger logger, string userId, Exception exception); + + [LoggerMessage(EventId = 1215, Level = LogLevel.Error, Message = "Error updating raw layout Id={LayoutId} for userId={UserId}")] + public static partial void ErrorUpdatingRawLayout(this ILogger logger, Guid layoutId, string userId, Exception exception); + + [LoggerMessage(EventId = 1216, Level = LogLevel.Error, Message = "Error deleting raw layout Id={LayoutId} for userId={UserId}")] + public static partial void ErrorDeletingRawLayout(this ILogger logger, Guid layoutId, string userId, Exception exception); + + [LoggerMessage(EventId = 1217, Level = LogLevel.Error, Message = "Error processing health check request")] + public static partial void ErrorProcessingHealthCheck(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 1218, + Level = LogLevel.Error, + Message = "LocalStack dependency check failed at {HealthUrl}: {FailureReason}. LocalStack is required for local development. See docs/local-dev.md for setup instructions")] + public static partial void LocalStackDependencyUnavailable(this ILogger logger, string healthUrl, string failureReason, Exception? exception); +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Program.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Program.cs new file mode 100644 index 00000000..cd4ea6c5 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Program.cs @@ -0,0 +1,245 @@ +using AdaptiveRemote.Backend.RawLayoutService.Configuration; +using AdaptiveRemote.Backend.RawLayoutService.Endpoints; +using AdaptiveRemote.Backend.RawLayoutService.Logging; +using AdaptiveRemote.Backend.RawLayoutService.Repositories; +using AdaptiveRemote.Backend.RawLayoutService.Services; +using AdaptiveRemote.Contracts; +using Amazon.DynamoDBv2; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Scalar.AspNetCore; +using System.Net.Http; +using System.Text.Json; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Configure DynamoDB +DynamoDbSettings dynamoDbSettings = builder.Configuration + .GetSection("DynamoDB") + .Get<DynamoDbSettings>() ?? new DynamoDbSettings(); + +builder.Services.Configure<DynamoDbSettings>(builder.Configuration.GetSection("DynamoDB")); + +// Create DynamoDB client +IAmazonDynamoDB dynamoDbClient; + +if (!string.IsNullOrEmpty(dynamoDbSettings.ServiceUrl)) +{ + // LocalStack or custom endpoint - use explicit credentials from environment + AmazonDynamoDBConfig dynamoDbConfig = new() + { + ServiceURL = dynamoDbSettings.ServiceUrl, + // Don't set RegionEndpoint when using ServiceURL - it overrides the custom endpoint + AuthenticationRegion = dynamoDbSettings.Region + }; + + string? accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID"); + string? secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY"); + + if (!string.IsNullOrEmpty(accessKey) && !string.IsNullOrEmpty(secretKey)) + { + // Use explicit credentials for LocalStack + dynamoDbClient = new AmazonDynamoDBClient( + new Amazon.Runtime.BasicAWSCredentials(accessKey, secretKey), + dynamoDbConfig); + } + else + { + // Fall back to default credential chain + dynamoDbClient = new AmazonDynamoDBClient(dynamoDbConfig); + } +} +else +{ + // Production AWS - use default credential chain (IAM roles, etc.) + AmazonDynamoDBConfig dynamoDbConfig = new() + { + RegionEndpoint = Amazon.RegionEndpoint.GetBySystemName(dynamoDbSettings.Region) + }; + dynamoDbClient = new AmazonDynamoDBClient(dynamoDbConfig); +} + +builder.Services.AddSingleton(dynamoDbClient); + +// Register repositories and services +builder.Services.AddSingleton<DynamoDbRawLayoutRepository>(); +builder.Services.AddSingleton<IRawLayoutRepository>(sp => sp.GetRequiredService<DynamoDbRawLayoutRepository>()); +builder.Services.AddSingleton<IRawLayoutStatusWriter>(sp => sp.GetRequiredService<DynamoDbRawLayoutRepository>()); + +// Register stub implementations (to be replaced in later tasks) +builder.Services.AddSingleton<ILayoutProcessingTrigger, StubLayoutProcessingTrigger>(); +builder.Services.AddSingleton<INotificationPublisher, StubNotificationPublisher>(); + +// Configure JWT Bearer authentication with AWS Cognito +CognitoSettings cognitoSettings = builder.Configuration + .GetSection("Cognito") + .Get<CognitoSettings>() ?? new CognitoSettings(); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = cognitoSettings.Authority; + + if (!string.IsNullOrEmpty(cognitoSettings.Audience)) + { + options.Audience = cognitoSettings.Audience; + } + else + { + // When no audience is configured, skip audience validation. + options.TokenValidationParameters.ValidateAudience = false; + } + + // Preserve original claim names from the JWT (don't remap to .NET claim types). + options.MapInboundClaims = false; + + // Allow HTTP metadata endpoints in non-production environments (local dev and tests). + options.RequireHttpsMetadata = builder.Environment.IsProduction(); + }); + +builder.Services.AddAuthorization(); +builder.Services.AddOpenApi(); + +WebApplication app = builder.Build(); + +ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>(); +logger.ServiceStarting(); + +if (app.Environment.IsDevelopment()) +{ + await EnsureLocalStackRunningAsync(app, logger).ConfigureAwait(false); +} + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapOpenApi(); + +if (app.Environment.IsDevelopment()) +{ + app.MapScalarApiReference(); +} + +// Map endpoints +app.MapHealthEndpoints(); +app.MapLayoutEndpoints(); + +// Log the configured listen address; fall back to Kestrel's default. +// ASPNETCORE_URLS is the standard env-var; "urls" is the equivalent command-line key. +string listenAddress = app.Configuration["ASPNETCORE_URLS"] + ?? app.Configuration["urls"] + ?? "http://localhost:5000"; +logger.ServiceStarted(listenAddress); + +app.Run(); + +static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logger) +{ + const int LocalStackHealthCheckTimeoutSeconds = 5; + const int LocalStackStartupWaitTimeoutSeconds = 30; + const int LocalStackRetryDelaySeconds = 2; + TimeSpan localStackStartupWaitTimeout = TimeSpan.FromSeconds(LocalStackStartupWaitTimeoutSeconds); + TimeSpan localStackRetryDelay = TimeSpan.FromSeconds(LocalStackRetryDelaySeconds); + string[] requiredServices = ["dynamodb"]; + + string baseUrl = app.Configuration["LocalStack:BaseUrl"] ?? "http://localhost:4566"; + + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? baseUri)) + { + logger.LocalStackDependencyUnavailable(baseUrl, "configuration value is not a valid absolute URL", exception: null); + Environment.Exit(1); + } + + Uri healthUri = new(baseUri, "/_localstack/health"); + + using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(LocalStackHealthCheckTimeoutSeconds) }; + Exception? lastException = null; + string? lastFailureReason = null; + DateTime deadlineUtc = DateTime.UtcNow.Add(localStackStartupWaitTimeout); + + while (DateTime.UtcNow < deadlineUtc) + { + try + { + using HttpResponseMessage response = await client.GetAsync(healthUri).ConfigureAwait(false); + string body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + lastFailureReason = $"HTTP {(int)response.StatusCode}"; + } + else + { + using JsonDocument json = JsonDocument.Parse(body); + if (IsLocalStackRunning(json.RootElement, requiredServices, out string failureReason)) + { + return; + } + + lastFailureReason = failureReason; + } + + lastException = null; + } + catch (Exception ex) + { + lastException = ex; + lastFailureReason = ex.Message; + } + + await Task.Delay(localStackRetryDelay).ConfigureAwait(false); + } + + logger.LocalStackDependencyUnavailable( + healthUri.ToString(), + $"did not become healthy within {LocalStackStartupWaitTimeoutSeconds}s; last check result: {lastFailureReason ?? "unknown health check failure"}", + lastException); + Environment.Exit(1); +} + +static bool IsLocalStackRunning(JsonElement root, IReadOnlyList<string> requiredServices, out string failureReason) +{ + if (root.TryGetProperty("status", out JsonElement statusElement)) + { + string status = statusElement.GetString() ?? string.Empty; + if (string.Equals(status, "running", StringComparison.OrdinalIgnoreCase)) + { + failureReason = string.Empty; + return true; + } + + failureReason = $"status='{status}'"; + return false; + } + + if (!root.TryGetProperty("services", out JsonElement servicesElement) || servicesElement.ValueKind != JsonValueKind.Object) + { + failureReason = "health response did not contain a running status or services object"; + return false; + } + + foreach (string service in requiredServices) + { + if (!servicesElement.TryGetProperty(service, out JsonElement serviceStatusElement)) + { + failureReason = $"service '{service}' was missing from health response"; + return false; + } + + string serviceStatus = serviceStatusElement.GetString() ?? string.Empty; + if (!string.Equals(serviceStatus, "available", StringComparison.OrdinalIgnoreCase) && + !string.Equals(serviceStatus, "running", StringComparison.OrdinalIgnoreCase)) + { + failureReason = $"service '{service}' status was '{serviceStatus}'"; + return false; + } + } + + failureReason = string.Empty; + return true; +} + +// Make Program visible for testing +public partial class Program { } diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Properties/launchSettings.json b/src/AdaptiveRemote.Backend.RawLayoutService/Properties/launchSettings.json new file mode 100644 index 00000000..d508bee7 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "profiles": { + "Development": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "scalar", + "outputCapture": "None", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54435;http://localhost:54436" + }, + "AdaptiveRemote.Backend.RawLayoutService": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "scalar", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54435;http://localhost:54436" + } + } +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Repositories/DynamoDbRawLayoutRepository.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Repositories/DynamoDbRawLayoutRepository.cs new file mode 100644 index 00000000..538ccce1 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Repositories/DynamoDbRawLayoutRepository.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using AdaptiveRemote.Backend.RawLayoutService.Configuration; +using AdaptiveRemote.Contracts; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Backend.RawLayoutService.Repositories; + +/// <summary> +/// DynamoDB-backed repository for raw layouts. +/// Implements both IRawLayoutRepository (CRUD) and IRawLayoutStatusWriter (validation result updates). +/// Partition key: UserId; Sort key: Id (Guid as string). +/// </summary> +public class DynamoDbRawLayoutRepository : IRawLayoutRepository, IRawLayoutStatusWriter +{ + private readonly IAmazonDynamoDB _dynamoDb; + private readonly string _tableName; + + public DynamoDbRawLayoutRepository(IAmazonDynamoDB dynamoDb, IOptions<DynamoDbSettings> settings) + { + _dynamoDb = dynamoDb; + _tableName = settings.Value.TableName; + } + + public async Task<RawLayout?> GetAsync(Guid id, CancellationToken ct) + { + // We need to query by Id across all users. In production, this would require a GSI. + // For MVP, we'll scan (inefficient but acceptable for low volume). + ScanRequest scanRequest = new() + { + TableName = _tableName, + FilterExpression = "Id = :id", + ExpressionAttributeValues = new Dictionary<string, AttributeValue> + { + { ":id", new AttributeValue { S = id.ToString() } } + } + }; + + ScanResponse response = await _dynamoDb.ScanAsync(scanRequest, ct); + + if (response.Items.Count == 0) + { + return null; + } + + return response.Items + .Select(DeserializeRawLayout) + .FirstOrDefault(l => l.Id == id); + } + + public async Task<IReadOnlyList<RawLayout>> ListByUserAsync(string userId, CancellationToken ct) + { + QueryRequest queryRequest = new() + { + TableName = _tableName, + KeyConditionExpression = "UserId = :userId", + ExpressionAttributeValues = new Dictionary<string, AttributeValue> + { + { ":userId", new AttributeValue { S = userId } } + } + }; + + QueryResponse response = await _dynamoDb.QueryAsync(queryRequest, ct); + + return response.Items + .Select(DeserializeRawLayout) + .Where(l => l.UserId == userId) + .ToList(); + } + + public async Task<RawLayout> SaveAsync(RawLayout layout, CancellationToken ct) + { + Dictionary<string, AttributeValue> item = new() + { + { "UserId", new AttributeValue { S = layout.UserId } }, + { "Id", new AttributeValue { S = layout.Id.ToString() } }, + { "Name", new AttributeValue { S = layout.Name } }, + { "Elements", new AttributeValue { S = JsonSerializer.Serialize(layout.Elements, LayoutContractsJsonContext.Default.IReadOnlyListRawLayoutElementDto) } }, + { "Version", new AttributeValue { N = layout.Version.ToString() } }, + { "CreatedAt", new AttributeValue { S = layout.CreatedAt.ToString("O") } }, + { "UpdatedAt", new AttributeValue { S = layout.UpdatedAt.ToString("O") } } + }; + + if (layout.ValidationResult != null) + { + item["ValidationResult"] = new AttributeValue + { + S = JsonSerializer.Serialize(layout.ValidationResult, LayoutContractsJsonContext.Default.ValidationResult) + }; + } + + PutItemRequest putRequest = new() + { + TableName = _tableName, + Item = item + }; + + await _dynamoDb.PutItemAsync(putRequest, ct); + + return layout; + } + + public async Task DeleteAsync(Guid id, CancellationToken ct) + { + // First, get the item to find the UserId (partition key) + RawLayout? existingLayout = await GetAsync(id, ct); + if (existingLayout == null) + { + return; // Already deleted or doesn't exist + } + + DeleteItemRequest deleteRequest = new() + { + TableName = _tableName, + Key = new Dictionary<string, AttributeValue> + { + { "UserId", new AttributeValue { S = existingLayout.UserId } }, + { "Id", new AttributeValue { S = id.ToString() } } + } + }; + + await _dynamoDb.DeleteItemAsync(deleteRequest, ct); + } + + public async Task UpdateValidationResultAsync(Guid rawLayoutId, ValidationResult result, CancellationToken ct) + { + // First, get the item to find the UserId (partition key) + RawLayout existingLayout = await GetAsync(rawLayoutId, ct) + ?? throw new InvalidOperationException($"Cannot update validation result: raw layout {rawLayoutId} not found"); + + UpdateItemRequest updateRequest = new() + { + TableName = _tableName, + Key = new Dictionary<string, AttributeValue> + { + { "UserId", new AttributeValue { S = existingLayout.UserId } }, + { "Id", new AttributeValue { S = rawLayoutId.ToString() } } + }, + UpdateExpression = "SET ValidationResult = :validationResult", + ExpressionAttributeValues = new Dictionary<string, AttributeValue> + { + { ":validationResult", new AttributeValue + { + S = JsonSerializer.Serialize(result, LayoutContractsJsonContext.Default.ValidationResult) + } + } + } + }; + + await _dynamoDb.UpdateItemAsync(updateRequest, ct); + } + + private static RawLayout DeserializeRawLayout(Dictionary<string, AttributeValue> item) + { + Guid id = Guid.Parse(item["Id"].S); + string userId = item["UserId"].S; + string name = item["Name"].S; + IReadOnlyList<RawLayoutElementDto> elements = JsonSerializer.Deserialize(item["Elements"].S, LayoutContractsJsonContext.Default.IReadOnlyListRawLayoutElementDto) + ?? Array.Empty<RawLayoutElementDto>(); + int version = int.Parse(item["Version"].N); + DateTimeOffset createdAt = DateTimeOffset.Parse(item["CreatedAt"].S); + DateTimeOffset updatedAt = DateTimeOffset.Parse(item["UpdatedAt"].S); + + ValidationResult? validationResult = null; + if (item.TryGetValue("ValidationResult", out AttributeValue? validationResultAttr)) + { + validationResult = JsonSerializer.Deserialize(validationResultAttr.S, LayoutContractsJsonContext.Default.ValidationResult); + } + + return new RawLayout(id, userId, name, elements, version, createdAt, updatedAt, validationResult); + } +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Services/StubLayoutProcessingTrigger.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Services/StubLayoutProcessingTrigger.cs new file mode 100644 index 00000000..67dfcc9f --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Services/StubLayoutProcessingTrigger.cs @@ -0,0 +1,16 @@ +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.RawLayoutService.Services; + +/// <summary> +/// Stub implementation of ILayoutProcessingTrigger. +/// To be replaced with real SQS implementation in Task 5. +/// </summary> +public class StubLayoutProcessingTrigger : ILayoutProcessingTrigger +{ + public Task TriggerAsync(Guid rawLayoutId, CancellationToken ct) + { + // No-op stub; SQS wiring deferred to Task 5 + return Task.CompletedTask; + } +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Services/StubNotificationPublisher.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Services/StubNotificationPublisher.cs new file mode 100644 index 00000000..efbda773 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Services/StubNotificationPublisher.cs @@ -0,0 +1,22 @@ +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.RawLayoutService.Services; + +/// <summary> +/// Stub implementation of INotificationPublisher. +/// To be replaced with real SSE implementation in Task 9. +/// </summary> +public class StubNotificationPublisher : INotificationPublisher +{ + public Task PublishLayoutSavedAsync(string userId, Guid rawLayoutId, CancellationToken ct) + { + // No-op stub; notification wiring deferred to Task 9 + return Task.CompletedTask; + } + + public Task PublishLayoutReadyAsync(string userId, Guid compiledLayoutId, CancellationToken ct) + { + // No-op stub; notification wiring deferred to Task 9 + return Task.CompletedTask; + } +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.Development.json b/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.Development.json new file mode 100644 index 00000000..0c85ab57 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.Development.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "DynamoDB": { + "ServiceUrl": "http://localhost:4566", + "TableName": "RawLayouts", + "Region": "us-east-1" + }, + "LocalStack": { + "BaseUrl": "http://localhost:4566" + } +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.json b/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.json new file mode 100644 index 00000000..cfbc7ddb --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cognito": { + "Authority": "", + "Audience": "" + }, + "DynamoDB": { + "ServiceUrl": "", + "TableName": "RawLayouts", + "Region": "us-east-1" + } +} diff --git a/src/AdaptiveRemote.Contracts/ILayoutProcessingTrigger.cs b/src/AdaptiveRemote.Contracts/ILayoutProcessingTrigger.cs new file mode 100644 index 00000000..ca79e506 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/ILayoutProcessingTrigger.cs @@ -0,0 +1,9 @@ +namespace AdaptiveRemote.Contracts; + +/// <summary> +/// RawLayoutService → LayoutProcessingService — SQS-backed; enqueues a message on layout save +/// </summary> +public interface ILayoutProcessingTrigger +{ + Task TriggerAsync(Guid rawLayoutId, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.Contracts/INotificationPublisher.cs b/src/AdaptiveRemote.Contracts/INotificationPublisher.cs new file mode 100644 index 00000000..5dc4c933 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/INotificationPublisher.cs @@ -0,0 +1,13 @@ +namespace AdaptiveRemote.Contracts; + +/// <summary> +/// NotificationService — called by RawLayoutService on save, and by LayoutProcessingService on publish. +/// SSE event types: +/// layout-saved → editor subscribes; used to detect concurrent saves on the same layout +/// layout-ready → client subscribes; triggers download of the new compiled layout +/// </summary> +public interface INotificationPublisher +{ + Task PublishLayoutSavedAsync(string userId, Guid rawLayoutId, CancellationToken ct); + Task PublishLayoutReadyAsync(string userId, Guid compiledLayoutId, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.Contracts/IRawLayoutRepository.cs b/src/AdaptiveRemote.Contracts/IRawLayoutRepository.cs new file mode 100644 index 00000000..07643873 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/IRawLayoutRepository.cs @@ -0,0 +1,12 @@ +namespace AdaptiveRemote.Contracts; + +/// <summary> +/// CRUD interface for raw layouts. Used by the editor application to manage layouts. +/// </summary> +public interface IRawLayoutRepository +{ + Task<RawLayout?> GetAsync(Guid id, CancellationToken ct); + Task<IReadOnlyList<RawLayout>> ListByUserAsync(string userId, CancellationToken ct); + Task<RawLayout> SaveAsync(RawLayout layout, CancellationToken ct); + Task DeleteAsync(Guid id, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.Contracts/IRawLayoutStatusWriter.cs b/src/AdaptiveRemote.Contracts/IRawLayoutStatusWriter.cs new file mode 100644 index 00000000..88bf2495 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/IRawLayoutStatusWriter.cs @@ -0,0 +1,10 @@ +namespace AdaptiveRemote.Contracts; + +/// <summary> +/// Narrow write-back interface for LayoutProcessingService to record compilation results +/// on a raw layout without requiring full CRUD access to RawLayoutService. +/// </summary> +public interface IRawLayoutStatusWriter +{ + Task UpdateValidationResultAsync(Guid rawLayoutId, ValidationResult result, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs b/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs index b165db23..787c075a 100644 --- a/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs +++ b/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs @@ -12,4 +12,5 @@ namespace AdaptiveRemote.Contracts; [JsonSerializable(typeof(HealthResponse))] [JsonSerializable(typeof(IReadOnlyList<RawLayout>))] [JsonSerializable(typeof(IReadOnlyList<CompiledLayout>))] +[JsonSerializable(typeof(IReadOnlyList<RawLayoutElementDto>))] public partial class LayoutContractsJsonContext : JsonSerializerContext { } diff --git a/test/AdaptiveRemote.App.Tests/AdaptiveRemote.App.Tests.csproj b/test/AdaptiveRemote.App.Tests/AdaptiveRemote.App.Tests.csproj index 17a6451e..00c243fb 100644 --- a/test/AdaptiveRemote.App.Tests/AdaptiveRemote.App.Tests.csproj +++ b/test/AdaptiveRemote.App.Tests/AdaptiveRemote.App.Tests.csproj @@ -21,6 +21,7 @@ <ItemGroup> <ProjectReference Include="..\..\src\AdaptiveRemote.App\AdaptiveRemote.App.csproj" /> + <ProjectReference Include="..\AdaptiveRemote.TestUtilities\AdaptiveRemote.TestUtilities.csproj" /> </ItemGroup> <ItemGroup> diff --git a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj index f42778e2..62d82992 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj +++ b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj @@ -17,6 +17,7 @@ <PackageReference Include="Reqnroll.MSTest" /> <PackageReference Include="FluentAssertions" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" /> + <PackageReference Include="AWSSDK.DynamoDBv2" /> </ItemGroup> <ItemGroup> diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature new file mode 100644 index 00000000..7971ce52 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature @@ -0,0 +1,68 @@ +@ApiIntegrationTest +Feature: RawLayoutService Endpoints + +Scenario: List raw layouts when user has no layouts + Given RawLayoutService is running + When a test client calls GET /layouts/raw + Then the response is 200 OK + And the body is an empty RawLayout array + +Scenario: Create a new raw layout + Given RawLayoutService is running + When a test client calls POST /layouts/raw with a valid RawLayout body + Then the response is 201 Created + And the body contains the created RawLayout with a generated Id + And the service logs contain a request log entry for POST /layouts/raw + And the service logs contain no warnings or errors + +Scenario: Get raw layout by ID + Given RawLayoutService is running + And a raw layout exists with name "Test Layout" + When a test client calls GET /layouts/raw/{id} for the created layout + Then the response is 200 OK + And the body deserializes to the created RawLayout + +Scenario: Update an existing raw layout + Given RawLayoutService is running + And a raw layout exists with name "Original Layout" + When a test client calls PUT /layouts/raw/{id} with updated name "Updated Layout" + Then the response is 200 OK + And the returned layout has name "Updated Layout" + And the layout version is incremented + +Scenario: Delete a raw layout + Given RawLayoutService is running + And a raw layout exists with name "Layout to Delete" + When a test client calls DELETE /layouts/raw/{id} for the created layout + Then the response is 204 No Content + And getting the layout by ID returns 404 Not Found + +Scenario: Access raw layouts without authentication + Given RawLayoutService is running + When an unauthenticated client calls GET /layouts/raw + Then the response is 401 Unauthorized + +Scenario: Get non-existent layout by ID + Given RawLayoutService is running + When a test client calls GET /layouts/raw/{id} with a random GUID + Then the response is 404 Not Found + +Scenario: Update non-existent layout + Given RawLayoutService is running + When a test client calls PUT /layouts/raw/{id} with a random GUID and name "Updated" + Then the response is 404 Not Found + +Scenario: Delete non-existent layout + Given RawLayoutService is running + When a test client calls DELETE /layouts/raw/{id} with a random GUID + Then the response is 404 Not Found + +Scenario: Create layout with invalid data + Given RawLayoutService is running + When a test client calls POST /layouts/raw with an invalid RawLayout body + Then the response is 400 Bad Request + +Scenario: Create layout with missing required fields + Given RawLayoutService is running + When a test client calls POST /layouts/raw with a RawLayout missing required fields + Then the response is 400 Bad Request diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature.cs new file mode 100644 index 00000000..adf35c35 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature.cs @@ -0,0 +1,544 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace AdaptiveRemote.Backend.ApiTests.Features +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class RawLayoutServiceEndpointsFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "ApiIntegrationTest"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "RawLayoutService Endpoints", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "RawLayoutEndpoints.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute(Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupBehavior.EndOfClass)] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/RawLayoutEndpoints.feature.ndjson", 13); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("List raw layouts when user has no layouts")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("List raw layouts when user has no layouts")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task ListRawLayoutsWhenUserHasNoLayouts() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("List raw layouts when user has no layouts", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 4 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 5 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 6 + await testRunner.WhenAsync("a test client calls GET /layouts/raw", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 7 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 8 + await testRunner.AndAsync("the body is an empty RawLayout array", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Create a new raw layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Create a new raw layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task CreateANewRawLayout() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create a new raw layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 10 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 11 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 12 + await testRunner.WhenAsync("a test client calls POST /layouts/raw with a valid RawLayout body", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 13 + await testRunner.ThenAsync("the response is 201 Created", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 14 + await testRunner.AndAsync("the body contains the created RawLayout with a generated Id", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.AndAsync("the service logs contain a request log entry for POST /layouts/raw", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.AndAsync("the service logs contain no warnings or errors", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get raw layout by ID")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get raw layout by ID")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task GetRawLayoutByID() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "2"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get raw layout by ID", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 18 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 19 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 20 + await testRunner.AndAsync("a raw layout exists with name \"Test Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 21 + await testRunner.WhenAsync("a test client calls GET /layouts/raw/{id} for the created layout", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 22 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 23 + await testRunner.AndAsync("the body deserializes to the created RawLayout", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Update an existing raw layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Update an existing raw layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task UpdateAnExistingRawLayout() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "3"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Update an existing raw layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 25 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 26 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 27 + await testRunner.AndAsync("a raw layout exists with name \"Original Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 28 + await testRunner.WhenAsync("a test client calls PUT /layouts/raw/{id} with updated name \"Updated Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 29 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 30 + await testRunner.AndAsync("the returned layout has name \"Updated Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 31 + await testRunner.AndAsync("the layout version is incremented", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Delete a raw layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Delete a raw layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task DeleteARawLayout() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "4"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete a raw layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 33 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 34 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 35 + await testRunner.AndAsync("a raw layout exists with name \"Layout to Delete\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 36 + await testRunner.WhenAsync("a test client calls DELETE /layouts/raw/{id} for the created layout", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 37 + await testRunner.ThenAsync("the response is 204 No Content", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 38 + await testRunner.AndAsync("getting the layout by ID returns 404 Not Found", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Access raw layouts without authentication")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Access raw layouts without authentication")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task AccessRawLayoutsWithoutAuthentication() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "5"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Access raw layouts without authentication", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 40 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 41 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 42 + await testRunner.WhenAsync("an unauthenticated client calls GET /layouts/raw", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 43 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get non-existent layout by ID")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get non-existent layout by ID")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task GetNon_ExistentLayoutByID() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "6"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get non-existent layout by ID", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 45 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 46 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 47 + await testRunner.WhenAsync("a test client calls GET /layouts/raw/{id} with a random GUID", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 48 + await testRunner.ThenAsync("the response is 404 Not Found", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Update non-existent layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Update non-existent layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task UpdateNon_ExistentLayout() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "7"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Update non-existent layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 50 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 51 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 52 + await testRunner.WhenAsync("a test client calls PUT /layouts/raw/{id} with a random GUID and name \"Updated\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 53 + await testRunner.ThenAsync("the response is 404 Not Found", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Delete non-existent layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Delete non-existent layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task DeleteNon_ExistentLayout() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "8"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete non-existent layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 55 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 56 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 57 + await testRunner.WhenAsync("a test client calls DELETE /layouts/raw/{id} with a random GUID", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 58 + await testRunner.ThenAsync("the response is 404 Not Found", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Create layout with invalid data")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Create layout with invalid data")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task CreateLayoutWithInvalidData() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "9"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create layout with invalid data", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 60 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 61 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 62 + await testRunner.WhenAsync("a test client calls POST /layouts/raw with an invalid RawLayout body", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 63 + await testRunner.ThenAsync("the response is 400 Bad Request", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Create layout with missing required fields")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Create layout with missing required fields")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task CreateLayoutWithMissingRequiredFields() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "10"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create layout with missing required fields", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 65 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 66 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 67 + await testRunner.WhenAsync("a test client calls POST /layouts/raw with a RawLayout missing required fields", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 68 + await testRunner.ThenAsync("the response is 400 Bad Request", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs index 7def9989..9c9ebfe2 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs @@ -41,14 +41,6 @@ public async Task WhenExpiredJwtClientCallsGet(string endpoint) _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); } - [Then(@"the response is (\d+) Unauthorized")] - public void ThenTheResponseIsUnauthorized(int statusCode) - { - _context.LastResponse.Should().NotBeNull(); - ((int)_context.LastResponse!.StatusCode).Should().Be(statusCode); - _context.LastResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - public void Dispose() { // ServiceContext owns LastResponse and Fixture; nothing to dispose here. diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs index a05241fb..38ee35b1 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs @@ -23,7 +23,7 @@ public async Task GivenCompiledLayoutServiceIsRunning() await _context.Fixture.StartServiceAsync(); } - [When(@"a test client calls GET (.*)")] + [When(@"a test client calls GET (/\S+)")] public async Task WhenATestClientCallsGet(string endpoint) { _context.LastResponse = await _context.Fixture.HttpClient.GetAsync(endpoint); @@ -37,6 +37,27 @@ public void ThenTheResponseIsOk(int statusCode) ((int)_context.LastResponse!.StatusCode).Should().Be(statusCode); } + [Then(@"the response is 404 Not Found")] + public void ThenTheResponseIsNotFound() + { + _context.LastResponse.Should().NotBeNull(); + _context.LastResponse!.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Then(@"the response is 400 Bad Request")] + public void ThenTheResponseIsBadRequest() + { + _context.LastResponse.Should().NotBeNull(); + _context.LastResponse!.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Then(@"the response is 401 Unauthorized")] + public void ThenTheResponseIsUnauthorized() + { + _context.LastResponse.Should().NotBeNull(); + _context.LastResponse!.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + [Then(@"the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext")] public void ThenTheBodyDeserializesToValidCompiledLayout() { @@ -72,7 +93,7 @@ public void ThenTheCompiledLayoutContainsExpectedCommands() commands.Should().Contain(c => c.Name == "Exit" && c.Type == CommandType.Lifecycle); } - [Then(@"the service logs contain a request log entry for GET (.*)")] + [Then(@"the service logs contain a request log entry for (?:GET|POST|PUT|DELETE|PATCH) (.*)")] public void ThenTheServiceLogsContainRequestLogEntry(string endpoint) { string logs = _context.Fixture.GetLogs(); diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/RawLayoutSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/RawLayoutSteps.cs new file mode 100644 index 00000000..02536c28 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/RawLayoutSteps.cs @@ -0,0 +1,303 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using AdaptiveRemote.Backend.ApiTests.Support; +using AdaptiveRemote.Contracts; +using FluentAssertions; +using Reqnroll; + +namespace AdaptiveRemote.Backend.ApiTests.StepDefinitions; + +[Binding] +public class RawLayoutSteps +{ + private readonly ServiceContext _context; + private RawLayout? _createdLayout; + + public RawLayoutSteps(ServiceContext context) + { + _context = context; + } + + [Given(@"RawLayoutService is running")] + public async Task GivenRawLayoutServiceIsRunning() + { + await _context.Fixture.StartServiceAsync("AdaptiveRemote.Backend.RawLayoutService"); + } + + [Given(@"a raw layout exists with name ""(.*)""")] + public async Task GivenARawLayoutExistsWithName(string layoutName) + { + // Create a test layout + RawLayout testLayout = new( + Id: Guid.Empty, // Will be generated by the service + UserId: "test-user", // Will be overridden by the service with authenticated user + Name: layoutName, + Elements: new List<RawLayoutElementDto> + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "TestCommand", + Label: "Test", + Glyph: null, + SpeakPhrase: "test command", + Reverse: null, + CssId: "test-cmd", + GridRow: 0, + GridColumn: 0 + ) + }, + Version: 1, + CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow, + ValidationResult: null + ); + + StringContent content = new( + JsonSerializer.Serialize(testLayout, LayoutContractsJsonContext.Default.RawLayout), + Encoding.UTF8, + "application/json"); + + HttpResponseMessage response = await _context.Fixture.HttpClient.PostAsync("/layouts/raw", content); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + string responseBody = await response.Content.ReadAsStringAsync(); + _createdLayout = JsonSerializer.Deserialize<RawLayout>(responseBody, LayoutContractsJsonContext.Default.RawLayout); + _createdLayout.Should().NotBeNull(); + } + + [When(@"a test client calls POST \/layouts\/raw with a valid RawLayout body")] + public async Task WhenATestClientCallsPostWithValidRawLayout() + { + RawLayout testLayout = new( + Id: Guid.Empty, + UserId: "test-user", + Name: "New Test Layout", + Elements: new List<RawLayoutElementDto> + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Up", + Label: "Up", + Glyph: "↑", + SpeakPhrase: "up", + Reverse: "Down", + CssId: "up-btn", + GridRow: 0, + GridColumn: 1 + ) + }, + Version: 1, + CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow, + ValidationResult: null + ); + + StringContent content = new( + JsonSerializer.Serialize(testLayout, LayoutContractsJsonContext.Default.RawLayout), + Encoding.UTF8, + "application/json"); + + _context.LastResponse = await _context.Fixture.HttpClient.PostAsync("/layouts/raw", content); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client calls GET \/layouts\/raw\/\{id\} for the created layout")] + public async Task WhenATestClientCallsGetForTheCreatedLayout() + { + _createdLayout.Should().NotBeNull(); + _context.LastResponse = await _context.Fixture.HttpClient.GetAsync($"/layouts/raw/{_createdLayout!.Id}"); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client calls PUT \/layouts\/raw\/\{id\} with updated name ""(.*)""")] + public async Task WhenATestClientCallsPutWithUpdatedName(string newName) + { + _createdLayout.Should().NotBeNull(); + + RawLayout updatedLayout = _createdLayout! with { Name = newName }; + + StringContent content = new( + JsonSerializer.Serialize(updatedLayout, LayoutContractsJsonContext.Default.RawLayout), + Encoding.UTF8, + "application/json"); + + _context.LastResponse = await _context.Fixture.HttpClient.PutAsync($"/layouts/raw/{_createdLayout.Id}", content); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client calls DELETE \/layouts\/raw\/\{id\} for the created layout")] + public async Task WhenATestClientCallsDeleteForTheCreatedLayout() + { + _createdLayout.Should().NotBeNull(); + _context.LastResponse = await _context.Fixture.HttpClient.DeleteAsync($"/layouts/raw/{_createdLayout!.Id}"); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"an unauthenticated client calls GET \/layouts\/raw")] + public async Task WhenAnUnauthenticatedClientCallsGet() + { + HttpClient anonymousClient = _context.Fixture.CreateAnonymousHttpClient(); + _context.LastResponse = await anonymousClient.GetAsync("/layouts/raw"); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client calls GET \/layouts\/raw\/\{id\} with a random GUID")] + public async Task WhenATestClientCallsGetWithRandomGuid() + { + Guid randomId = Guid.NewGuid(); + _context.LastResponse = await _context.Fixture.HttpClient.GetAsync($"/layouts/raw/{randomId}"); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client calls PUT \/layouts\/raw\/\{id\} with a random GUID and name ""(.*)""")] + public async Task WhenATestClientCallsPutWithRandomGuid(string name) + { + Guid randomId = Guid.NewGuid(); + RawLayout layout = new( + Id: randomId, + UserId: "test-user", + Name: name, + Elements: Array.Empty<RawLayoutElementDto>(), + Version: 1, + CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow, + ValidationResult: null + ); + + StringContent content = new( + JsonSerializer.Serialize(layout, LayoutContractsJsonContext.Default.RawLayout), + Encoding.UTF8, + "application/json"); + + _context.LastResponse = await _context.Fixture.HttpClient.PutAsync($"/layouts/raw/{randomId}", content); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client calls DELETE \/layouts\/raw\/\{id\} with a random GUID")] + public async Task WhenATestClientCallsDeleteWithRandomGuid() + { + Guid randomId = Guid.NewGuid(); + _context.LastResponse = await _context.Fixture.HttpClient.DeleteAsync($"/layouts/raw/{randomId}"); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client calls POST \/layouts\/raw with an invalid RawLayout body")] + public async Task WhenATestClientCallsPostWithInvalidRawLayout() + { + // Send malformed JSON + StringContent content = new( + "{\"Name\": \"Test\", \"InvalidField\": true}", + Encoding.UTF8, + "application/json"); + + _context.LastResponse = await _context.Fixture.HttpClient.PostAsync("/layouts/raw", content); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client calls POST \/layouts\/raw with a RawLayout missing required fields")] + public async Task WhenATestClientCallsPostWithMissingFields() + { + // Send JSON with only partial fields (missing Elements, Version, etc.) + StringContent content = new( + "{\"Name\": \"Incomplete Layout\"}", + Encoding.UTF8, + "application/json"); + + _context.LastResponse = await _context.Fixture.HttpClient.PostAsync("/layouts/raw", content); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [Then(@"the body is an empty RawLayout array")] + public void ThenTheBodyIsAnEmptyRawLayoutArray() + { + _context.LastResponseBody.Should().NotBeNullOrEmpty(); + + IReadOnlyList<RawLayout>? layouts = JsonSerializer.Deserialize<IReadOnlyList<RawLayout>>( + _context.LastResponseBody!, + LayoutContractsJsonContext.Default.IReadOnlyListRawLayout); + + layouts.Should().NotBeNull(); + layouts.Should().BeEmpty(); + } + + [Then(@"the response is 201 Created")] + public void ThenTheResponseIsCreated() + { + _context.LastResponse.Should().NotBeNull(); + _context.LastResponse!.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Then(@"the response is 204 No Content")] + public void ThenTheResponseIsNoContent() + { + _context.LastResponse.Should().NotBeNull(); + _context.LastResponse!.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Then(@"the body contains the created RawLayout with a generated Id")] + public void ThenTheBodyContainsTheCreatedRawLayout() + { + _context.LastResponseBody.Should().NotBeNullOrEmpty(); + + RawLayout? layout = JsonSerializer.Deserialize<RawLayout>( + _context.LastResponseBody!, + LayoutContractsJsonContext.Default.RawLayout); + + layout.Should().NotBeNull(); + layout!.Id.Should().NotBeEmpty(); + layout.Name.Should().Be("New Test Layout"); + layout.Elements.Should().HaveCount(1); + layout.Version.Should().Be(1); + } + + [Then(@"the body deserializes to the created RawLayout")] + public void ThenTheBodyDeserializesToTheCreatedRawLayout() + { + _context.LastResponseBody.Should().NotBeNullOrEmpty(); + + RawLayout? layout = JsonSerializer.Deserialize<RawLayout>( + _context.LastResponseBody!, + LayoutContractsJsonContext.Default.RawLayout); + + layout.Should().NotBeNull(); + layout!.Id.Should().Be(_createdLayout!.Id); + layout.Name.Should().Be(_createdLayout.Name); + } + + [Then(@"the returned layout has name ""(.*)""")] + public void ThenTheReturnedLayoutHasName(string expectedName) + { + _context.LastResponseBody.Should().NotBeNullOrEmpty(); + + RawLayout? layout = JsonSerializer.Deserialize<RawLayout>( + _context.LastResponseBody!, + LayoutContractsJsonContext.Default.RawLayout); + + layout.Should().NotBeNull(); + layout!.Name.Should().Be(expectedName); + } + + [Then(@"the layout version is incremented")] + public void ThenTheLayoutVersionIsIncremented() + { + _context.LastResponseBody.Should().NotBeNullOrEmpty(); + + RawLayout? layout = JsonSerializer.Deserialize<RawLayout>( + _context.LastResponseBody!, + LayoutContractsJsonContext.Default.RawLayout); + + layout.Should().NotBeNull(); + _createdLayout.Should().NotBeNull(); + layout!.Version.Should().Be(_createdLayout!.Version + 1); + } + + [Then(@"getting the layout by ID returns 404 Not Found")] + public async Task ThenGettingTheLayoutByIdReturnsNotFound() + { + _createdLayout.Should().NotBeNull(); + HttpResponseMessage response = await _context.Fixture.HttpClient.GetAsync($"/layouts/raw/{_createdLayout!.Id}"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/LocalStackFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/LocalStackFixture.cs new file mode 100644 index 00000000..ba552a30 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/LocalStackFixture.cs @@ -0,0 +1,222 @@ +using System.Diagnostics; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; + +namespace AdaptiveRemote.Backend.ApiTests.Support; + +/// <summary> +/// Manages a LocalStack Docker container for integration testing. +/// Provides a local DynamoDB instance that services can connect to. +/// </summary> +public class LocalStackFixture : IDisposable +{ + private Process? _dockerProcess; + private bool _isStarted; + private bool _ownsContainer; // Track if we created the container + + public string ServiceUrl { get; } = "http://localhost:4566"; + + public string Region { get; } = "us-east-1"; + + /// <summary> + /// Starts LocalStack in a Docker container and waits for it to be ready. + /// </summary> + public async Task StartAsync() + { + if (_isStarted) + { + return; + } + + // Check if LocalStack container is already running + Process checkProcess = new() + { + StartInfo = new ProcessStartInfo + { + FileName = "docker", + Arguments = "ps --filter name=localstack-test --format {{.Names}}", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + + checkProcess.Start(); + string existingContainer = await checkProcess.StandardOutput.ReadToEndAsync(); + await checkProcess.WaitForExitAsync(); + + if (!string.IsNullOrWhiteSpace(existingContainer)) + { + // Container already running, just mark as started (we don't own it) + _isStarted = true; + _ownsContainer = false; + await WaitForLocalStackReadyAsync(); + return; + } + + // Start LocalStack container + ProcessStartInfo startInfo = new() + { + FileName = "docker", + Arguments = "run --rm -d " + + "--name localstack-test " + + "-p 4566:4566 " + + "-e SERVICES=dynamodb " + + "localstack/localstack:3.0", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + _dockerProcess = new Process { StartInfo = startInfo }; + _dockerProcess.Start(); + + string containerId = await _dockerProcess.StandardOutput.ReadToEndAsync(); + await _dockerProcess.WaitForExitAsync(); + + if (_dockerProcess.ExitCode != 0) + { + string error = await _dockerProcess.StandardError.ReadToEndAsync(); + throw new InvalidOperationException($"Failed to start LocalStack: {error}"); + } + + // Wait for LocalStack to be ready + await WaitForLocalStackReadyAsync(); + + _isStarted = true; + _ownsContainer = true; // We created this container + } + + /// <summary> + /// Creates a DynamoDB table in LocalStack for testing. + /// </summary> + public async Task CreateTableAsync(string tableName, CancellationToken cancellationToken = default) + { + if (!_isStarted) + { + throw new InvalidOperationException("LocalStack must be started before creating tables"); + } + + // Use dummy credentials for LocalStack + Amazon.Runtime.BasicAWSCredentials credentials = new("test", "test"); + + AmazonDynamoDBConfig config = new() + { + ServiceURL = ServiceUrl, + // Don't set RegionEndpoint when using ServiceURL - it overrides the custom endpoint + AuthenticationRegion = Region + }; + + using AmazonDynamoDBClient client = new(credentials, config); + + // Check if table already exists + try + { + await client.DescribeTableAsync(tableName, cancellationToken); + // Table exists, no need to create + return; + } + catch (ResourceNotFoundException) + { + // Table doesn't exist, proceed to create + } + + CreateTableRequest request = new() + { + TableName = tableName, + KeySchema = new List<KeySchemaElement> + { + new KeySchemaElement { AttributeName = "UserId", KeyType = KeyType.HASH }, + new KeySchemaElement { AttributeName = "Id", KeyType = KeyType.RANGE } + }, + AttributeDefinitions = new List<AttributeDefinition> + { + new AttributeDefinition { AttributeName = "UserId", AttributeType = ScalarAttributeType.S }, + new AttributeDefinition { AttributeName = "Id", AttributeType = ScalarAttributeType.S } + }, + BillingMode = BillingMode.PAY_PER_REQUEST + }; + + await client.CreateTableAsync(request, cancellationToken); + + // Wait for table to be active + bool isActive = false; + for (int i = 0; i < 30 && !isActive; i++) + { + try + { + DescribeTableResponse response = await client.DescribeTableAsync(tableName, cancellationToken); + isActive = response.Table.TableStatus == TableStatus.ACTIVE; + if (!isActive) + { + await Task.Delay(500, cancellationToken); + } + } + catch (ResourceNotFoundException) + { + await Task.Delay(500, cancellationToken); + } + } + + if (!isActive) + { + throw new InvalidOperationException($"Table {tableName} did not become active within 15 seconds"); + } + } + + private async Task WaitForLocalStackReadyAsync() + { + // Poll LocalStack health endpoint + using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(2) }; + + for (int i = 0; i < 60; i++) + { + try + { + HttpResponseMessage response = await client.GetAsync($"{ServiceUrl}/_localstack/health"); + if (response.IsSuccessStatusCode) + { + // Give it a bit more time to fully initialize DynamoDB + await Task.Delay(2000); + return; + } + } + catch + { + // Ignore exceptions during startup + } + + await Task.Delay(1000); + } + + throw new InvalidOperationException("LocalStack did not become ready within 60 seconds"); + } + + public void Dispose() + { + // Only stop the container if we created it + if (_ownsContainer && _dockerProcess != null) + { + // Stop and remove the container + Process stopProcess = new() + { + StartInfo = new ProcessStartInfo + { + FileName = "docker", + Arguments = "stop localstack-test", + UseShellExecute = false, + CreateNoWindow = true + } + }; + + stopProcess.Start(); + stopProcess.WaitForExit(10000); + stopProcess.Dispose(); + + _dockerProcess.Dispose(); + } + + GC.SuppressFinalize(this); + } +} diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs index 959e73e6..bc7c983a 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs @@ -7,7 +7,7 @@ namespace AdaptiveRemote.Backend.ApiTests.Support; /// <summary> -/// Manages the lifecycle of CompiledLayoutService for API integration tests. +/// Manages the lifecycle of backend services for API integration tests. /// Starts the service process and captures structured log output. /// /// A <see cref="TestJwtAuthority"/> is started before the service so that the @@ -20,10 +20,20 @@ namespace AdaptiveRemote.Backend.ApiTests.Support; /// </summary> public class ServiceFixture : IDisposable { + // LocalStack is shared across all scenarios to avoid repeated slow startups. + // Data isolation is achieved via unique per-scenario user IDs. + private static LocalStackFixture? _sharedLocalStack; + private static readonly SemaphoreSlim _localStackInitLock = new(1, 1); + private Process? _serviceProcess; private readonly StringBuilder _logOutput = new(); private readonly object _logLock = new(); private TestJwtAuthority? _jwtAuthority; + private string? _startedServiceName; + + // Use a unique user ID per fixture so each scenario operates on isolated data + // even when DynamoDB is shared across test scenarios via the shared LocalStack. + private readonly string _testUserId = $"test-user-{Guid.NewGuid():N}"; public string ServiceUrl { get; } @@ -37,13 +47,28 @@ public ServiceFixture() ServiceUrl = $"http://localhost:{GetFreePort()}"; } - public async Task StartServiceAsync() + public async Task StartServiceAsync(string serviceName = "AdaptiveRemote.Backend.CompiledLayoutService") { if (_serviceProcess != null) { + if (_startedServiceName != serviceName) + { + throw new InvalidOperationException($"Service fixture already started with {_startedServiceName}, cannot start {serviceName}"); + } return; // Already started } + _startedServiceName = serviceName; + + // Start LocalStack if this is the RawLayoutService (which needs DynamoDB). + // A single LocalStack instance is shared across all scenarios. + LocalStackFixture? localStack = null; + if (serviceName == "AdaptiveRemote.Backend.RawLayoutService") + { + localStack = await GetSharedLocalStackAsync(); + await localStack.CreateTableAsync("RawLayouts"); + } + // Start the JWT authority first so its URL is available for service configuration. _jwtAuthority = new TestJwtAuthority(); @@ -62,8 +87,8 @@ public async Task StartServiceAsync() string projectPath = Path.Combine( repoRoot, - "src", "AdaptiveRemote.Backend.CompiledLayoutService", - "AdaptiveRemote.Backend.CompiledLayoutService.csproj"); + "src", serviceName, + $"{serviceName}.csproj"); if (!File.Exists(projectPath)) { @@ -91,6 +116,17 @@ public async Task StartServiceAsync() } }; + // Configure DynamoDB for RawLayoutService + if (localStack != null) + { + startInfo.Environment["DynamoDB__ServiceUrl"] = localStack.ServiceUrl; + startInfo.Environment["DynamoDB__Region"] = localStack.Region; + startInfo.Environment["DynamoDB__TableName"] = "RawLayouts"; + // Provide dummy AWS credentials for LocalStack + startInfo.Environment["AWS_ACCESS_KEY_ID"] = "test"; + startInfo.Environment["AWS_SECRET_ACCESS_KEY"] = "test"; + } + _serviceProcess = new Process { StartInfo = startInfo }; _serviceProcess.OutputDataReceived += (sender, args) => @@ -163,21 +199,22 @@ public async Task StartServiceAsync() throw new InvalidOperationException($"Service failed to start within 30 seconds (polling {ServiceUrl}/health). Logs:\n{logs}"); } - // Default HttpClient includes a valid bearer token for the standard test user. + // Default HttpClient includes a valid bearer token for the scenario-unique test user. HttpClient = CreateBearerHttpClient(CreateToken()); } /// <summary> - /// Creates a valid JWT for the given subject (default: "test-user"). + /// Creates a valid JWT for the given subject. Defaults to the scenario-unique test user + /// to ensure each scenario operates on isolated DynamoDB data. /// </summary> - public string CreateToken(string sub = "test-user") + public string CreateToken(string? sub = null) { if (_jwtAuthority is null) { throw new InvalidOperationException("StartServiceAsync() must be called before CreateToken()"); } - return _jwtAuthority.CreateToken(sub); + return _jwtAuthority.CreateToken(sub ?? _testUserId); } /// <summary> @@ -224,6 +261,7 @@ public void Dispose() HttpClient?.Dispose(); _jwtAuthority?.Dispose(); + // LocalStack is shared across all scenarios; do not dispose it here. GC.SuppressFinalize(this); } @@ -236,6 +274,26 @@ private static int GetFreePort() return port; } + private static async Task<LocalStackFixture> GetSharedLocalStackAsync() + { + await _localStackInitLock.WaitAsync().ConfigureAwait(false); + try + { + if (_sharedLocalStack == null) + { + LocalStackFixture localStack = new(); + await localStack.StartAsync().ConfigureAwait(false); + _sharedLocalStack = localStack; + } + + return _sharedLocalStack; + } + finally + { + _localStackInitLock.Release(); + } + } + /// <summary> /// Adds a bearer token to every outgoing request. /// </summary> diff --git a/test/AdaptiveRemote.Backend.RawLayoutService.Tests/AdaptiveRemote.Backend.RawLayoutService.Tests.csproj b/test/AdaptiveRemote.Backend.RawLayoutService.Tests/AdaptiveRemote.Backend.RawLayoutService.Tests.csproj new file mode 100644 index 00000000..ac6be815 --- /dev/null +++ b/test/AdaptiveRemote.Backend.RawLayoutService.Tests/AdaptiveRemote.Backend.RawLayoutService.Tests.csproj @@ -0,0 +1,32 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>AdaptiveRemote.Backend.RawLayoutService.Tests</RootNamespace> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="FluentAssertions" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest.TestAdapter" /> + <PackageReference Include="MSTest.TestFramework" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\src\AdaptiveRemote.Backend.RawLayoutService\AdaptiveRemote.Backend.RawLayoutService.csproj" /> + <ProjectReference Include="..\..\src\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj" /> + <ProjectReference Include="..\AdaptiveRemote.TestUtilities\AdaptiveRemote.TestUtilities.csproj" /> + </ItemGroup> + + <ItemGroup> + <Using Include="AdaptiveRemote.TestUtilities" /> + <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" /> + </ItemGroup> + +</Project> diff --git a/test/AdaptiveRemote.Backend.RawLayoutService.Tests/Repositories/DynamoDbRawLayoutRepositoryTests.cs b/test/AdaptiveRemote.Backend.RawLayoutService.Tests/Repositories/DynamoDbRawLayoutRepositoryTests.cs new file mode 100644 index 00000000..29125d4e --- /dev/null +++ b/test/AdaptiveRemote.Backend.RawLayoutService.Tests/Repositories/DynamoDbRawLayoutRepositoryTests.cs @@ -0,0 +1,830 @@ +using AdaptiveRemote.Backend.RawLayoutService.Configuration; +using AdaptiveRemote.Backend.RawLayoutService.Repositories; +using AdaptiveRemote.Contracts; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; + +namespace AdaptiveRemote.Backend.RawLayoutService.Tests.Repositories; + +[TestClass] +public class DynamoDbRawLayoutRepositoryTests +{ + private Mock<IAmazonDynamoDB> _mockDynamoDb = null!; + private DynamoDbRawLayoutRepository _repository = null!; + private const string TestTableName = "RawLayoutsTest"; + private const string TestUserId = "test-user-123"; + private const string OtherUserId = "other-user-456"; + + [TestInitialize] + public void Setup() + { + _mockDynamoDb = new Mock<IAmazonDynamoDB>(); + IOptions<DynamoDbSettings> settings = Options.Create(new DynamoDbSettings + { + TableName = TestTableName, + Region = "us-east-1" + }); + _repository = new DynamoDbRawLayoutRepository(_mockDynamoDb.Object, settings); + + // Default: no DynamoDB methods should be called unless a test explicitly sets them up + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .Verifiable(Times.Never); + _mockDynamoDb + .Setup(db => db.QueryAsync(It.IsAny<QueryRequest>(), It.IsAny<CancellationToken>())) + .Verifiable(Times.Never); + _mockDynamoDb + .Setup(db => db.PutItemAsync(It.IsAny<PutItemRequest>(), It.IsAny<CancellationToken>())) + .Verifiable(Times.Never); + _mockDynamoDb + .Setup(db => db.DeleteItemAsync(It.IsAny<DeleteItemRequest>(), It.IsAny<CancellationToken>())) + .Verifiable(Times.Never); + _mockDynamoDb + .Setup(db => db.UpdateItemAsync(It.IsAny<UpdateItemRequest>(), It.IsAny<CancellationToken>())) + .Verifiable(Times.Never); + } + + [TestCleanup] + public void Cleanup() + { + _mockDynamoDb.Verify(); + } + + // ─── GetAsync ──────────────────────────────────────────────────────────────── + + [TestMethod] + public void GetAsync_WithValidId_ReturnsMatchingLayout() + { + // Arrange + Guid testId = Guid.NewGuid(); + Guid decoyId = Guid.NewGuid(); + + // Response includes a decoy item with a different ID to prove the code finds the right one + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<RawLayout?> resultTask = _repository.GetAsync(testId, CancellationToken.None); + + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + tcs.SetResult(new ScanResponse + { + Items = + [ + CreateDynamoDbItem(decoyId, OtherUserId, "Decoy Layout", 1), + CreateDynamoDbItem(testId, TestUserId, "Test Layout", 1) + ] + }); + + // Assert + resultTask.Should().BeComplete(); + RawLayout? result = resultTask.Result; + result.Should().NotBeNull(); + result!.Id.Should().Be(testId); + result.UserId.Should().Be(TestUserId); + result.Name.Should().Be("Test Layout"); + result.Version.Should().Be(1); + + // Verify correct scan filter was sent to DynamoDB + _mockDynamoDb.Verify(db => db.ScanAsync( + It.Is<ScanRequest>(req => + req.FilterExpression == "Id = :id" && + req.ExpressionAttributeValues.ContainsKey(":id") && + req.ExpressionAttributeValues[":id"].S == testId.ToString()), + It.IsAny<CancellationToken>()), + Times.Once); + } + + [TestMethod] + public void GetAsync_WithNonExistentId_ReturnsNull() + { + // Arrange + Guid testId = Guid.NewGuid(); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<RawLayout?> resultTask = _repository.GetAsync(testId, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + tcs.SetResult(new ScanResponse { Items = [] }); + + // Assert + resultTask.Should().BeComplete(); + resultTask.Result.Should().BeNull(); + } + + [TestMethod] + public void GetAsync_WhenScanAsyncIsPending_TaskIsNotComplete() + { + // Arrange + Guid testId = Guid.NewGuid(); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<RawLayout?> resultTask = _repository.GetAsync(testId, CancellationToken.None); + + // Assert + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + tcs.SetResult(new ScanResponse { Items = [] }); + + resultTask.Should().BeComplete(because: "ScanAsync has now completed"); + } + + [TestMethod] + public void GetAsync_WhenCancelled_TaskIsCancelled() + { + // Arrange + Guid testId = Guid.NewGuid(); + CancellationTokenSource cts = new(); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<RawLayout?> resultTask = _repository.GetAsync(testId, cts.Token); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + cts.Cancel(); + + // Assert + resultTask.Should().BeCanceled(because: "cancellation was requested"); + } + + [TestMethod] + public void GetAsync_WhenScanThrowsException_TaskIsFaulted() + { + // Arrange + Guid testId = Guid.NewGuid(); + AmazonDynamoDBException expectedException = new("DynamoDB scan failed"); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<RawLayout?> resultTask = _repository.GetAsync(testId, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + tcs.SetException(expectedException); + + // Assert + resultTask.Should().BeFaultedWith(expectedException); + } + + // ─── ListByUserAsync ────────────────────────────────────────────────────────── + + [TestMethod] + public void ListByUserAsync_ReturnsOnlyLayoutsForRequestedUser() + { + // Arrange + Guid id1 = Guid.NewGuid(); + Guid id2 = Guid.NewGuid(); + Guid decoyId = Guid.NewGuid(); + + // Response includes decoy items for a different user to prove UserId filtering is working + TaskCompletionSource<QueryResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.QueryAsync(It.IsAny<QueryRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<IReadOnlyList<RawLayout>> resultTask = _repository.ListByUserAsync(TestUserId, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "QueryAsync has not completed yet"); + + tcs.SetResult(new QueryResponse + { + Items = + [ + CreateDynamoDbItem(id1, TestUserId, "Layout 1", 1), + CreateDynamoDbItem(decoyId, OtherUserId, "Decoy Layout", 1), + CreateDynamoDbItem(id2, TestUserId, "Layout 2", 2) + ] + }); + + // Assert + resultTask.Should().BeComplete(); + IReadOnlyList<RawLayout> results = resultTask.Result; + results.Should().HaveCount(2, because: "decoy items for other users should be filtered out"); + results[0].Id.Should().Be(id1); + results[1].Id.Should().Be(id2); + results.Should().OnlyContain(r => r.UserId == TestUserId); + + // Verify correct query parameters were sent to DynamoDB + _mockDynamoDb.Verify(db => db.QueryAsync( + It.Is<QueryRequest>(req => + req.KeyConditionExpression == "UserId = :userId" && + req.ExpressionAttributeValues.ContainsKey(":userId") && + req.ExpressionAttributeValues[":userId"].S == TestUserId), + It.IsAny<CancellationToken>()), + Times.Once); + } + + [TestMethod] + public void ListByUserAsync_WithNoLayouts_ReturnsEmptyList() + { + // Arrange + TaskCompletionSource<QueryResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.QueryAsync(It.IsAny<QueryRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<IReadOnlyList<RawLayout>> resultTask = _repository.ListByUserAsync(TestUserId, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "QueryAsync has not completed yet"); + + tcs.SetResult(new QueryResponse { Items = [] }); + + // Assert + resultTask.Should().BeComplete(); + resultTask.Result.Should().BeEmpty(); + } + + [TestMethod] + public void ListByUserAsync_WhenQueryAsyncIsPending_TaskIsNotComplete() + { + // Arrange + TaskCompletionSource<QueryResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.QueryAsync(It.IsAny<QueryRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<IReadOnlyList<RawLayout>> resultTask = _repository.ListByUserAsync(TestUserId, CancellationToken.None); + + // Assert + resultTask.Should().NotBeComplete(because: "QueryAsync has not completed yet"); + + tcs.SetResult(new QueryResponse { Items = [] }); + + resultTask.Should().BeComplete(because: "QueryAsync has now completed"); + } + + [TestMethod] + public void ListByUserAsync_WhenCancelled_TaskIsCancelled() + { + // Arrange + CancellationTokenSource cts = new(); + TaskCompletionSource<QueryResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.QueryAsync(It.IsAny<QueryRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<IReadOnlyList<RawLayout>> resultTask = _repository.ListByUserAsync(TestUserId, cts.Token); + resultTask.Should().NotBeComplete(because: "QueryAsync has not completed yet"); + + cts.Cancel(); + + // Assert + resultTask.Should().BeCanceled(because: "cancellation was requested"); + } + + [TestMethod] + public void ListByUserAsync_WhenQueryThrowsException_TaskIsFaulted() + { + // Arrange + AmazonDynamoDBException expectedException = new("DynamoDB query failed"); + TaskCompletionSource<QueryResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.QueryAsync(It.IsAny<QueryRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<IReadOnlyList<RawLayout>> resultTask = _repository.ListByUserAsync(TestUserId, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "QueryAsync has not completed yet"); + + tcs.SetException(expectedException); + + // Assert + resultTask.Should().BeFaultedWith(expectedException); + } + + // ─── SaveAsync ──────────────────────────────────────────────────────────────── + + [TestMethod] + public void SaveAsync_CreatesNewLayout() + { + // Arrange + RawLayout layout = CreateTestLayout(); + TaskCompletionSource<PutItemResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.PutItemAsync(It.IsAny<PutItemRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<RawLayout> resultTask = _repository.SaveAsync(layout, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "PutItemAsync has not completed yet"); + + tcs.SetResult(new PutItemResponse()); + + // Assert + resultTask.Should().BeComplete(); + resultTask.Result.Should().Be(layout); + + _mockDynamoDb.Verify(db => db.PutItemAsync( + It.Is<PutItemRequest>(req => + req.TableName == TestTableName && + req.Item.ContainsKey("UserId") && + req.Item.ContainsKey("Id") && + req.Item.ContainsKey("Name") && + req.Item.ContainsKey("Elements")), + It.IsAny<CancellationToken>()), + Times.Once); + } + + [TestMethod] + public void SaveAsync_WithValidationResult_StoresValidationResult() + { + // Arrange + ValidationResult validationResult = new(false, new List<ValidationIssue> + { + new("ERR001", "Test error", "/elements/0") + }); + RawLayout layout = CreateTestLayout() with { ValidationResult = validationResult }; + + TaskCompletionSource<PutItemResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.PutItemAsync(It.IsAny<PutItemRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<RawLayout> resultTask = _repository.SaveAsync(layout, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "PutItemAsync has not completed yet"); + + tcs.SetResult(new PutItemResponse()); + + // Assert + resultTask.Should().BeComplete(); + _mockDynamoDb.Verify(db => db.PutItemAsync( + It.Is<PutItemRequest>(req => req.Item.ContainsKey("ValidationResult")), + It.IsAny<CancellationToken>()), + Times.Once); + } + + [TestMethod] + public void SaveAsync_WhenPutAsyncIsPending_TaskIsNotComplete() + { + // Arrange + RawLayout layout = CreateTestLayout(); + TaskCompletionSource<PutItemResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.PutItemAsync(It.IsAny<PutItemRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<RawLayout> resultTask = _repository.SaveAsync(layout, CancellationToken.None); + + // Assert + resultTask.Should().NotBeComplete(because: "PutItemAsync has not completed yet"); + + tcs.SetResult(new PutItemResponse()); + + resultTask.Should().BeComplete(because: "PutItemAsync has now completed"); + } + + [TestMethod] + public void SaveAsync_WhenCancelled_TaskIsCancelled() + { + // Arrange + RawLayout layout = CreateTestLayout(); + CancellationTokenSource cts = new(); + TaskCompletionSource<PutItemResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.PutItemAsync(It.IsAny<PutItemRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<RawLayout> resultTask = _repository.SaveAsync(layout, cts.Token); + resultTask.Should().NotBeComplete(because: "PutItemAsync has not completed yet"); + + cts.Cancel(); + + // Assert + resultTask.Should().BeCanceled(because: "cancellation was requested"); + } + + [TestMethod] + public void SaveAsync_WhenPutThrowsException_TaskIsFaulted() + { + // Arrange + RawLayout layout = CreateTestLayout(); + AmazonDynamoDBException expectedException = new("DynamoDB put failed"); + TaskCompletionSource<PutItemResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.PutItemAsync(It.IsAny<PutItemRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task<RawLayout> resultTask = _repository.SaveAsync(layout, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "PutItemAsync has not completed yet"); + + tcs.SetException(expectedException); + + // Assert + resultTask.Should().BeFaultedWith(expectedException); + } + + // ─── DeleteAsync ────────────────────────────────────────────────────────────── + + [TestMethod] + public void DeleteAsync_WithExistingLayout_DeletesLayout() + { + // Arrange + Guid testId = Guid.NewGuid(); + Guid decoyId = Guid.NewGuid(); + + // Response includes a decoy item with a different ID to prove the correct item is deleted + TaskCompletionSource<ScanResponse> scanTcs = new(); + TaskCompletionSource<DeleteItemResponse> deleteTcs = new(); + + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(scanTcs.Task) + .Verifiable(Times.Once); + _mockDynamoDb + .Setup(db => db.DeleteItemAsync(It.IsAny<DeleteItemRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(deleteTcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.DeleteAsync(testId, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + scanTcs.SetResult(new ScanResponse + { + Items = + [ + CreateDynamoDbItem(decoyId, OtherUserId, "Decoy Layout", 1), + CreateDynamoDbItem(testId, TestUserId, "Test Layout", 1) + ] + }); + + resultTask.Should().NotBeComplete(because: "DeleteItemAsync has not completed yet"); + + deleteTcs.SetResult(new DeleteItemResponse()); + + // Assert + resultTask.Should().BeComplete(); + + _mockDynamoDb.Verify(db => db.DeleteItemAsync( + It.Is<DeleteItemRequest>(req => + req.TableName == TestTableName && + req.Key.ContainsKey("UserId") && + req.Key["UserId"].S == TestUserId && + req.Key.ContainsKey("Id") && + req.Key["Id"].S == testId.ToString()), + It.IsAny<CancellationToken>()), + Times.Once); + } + + [TestMethod] + public void DeleteAsync_WithNonExistentLayout_DoesNotCallDeleteItem() + { + // Arrange + Guid testId = Guid.NewGuid(); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + // DeleteItemAsync should NOT be called (the default Times.Never setup handles this) + + // Act + Task resultTask = _repository.DeleteAsync(testId, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + tcs.SetResult(new ScanResponse { Items = [] }); + + // Assert + resultTask.Should().BeComplete(); + } + + [TestMethod] + public void DeleteAsync_WhenScanAsyncIsPending_TaskIsNotComplete() + { + // Arrange + Guid testId = Guid.NewGuid(); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.DeleteAsync(testId, CancellationToken.None); + + // Assert + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + tcs.SetResult(new ScanResponse { Items = [] }); + + resultTask.Should().BeComplete(because: "ScanAsync has now completed"); + } + + [TestMethod] + public void DeleteAsync_WhenCancelledDuringScan_TaskIsCancelled() + { + // Arrange + Guid testId = Guid.NewGuid(); + CancellationTokenSource cts = new(); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.DeleteAsync(testId, cts.Token); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + cts.Cancel(); + + // Assert + resultTask.Should().BeCanceled(because: "cancellation was requested during scan"); + } + + [TestMethod] + public void DeleteAsync_WhenScanThrowsException_TaskIsFaulted() + { + // Arrange + Guid testId = Guid.NewGuid(); + AmazonDynamoDBException expectedException = new("DynamoDB scan failed"); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.DeleteAsync(testId, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + tcs.SetException(expectedException); + + // Assert + resultTask.Should().BeFaultedWith(expectedException); + } + + [TestMethod] + public void DeleteAsync_WhenDeleteItemThrowsException_TaskIsFaulted() + { + // Arrange + Guid testId = Guid.NewGuid(); + AmazonDynamoDBException expectedException = new("DynamoDB delete failed"); + TaskCompletionSource<ScanResponse> scanTcs = new(); + TaskCompletionSource<DeleteItemResponse> deleteTcs = new(); + + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(scanTcs.Task) + .Verifiable(Times.Once); + _mockDynamoDb + .Setup(db => db.DeleteItemAsync(It.IsAny<DeleteItemRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(deleteTcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.DeleteAsync(testId, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + scanTcs.SetResult(new ScanResponse { Items = [CreateDynamoDbItem(testId, TestUserId, "Test", 1)] }); + + resultTask.Should().NotBeComplete(because: "DeleteItemAsync has not completed yet"); + + deleteTcs.SetException(expectedException); + + // Assert + resultTask.Should().BeFaultedWith(expectedException); + } + + // ─── UpdateValidationResultAsync ───────────────────────────────────────────── + + [TestMethod] + public void UpdateValidationResultAsync_WithExistingLayout_UpdatesValidationResult() + { + // Arrange + Guid testId = Guid.NewGuid(); + ValidationResult validationResult = new(false, new List<ValidationIssue> + { + new("ERR001", "Validation failed", "/elements/0/label") + }); + + TaskCompletionSource<ScanResponse> scanTcs = new(); + TaskCompletionSource<UpdateItemResponse> updateTcs = new(); + + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(scanTcs.Task) + .Verifiable(Times.Once); + _mockDynamoDb + .Setup(db => db.UpdateItemAsync(It.IsAny<UpdateItemRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(updateTcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.UpdateValidationResultAsync(testId, validationResult, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + scanTcs.SetResult(new ScanResponse { Items = [CreateDynamoDbItem(testId, TestUserId, "Test Layout", 1)] }); + + resultTask.Should().NotBeComplete(because: "UpdateItemAsync has not completed yet"); + + updateTcs.SetResult(new UpdateItemResponse()); + + // Assert + resultTask.Should().BeComplete(); + + _mockDynamoDb.Verify(db => db.UpdateItemAsync( + It.Is<UpdateItemRequest>(req => + req.TableName == TestTableName && + req.UpdateExpression == "SET ValidationResult = :validationResult"), + It.IsAny<CancellationToken>()), + Times.Once); + } + + [TestMethod] + public void UpdateValidationResultAsync_WithNonExistentLayout_ThrowsInvalidOperationException() + { + // Arrange + Guid testId = Guid.NewGuid(); + ValidationResult validationResult = new(false, new List<ValidationIssue>()); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.UpdateValidationResultAsync(testId, validationResult, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + tcs.SetResult(new ScanResponse { Items = [] }); + + // Assert + resultTask.Should().BeFaultedWith( + new InvalidOperationException($"Cannot update validation result: raw layout {testId} not found")); + } + + [TestMethod] + public void UpdateValidationResultAsync_WhenScanAsyncIsPending_TaskIsNotComplete() + { + // Arrange + Guid testId = Guid.NewGuid(); + ValidationResult validationResult = new(false, new List<ValidationIssue>()); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.UpdateValidationResultAsync(testId, validationResult, CancellationToken.None); + + // Assert + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + tcs.SetResult(new ScanResponse { Items = [] }); + + // completes (faulted because layout not found, but still complete) + resultTask.Should().BeFaultedWith( + new InvalidOperationException($"Cannot update validation result: raw layout {testId} not found")); + } + + [TestMethod] + public void UpdateValidationResultAsync_WhenCancelledDuringScan_TaskIsCancelled() + { + // Arrange + Guid testId = Guid.NewGuid(); + ValidationResult validationResult = new(false, new List<ValidationIssue>()); + CancellationTokenSource cts = new(); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.UpdateValidationResultAsync(testId, validationResult, cts.Token); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + cts.Cancel(); + + // Assert + resultTask.Should().BeCanceled(because: "cancellation was requested during scan"); + } + + [TestMethod] + public void UpdateValidationResultAsync_WhenScanThrowsException_TaskIsFaulted() + { + // Arrange + Guid testId = Guid.NewGuid(); + ValidationResult validationResult = new(false, new List<ValidationIssue>()); + AmazonDynamoDBException expectedException = new("DynamoDB scan failed"); + TaskCompletionSource<ScanResponse> tcs = new(); + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(tcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.UpdateValidationResultAsync(testId, validationResult, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + tcs.SetException(expectedException); + + // Assert + resultTask.Should().BeFaultedWith(expectedException); + } + + [TestMethod] + public void UpdateValidationResultAsync_WhenUpdateItemThrowsException_TaskIsFaulted() + { + // Arrange + Guid testId = Guid.NewGuid(); + ValidationResult validationResult = new(false, new List<ValidationIssue>()); + AmazonDynamoDBException expectedException = new("DynamoDB update failed"); + TaskCompletionSource<ScanResponse> scanTcs = new(); + TaskCompletionSource<UpdateItemResponse> updateTcs = new(); + + _mockDynamoDb + .Setup(db => db.ScanAsync(It.IsAny<ScanRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(scanTcs.Task) + .Verifiable(Times.Once); + _mockDynamoDb + .Setup(db => db.UpdateItemAsync(It.IsAny<UpdateItemRequest>(), It.IsAny<CancellationToken>())) + .WithStandardTaskBehavior(updateTcs.Task) + .Verifiable(Times.Once); + + // Act + Task resultTask = _repository.UpdateValidationResultAsync(testId, validationResult, CancellationToken.None); + resultTask.Should().NotBeComplete(because: "ScanAsync has not completed yet"); + + scanTcs.SetResult(new ScanResponse { Items = [CreateDynamoDbItem(testId, TestUserId, "Test", 1)] }); + + resultTask.Should().NotBeComplete(because: "UpdateItemAsync has not completed yet"); + + updateTcs.SetException(expectedException); + + // Assert + resultTask.Should().BeFaultedWith(expectedException); + } + + // ─── Helpers ────────────────────────────────────────────────────────────────── + + private static Dictionary<string, AttributeValue> CreateDynamoDbItem(Guid id, string userId, string name, int version) + { + return new Dictionary<string, AttributeValue> + { + { "Id", new AttributeValue { S = id.ToString() } }, + { "UserId", new AttributeValue { S = userId } }, + { "Name", new AttributeValue { S = name } }, + { "Elements", new AttributeValue { S = "[]" } }, + { "Version", new AttributeValue { N = version.ToString() } }, + { "CreatedAt", new AttributeValue { S = DateTimeOffset.UtcNow.ToString("O") } }, + { "UpdatedAt", new AttributeValue { S = DateTimeOffset.UtcNow.ToString("O") } } + }; + } + + private static RawLayout CreateTestLayout() + { + return new RawLayout( + Id: Guid.NewGuid(), + UserId: TestUserId, + Name: "Test Layout", + Elements: Array.Empty<RawLayoutElementDto>(), + Version: 1, + CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow, + ValidationResult: null + ); + } +} diff --git a/test/AdaptiveRemote.TestUtilities/AdaptiveRemote.TestUtilities.csproj b/test/AdaptiveRemote.TestUtilities/AdaptiveRemote.TestUtilities.csproj new file mode 100644 index 00000000..2cff18de --- /dev/null +++ b/test/AdaptiveRemote.TestUtilities/AdaptiveRemote.TestUtilities.csproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>AdaptiveRemote.TestUtilities</RootNamespace> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="FluentAssertions" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest.TestAdapter" /> + <PackageReference Include="MSTest.TestFramework" /> + </ItemGroup> + + <ItemGroup> + <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" /> + </ItemGroup> + +</Project> diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/MemoryAssert.cs b/test/AdaptiveRemote.TestUtilities/MemoryAssert.cs similarity index 84% rename from test/AdaptiveRemote.App.Tests/TestUtilities/MemoryAssert.cs rename to test/AdaptiveRemote.TestUtilities/MemoryAssert.cs index d12ac964..29119d59 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/MemoryAssert.cs +++ b/test/AdaptiveRemote.TestUtilities/MemoryAssert.cs @@ -1,8 +1,8 @@ namespace AdaptiveRemote.TestUtilities; -internal static class MemoryAssert +public static class MemoryAssert { - internal static void AreEqual(ReadOnlyMemory<byte> expected, ReadOnlyMemory<byte> actual, string name) + public static void AreEqual(ReadOnlyMemory<byte> expected, ReadOnlyMemory<byte> actual, string name) { ReadOnlySpan<byte>.Enumerator expectedIter = expected.Span.GetEnumerator(); ReadOnlySpan<byte>.Enumerator actualIter = actual.Span.GetEnumerator(); @@ -28,10 +28,10 @@ internal static void AreEqual(ReadOnlyMemory<byte> expected, ReadOnlyMemory<byte } } - internal static void WriteTo(TestContext? testContext, ReadOnlyMemory<byte> expected, ReadOnlyMemory<byte> actual) + public static void WriteTo(TestContext? testContext, ReadOnlyMemory<byte> expected, ReadOnlyMemory<byte> actual) => WriteTo(testContext, string.Empty, expected, actual); - internal static void WriteTo(TestContext? testContext, string description, ReadOnlyMemory<byte> expected, ReadOnlyMemory<byte> actual) + public static void WriteTo(TestContext? testContext, string description, ReadOnlyMemory<byte> expected, ReadOnlyMemory<byte> actual) { if (testContext is null) { diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/MockEndPoint.cs b/test/AdaptiveRemote.TestUtilities/MockEndPoint.cs similarity index 63% rename from test/AdaptiveRemote.App.Tests/TestUtilities/MockEndPoint.cs rename to test/AdaptiveRemote.TestUtilities/MockEndPoint.cs index 50993b5e..b0297dcd 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/MockEndPoint.cs +++ b/test/AdaptiveRemote.TestUtilities/MockEndPoint.cs @@ -2,6 +2,6 @@ namespace AdaptiveRemote.TestUtilities; -internal class MockEndPoint : EndPoint +public class MockEndPoint : EndPoint { } diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/MockExtensions.cs b/test/AdaptiveRemote.TestUtilities/MockExtensions.cs similarity index 85% rename from test/AdaptiveRemote.App.Tests/TestUtilities/MockExtensions.cs rename to test/AdaptiveRemote.TestUtilities/MockExtensions.cs index b1b057c2..8e20cde7 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/MockExtensions.cs +++ b/test/AdaptiveRemote.TestUtilities/MockExtensions.cs @@ -5,9 +5,9 @@ namespace AdaptiveRemote.TestUtilities; -internal static class MockExtensions +public static class MockExtensions { - internal static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractType>( + public static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractType>( this IReturnsThrows<ContractType, Task> setup, Task? returnTask = default) where ContractType : class @@ -38,25 +38,25 @@ internal static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractTy }); } - internal static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractType, ReturnType>( + public static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractType, ReturnType>( this IReturnsThrows<ContractType, Task<ReturnType>> setup, ReturnType returnValue) where ContractType : class => setup.WithStandardTaskBehavior(Task.FromResult(returnValue)); - internal static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractType, ReturnType>( + public static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractType, ReturnType>( this IReturnsThrows<ContractType, ValueTask<ReturnType>> setup, ReturnType returnValue) where ContractType : class => setup.WithStandardTaskBehavior(Task.FromResult(returnValue)); - internal static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractType, ReturnType>( + public static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractType, ReturnType>( this IReturnsThrows<ContractType, ValueTask<ReturnType>> setup, Task<ReturnType> returnValue) where ContractType : class => setup.Returns(CreateStandardReturnValue(returnValue).AsValueTask()); - internal static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractType, ReturnType>( + public static IReturnsResult<ContractType> WithStandardTaskBehavior<ContractType, ReturnType>( this IReturnsThrows<ContractType, Task<ReturnType>> setup, Task<ReturnType> returnTask) where ContractType : class @@ -82,13 +82,13 @@ private static Func<IInvocation, Task<ReturnType>> CreateStandardReturnValue<Ret }; } - internal static ICallbackResult WithArgumentValidation<ArgumentType>( + public static ICallbackResult WithArgumentValidation<ArgumentType>( this ICallback setup, string argumentName, Action<ArgumentType> validator) => setup.Callback(CreateValidatorCallback(argumentName, validator)); - internal static ICallbackResult WithArgumentValidation<ArgumentType>( + public static ICallbackResult WithArgumentValidation<ArgumentType>( this ICallback setup, string argumentName, ArgumentType expectedValue) @@ -97,7 +97,7 @@ internal static ICallbackResult WithArgumentValidation<ArgumentType>( Assert.AreEqual(expectedValue, argumentValue, "Argument '{0}' in {1}", argumentName, setup); }); - internal static IReturnsThrows<ContractType, ReturnType> WithArgumentValidation<ContractType, ReturnType, ArgumentType>( + public static IReturnsThrows<ContractType, ReturnType> WithArgumentValidation<ContractType, ReturnType, ArgumentType>( this ICallback<ContractType, ReturnType> setup, string argumentName, ArgumentType expectedValue) @@ -107,7 +107,7 @@ internal static IReturnsThrows<ContractType, ReturnType> WithArgumentValidation< Assert.AreEqual(expectedValue, argumentValue, "Argument '{0}' in {1}", argumentName, setup); }); - internal static IReturnsThrows<ContractType, ReturnType> WithArgumentValidation<ContractType, ReturnType, ArgumentType>( + public static IReturnsThrows<ContractType, ReturnType> WithArgumentValidation<ContractType, ReturnType, ArgumentType>( this ICallback<ContractType, ReturnType> setup, string argumentName, Action<ArgumentType> validator) @@ -138,7 +138,7 @@ private static Func<IInvocation, ValueTask<ReturnType>> AsValueTask<ReturnType>( return invocation => new(taskFunc(invocation)); } - internal static CancellationToken WithExpectedCancellation<ContractType>( + public static CancellationToken WithExpectedCancellation<ContractType>( this ISetup<ContractType, Task> setup, bool throwWhenCancelled) where ContractType : class @@ -155,7 +155,7 @@ internal static CancellationToken WithExpectedCancellation<ContractType>( return result; } - internal static CancellationToken WithExpectedCancellation<ContractType, ResultType>( + public static CancellationToken WithExpectedCancellation<ContractType, ResultType>( this ISetup<ContractType, Task<ResultType>> setup, bool throwWhenCancelled) where ContractType : class diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/StringExtensions.cs b/test/AdaptiveRemote.TestUtilities/StringExtensions.cs similarity index 83% rename from test/AdaptiveRemote.App.Tests/TestUtilities/StringExtensions.cs rename to test/AdaptiveRemote.TestUtilities/StringExtensions.cs index 991ac9ca..46a28916 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/StringExtensions.cs +++ b/test/AdaptiveRemote.TestUtilities/StringExtensions.cs @@ -3,11 +3,11 @@ namespace AdaptiveRemote.TestUtilities; -internal static class StringExtensions +public static class StringExtensions { private static readonly Regex PlaceHolderRegex = new("{\\w+}"); - internal static FormattableString AsMessageTemplate(this string format, params object?[] args) + public static FormattableString AsMessageTemplate(this string format, params object?[] args) { Dictionary<string, string> placeholders = new(); format = PlaceHolderRegex.Replace(format, match => diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/TaskAssert.cs b/test/AdaptiveRemote.TestUtilities/TaskAssert.cs similarity index 99% rename from test/AdaptiveRemote.App.Tests/TestUtilities/TaskAssert.cs rename to test/AdaptiveRemote.TestUtilities/TaskAssert.cs index 691a8473..013121f5 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/TaskAssert.cs +++ b/test/AdaptiveRemote.TestUtilities/TaskAssert.cs @@ -4,7 +4,7 @@ namespace AdaptiveRemote.TestUtilities; -internal static class TaskAssert +public static class TaskAssert { public static TaskAssertions Should(this Task? task) => new(task); From 799c1550d84a39edce8a7b422bb61373b732e25d Mon Sep 17 00:00:00 2001 From: Joe Davis <jodasoft@outlook.com> Date: Tue, 28 Apr 2026 20:52:28 -0700 Subject: [PATCH 09/11] Update API Integration Tests project reference --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 9e929d71..49a0d00c 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -90,7 +90,7 @@ jobs: run: docker pull localstack/localstack:3.0 - name: API Integration Tests - run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" ./scripts/validate-unit-tests.proj --filter "TestCategory=ApiIntegrationTest" -m:1 + run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" backend.slnf --filter "TestCategory=ApiIntegrationTest" -m:1 - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action@v2 From bbf738ab91ecfc93de2d3756b98cdd46406d41fe Mon Sep 17 00:00:00 2001 From: Joe Davis <jodasoft@outlook.com> Date: Mon, 11 May 2026 19:43:22 -0700 Subject: [PATCH 10/11] [ADR-170] Implement LayoutProcessingService with SQS polling and orchestration pipeline (#160) --- .claude/commands/team-task.md | 6 +- .claude/settings.local.json | 7 +- .github/workflows/build-and-test.yml | 2 +- AdaptiveRemote.sln | 45 ++ CLAUDE.md | 4 + Directory.Build.props | 1 + Directory.Packages.props | 8 +- backend.slnf | 2 + docker-compose.yml | 35 +- localstack-init/01-create-tables.sh | 28 + scripts/validate-unit-tests.proj | 1 + .../Services/Broadlink/UdpService.cs | 22 + .../AdaptiveRemote.Backend.Common.csproj | 13 + .../Logging/FileLoggerExtensions.cs | 56 ++ .../Logging/MessageLogger.cs | 131 ++++ .../Logging/RequestHandlerScope.cs | 43 ++ ...emote.Backend.CompiledLayoutService.csproj | 1 + .../Endpoints/HealthEndpoints.cs | 5 +- .../Endpoints/LayoutEndpoints.cs | 26 +- .../Logging/MessageLogger.cs | 42 -- .../Program.cs | 29 +- .../Services/HardcodedLayoutProvider.cs | 25 + ...ote.Backend.LayoutProcessingService.csproj | 23 + .../Configuration/CognitoSettings.cs | 19 + .../CompiledLayoutServiceSettings.cs | 13 + .../Configuration/RawLayoutServiceSettings.cs | 21 + .../Configuration/SqsSettings.cs | 44 ++ .../Dockerfile | 24 + .../Endpoints/HealthEndpoints.cs | 44 ++ .../Program.cs | 306 ++++++++ .../Properties/launchSettings.json | 24 + .../Services/HttpCompiledLayoutRepository.cs | 100 +++ .../Services/HttpRawLayoutRepository.cs | 78 +++ .../Services/HttpRawLayoutStatusWriter.cs | 30 + .../Services/LayoutProcessingOrchestrator.cs | 231 +++++++ .../Services/ServiceAccountTokenHandler.cs | 29 + .../Services/StubLayoutCompilerClient.cs | 77 +++ .../Services/StubLayoutValidationClient.cs | 43 ++ .../Services/StubNotificationPublisher.cs | 22 + .../appsettings.Development.json | 22 + .../appsettings.json | 28 + ...tiveRemote.Backend.RawLayoutService.csproj | 2 + .../Configuration/SqsSettings.cs | 24 + .../Endpoints/HealthEndpoints.cs | 4 +- .../Endpoints/LayoutEndpoints.cs | 51 +- .../Logging/MessageLogger.cs | 72 -- .../Program.cs | 104 ++- .../Services/SqsLayoutProcessingTrigger.cs | 51 ++ .../appsettings.Development.json | 5 + .../appsettings.json | 5 + .../ICompiledLayoutRepository.cs | 21 + .../ILayoutCompilerClient.cs | 11 + .../ILayoutValidationClient.cs | 10 + src/_doc_BackendDevelopment.md | 37 + src/_doc_Projects.md | 10 + .../Services/Broadlink/UdpServiceTests.cs | 68 +- .../TestUtilities/MockLogger.cs | 165 +---- .../AdaptiveRemote.Backend.ApiTests.csproj | 8 +- .../Features/AuthenticationEndpoints.feature | 20 +- .../AuthenticationEndpoints.feature.cs | 63 +- .../Features/CompiledLayoutEndpoints.feature | 31 +- .../CompiledLayoutEndpoints.feature.cs | 124 +++- .../Features/HealthEndpoints.feature | 17 +- .../Features/HealthEndpoints.feature.cs | 72 +- .../LayoutProcessingServiceEndpoints.feature | 82 +++ ...ayoutProcessingServiceEndpoints.feature.cs | 334 +++++++++ .../Features/RawLayoutEndpoints.feature | 264 ++++++- .../Features/RawLayoutEndpoints.feature.cs | 653 +++++++++++++++--- .../Hooks/ApiTestHooks.cs | 18 + .../StepDefinitions/AuthenticationSteps.cs | 49 -- .../StepDefinitions/CommonSteps.cs | 151 ---- .../StepDefinitions/RawLayoutSteps.cs | 303 -------- .../Support/LocalStackFixture.cs | 222 ------ .../Support/ServiceContext.cs | 27 - .../Support/ServiceFixture.cs | 318 --------- .../reqnroll.json | 9 + ...ckend.LayoutProcessingService.Tests.csproj | 33 + .../LayoutProcessingOrchestratorTests.cs | 596 ++++++++++++++++ .../{ => Application}/AccessibilitySteps.cs | 2 +- .../AdptiveRemoteHostSteps.cs | 2 +- .../{ => Application}/DebugSteps.cs | 2 +- .../ISpeechTestServiceExtensions.cs | 3 +- .../SimulatedBroadlinkSteps.cs | 3 +- .../{ => Application}/SimulatedTiVoSteps.cs | 4 +- .../{ => Application}/SpeechSteps.cs | 2 +- .../{ => Application}/UISteps.cs | 2 +- .../Backend/AuthenticationSteps.cs | 30 + .../Backend/CompiledLayoutSteps.cs | 53 ++ .../Backend/HealthResponseSteps.cs | 12 + .../Backend/HttpRequestSteps.cs | 93 +++ .../Backend/HttpResponseSteps.cs | 95 +++ .../Backend/RawLayoutSteps.cs | 22 + .../Backend/ServiceSteps.cs | 31 + .../Hooks/EnvironmentSetupHooks.cs | 34 +- .../LogVerificationSteps.cs | 251 ++++++- .../StepsBase.cs | 4 + ...veRemote.EndtoEndTests.TestServices.csproj | 6 +- .../Backend/LocalStackFixture.cs | 365 ++++++++++ .../Backend/ServiceFixture.cs | 196 ++++++ .../Backend}/TestJwtAuthority.cs | 4 +- .../BlazorWebViewUITestService.cs | 1 + .../Host/AdaptiveRemoteHost.Builder.cs | 1 + .../Host/AdaptiveRemoteHost.cs | 1 + .../Host/ISimulatedEnvironment.cs | 22 + .../Host/SimulatedEnvironment.cs | 125 +++- .../IApplicationTestServiceExtensions.cs | 1 + .../ITestEndpointExtensions.cs | 1 + .../IUITestServiceExtensions.cs | 1 + .../Logging/HostApplicationLoggerProvider.cs | 1 + .../Logging/TestResultFileHelper.cs | 29 + .../ISimulatedBroadlinkDeviceExtensions.cs | 2 + .../SimulatedBroadlinkDevice.cs | 1 + .../SimulatedTiVo/SimulatedTiVoDevice.cs | 1 + .../TestClient.cs | 101 +++ .../AdaptiveRemote.TestUtilities.csproj | 2 +- .../HttpClientExtensions.cs | 10 + .../MockLogger.cs | 145 ++++ .../WaitHelpers.cs | 2 +- 118 files changed, 5807 insertions(+), 1600 deletions(-) create mode 100644 src/AdaptiveRemote.Backend.Common/AdaptiveRemote.Backend.Common.csproj create mode 100644 src/AdaptiveRemote.Backend.Common/Logging/FileLoggerExtensions.cs create mode 100644 src/AdaptiveRemote.Backend.Common/Logging/MessageLogger.cs create mode 100644 src/AdaptiveRemote.Backend.Common/Logging/RequestHandlerScope.cs delete mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/AdaptiveRemote.Backend.LayoutProcessingService.csproj create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/CognitoSettings.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/CompiledLayoutServiceSettings.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/RawLayoutServiceSettings.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/SqsSettings.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Dockerfile create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Endpoints/HealthEndpoints.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Properties/launchSettings.json create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpCompiledLayoutRepository.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpRawLayoutRepository.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpRawLayoutStatusWriter.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/LayoutProcessingOrchestrator.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/ServiceAccountTokenHandler.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubNotificationPublisher.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.Development.json create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.json create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Configuration/SqsSettings.cs delete mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Logging/MessageLogger.cs create mode 100644 src/AdaptiveRemote.Backend.RawLayoutService/Services/SqsLayoutProcessingTrigger.cs create mode 100644 src/AdaptiveRemote.Contracts/ILayoutCompilerClient.cs create mode 100644 src/AdaptiveRemote.Contracts/ILayoutValidationClient.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/LayoutProcessingServiceEndpoints.feature create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/LayoutProcessingServiceEndpoints.feature.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Hooks/ApiTestHooks.cs delete mode 100644 test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs delete mode 100644 test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs delete mode 100644 test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/RawLayoutSteps.cs delete mode 100644 test/AdaptiveRemote.Backend.ApiTests/Support/LocalStackFixture.cs delete mode 100644 test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs delete mode 100644 test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/reqnroll.json create mode 100644 test/AdaptiveRemote.Backend.LayoutProcessingService.Tests/AdaptiveRemote.Backend.LayoutProcessingService.Tests.csproj create mode 100644 test/AdaptiveRemote.Backend.LayoutProcessingService.Tests/Services/LayoutProcessingOrchestratorTests.cs rename test/AdaptiveRemote.EndToEndTests.Steps/{ => Application}/AccessibilitySteps.cs (97%) rename test/AdaptiveRemote.EndToEndTests.Steps/{ => Application}/AdptiveRemoteHostSteps.cs (96%) rename test/AdaptiveRemote.EndToEndTests.Steps/{ => Application}/DebugSteps.cs (91%) rename test/AdaptiveRemote.EndToEndTests.Steps/{ => Application}/ISpeechTestServiceExtensions.cs (98%) rename test/AdaptiveRemote.EndToEndTests.Steps/{ => Application}/SimulatedBroadlinkSteps.cs (98%) rename test/AdaptiveRemote.EndToEndTests.Steps/{ => Application}/SimulatedTiVoSteps.cs (94%) rename test/AdaptiveRemote.EndToEndTests.Steps/{ => Application}/SpeechSteps.cs (94%) rename test/AdaptiveRemote.EndToEndTests.Steps/{ => Application}/UISteps.cs (97%) create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/Backend/AuthenticationSteps.cs create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/Backend/CompiledLayoutSteps.cs create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/Backend/HealthResponseSteps.cs create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/Backend/HttpRequestSteps.cs create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/Backend/HttpResponseSteps.cs create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/Backend/RawLayoutSteps.cs create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/Backend/ServiceSteps.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/LocalStackFixture.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/ServiceFixture.cs rename test/{AdaptiveRemote.Backend.ApiTests/Support => AdaptiveRemote.EndtoEndTests.TestServices/Backend}/TestJwtAuthority.cs (98%) create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestResultFileHelper.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/TestClient.cs create mode 100644 test/AdaptiveRemote.TestUtilities/HttpClientExtensions.cs create mode 100644 test/AdaptiveRemote.TestUtilities/MockLogger.cs rename test/{AdaptiveRemote.EndtoEndTests.TestServices => AdaptiveRemote.TestUtilities}/WaitHelpers.cs (98%) diff --git a/.claude/commands/team-task.md b/.claude/commands/team-task.md index 198d6041..65bbfb68 100644 --- a/.claude/commands/team-task.md +++ b/.claude/commands/team-task.md @@ -114,7 +114,7 @@ Researcher instructions: ### Phase 2 — Developer, first pass (Sonnet) -Spawn a Developer agent with `model: sonnet`. Pass it: +Spawn a Developer agent with `model: claude-sonnet-4-6`. Pass it: - The task brief (full text from Researcher) - Task key, base branch @@ -311,7 +311,7 @@ git rev-parse HEAD Store this as `REVIEWER_BASELINE`. -Spawn a Reviewer agent with `model: sonnet`. Pass it the PR URL, branch, and base branch. +Spawn a Reviewer agent with `model: claude-sonnet-4-6`. Pass it the PR URL, branch, and base branch. Reviewer instructions: @@ -364,7 +364,7 @@ Reviewer instructions: ### Phase 6 — Developer, review pass (Sonnet) -Spawn a Developer agent with `model: sonnet`. Pass it the PR URL, branch, and task brief. +Spawn a Developer agent with `model: claude-sonnet-4-6`. Pass it the PR URL, branch, and task brief. Developer instructions: diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f5a19364..bca7a44b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,12 @@ "Bash(xargs:*)", "mcp__jira__createJiraIssue", "mcp__jira__editJiraIssue", - "mcp__jira__getJiraProjectIssueTypesMetadata" + "mcp__jira__getJiraProjectIssueTypesMetadata", + "Bash(dotnet build *)", + "Bash(dotnet test *)", + "Bash(curl -s http://localhost:4566/_localstack/health)", + "Bash(git add *)", + "Bash(gh pr *)" ] } } diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 49a0d00c..07b50a50 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -90,7 +90,7 @@ jobs: run: docker pull localstack/localstack:3.0 - name: API Integration Tests - run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" backend.slnf --filter "TestCategory=ApiIntegrationTest" -m:1 + run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" ./test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj --filter "TestCategory=ApiIntegrationTest" -m:1 - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action@v2 diff --git a/AdaptiveRemote.sln b/AdaptiveRemote.sln index 28846c9a..452270af 100644 --- a/AdaptiveRemote.sln +++ b/AdaptiveRemote.sln @@ -62,6 +62,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.RawL EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.TestUtilities", "test\AdaptiveRemote.TestUtilities\AdaptiveRemote.TestUtilities.csproj", "{352E5981-CC33-4474-8203-9CE241F42281}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.LayoutProcessingService", "src\AdaptiveRemote.Backend.LayoutProcessingService\AdaptiveRemote.Backend.LayoutProcessingService.csproj", "{F341B9BA-8517-447F-93B3-7E09AAD0F0E1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.LayoutProcessingService.Tests", "test\AdaptiveRemote.Backend.LayoutProcessingService.Tests\AdaptiveRemote.Backend.LayoutProcessingService.Tests.csproj", "{A829A88B-C42D-4D3B-8CDE-621862E4B39C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.Common", "src\AdaptiveRemote.Backend.Common\AdaptiveRemote.Backend.Common.csproj", "{1F36A31B-299C-480C-B974-F4CE67C6F034}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -276,6 +282,42 @@ Global {352E5981-CC33-4474-8203-9CE241F42281}.Release|x64.Build.0 = Release|Any CPU {352E5981-CC33-4474-8203-9CE241F42281}.Release|x86.ActiveCfg = Release|Any CPU {352E5981-CC33-4474-8203-9CE241F42281}.Release|x86.Build.0 = Release|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Debug|x64.ActiveCfg = Debug|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Debug|x64.Build.0 = Debug|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Debug|x86.Build.0 = Debug|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Release|Any CPU.Build.0 = Release|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Release|x64.ActiveCfg = Release|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Release|x64.Build.0 = Release|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Release|x86.ActiveCfg = Release|Any CPU + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1}.Release|x86.Build.0 = Release|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Debug|x64.ActiveCfg = Debug|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Debug|x64.Build.0 = Debug|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Debug|x86.ActiveCfg = Debug|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Debug|x86.Build.0 = Debug|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Release|Any CPU.Build.0 = Release|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Release|x64.ActiveCfg = Release|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Release|x64.Build.0 = Release|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Release|x86.ActiveCfg = Release|Any CPU + {A829A88B-C42D-4D3B-8CDE-621862E4B39C}.Release|x86.Build.0 = Release|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Debug|x64.ActiveCfg = Debug|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Debug|x64.Build.0 = Debug|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Debug|x86.ActiveCfg = Debug|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Debug|x86.Build.0 = Debug|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Release|Any CPU.Build.0 = Release|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Release|x64.ActiveCfg = Release|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Release|x64.Build.0 = Release|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Release|x86.ActiveCfg = Release|Any CPU + {1F36A31B-299C-480C-B974-F4CE67C6F034}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -295,6 +337,9 @@ Global {B1B7B3E8-A43B-4BB6-828D-226E06AA0EAD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {E13EF56D-99C3-40A7-A55E-C25C4906CD44} = {0C88DD14-F956-CE84-757C-A364CCF449FC} {352E5981-CC33-4474-8203-9CE241F42281} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {F341B9BA-8517-447F-93B3-7E09AAD0F0E1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A829A88B-C42D-4D3B-8CDE-621862E4B39C} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {1F36A31B-299C-480C-B974-F4CE67C6F034} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {556A11E4-2F89-4600-9831-8162F067EC3E} diff --git a/CLAUDE.md b/CLAUDE.md index af48d4d6..5889e767 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,10 @@ Event IDs are organized in ranges by subsystem: | 800–899 | TiVoConnection | | 900–999 | UdpService | | 1000–1099 | BroadlinkCommandService | +| 1100–1199 | CompiledLayoutService (backend) | +| 1200–1299 | RawLayoutService (backend) | +| 1300–1699 | (reserved — App subsystems: ProgrammaticSettings, ScopedBackgroundProcess, ConversationState, SamplesRecorder, TestEndpointService, CognitoTokenService) | +| 1700–1799 | LayoutProcessingService (backend) | Assign new log messages the next unused ID in the appropriate range. When replacing an existing message, use exact text including whitespace, newlines, and punctuation. diff --git a/Directory.Build.props b/Directory.Build.props index 6c1c1c22..c753d1cb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,6 +14,7 @@ <ItemGroup> <!-- Enforce code analysis with latest analyzers --> <PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" PrivateAssets="all" /> + <PackageReference Include="Nerdbank.MessagePack" /> </ItemGroup> <PropertyGroup> diff --git a/Directory.Packages.props b/Directory.Packages.props index 18035c3c..ae5ea6fc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ <ItemGroup Label="Application Packages"> <!-- AWS SDK --> <PackageVersion Include="AWSSDK.DynamoDBv2" Version="3.7.406.21" /> + <PackageVersion Include="AWSSDK.SQS" Version="3.7.400.109" /> <!-- Azure and Cloud Services --> <PackageVersion Include="Azure.Identity" Version="1.17.1" /> <PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.6.0" /> @@ -15,11 +16,13 @@ <PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Wpf" Version="8.0.100" /> <!-- Microsoft Extensions --> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" /> + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Ini" Version="10.0.0" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.3" /> <!-- OpenTelemetry --> - <PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.8.1" /> - <PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.8.1" /> + <PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.15.3" /> + <PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" /> <PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" /> <!-- Third-party Libraries --> <PackageVersion Include="I8Beef.TiVo" Version="1.0.0.14" /> @@ -30,6 +33,7 @@ <PackageVersion Include="System.Text.Json" Version="8.0.5" /> <!-- Development Tools --> <PackageVersion Include="Nerdbank.GitVersioning" Version="3.9.50" /> + <PackageVersion Include="Nerdbank.MessagePack" Version="1.1.62" /> <!-- Code Analysis --> <PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.103" /> <PackageVersion Include="Microsoft.VisualStudio.Threading" Version="17.14.15" /> diff --git a/backend.slnf b/backend.slnf index 7fecc9f9..57da75f3 100644 --- a/backend.slnf +++ b/backend.slnf @@ -5,8 +5,10 @@ "src\\AdaptiveRemote.Contracts\\AdaptiveRemote.Contracts.csproj", "src\\AdaptiveRemote.Backend.CompiledLayoutService\\AdaptiveRemote.Backend.CompiledLayoutService.csproj", "src\\AdaptiveRemote.Backend.RawLayoutService\\AdaptiveRemote.Backend.RawLayoutService.csproj", + "src\\AdaptiveRemote.Backend.LayoutProcessingService\\AdaptiveRemote.Backend.LayoutProcessingService.csproj", "test\\AdaptiveRemote.Backend.ApiTests\\AdaptiveRemote.Backend.ApiTests.csproj", "test\\AdaptiveRemote.Backend.RawLayoutService.Tests\\AdaptiveRemote.Backend.RawLayoutService.Tests.csproj", + "test\\AdaptiveRemote.Backend.LayoutProcessingService.Tests\\AdaptiveRemote.Backend.LayoutProcessingService.Tests.csproj", "test\\AdaptiveRemote.TestUtilities\\AdaptiveRemote.TestUtilities.csproj" ] } diff --git a/docker-compose.yml b/docker-compose.yml index a368782c..257ee7a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: localstack: - image: localstack/localstack:latest + image: localstack/localstack:1.4.0 ports: - "4566:4566" environment: @@ -50,6 +50,10 @@ services: - DynamoDB__ServiceUrl=http://localstack:4566 - DynamoDB__TableName=RawLayouts - DynamoDB__Region=us-east-1 + # SQS settings for LocalStack + - Sqs__ServiceUrl=http://localstack:4566 + - Sqs__QueueUrl=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/LayoutProcessingQueue + - Sqs__Region=us-east-1 - AWS_ACCESS_KEY_ID=test - AWS_SECRET_ACCESS_KEY=test - LocalStack__BaseUrl=http://localstack:4566 @@ -58,6 +62,35 @@ services: networks: - backend + layoutprocessingservice: + build: + context: . + dockerfile: src/AdaptiveRemote.Backend.LayoutProcessingService/Dockerfile + ports: + - "8082:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + # Set Cognito dev user pool values here or via a .env file. + - Cognito__Authority=${COGNITO_AUTHORITY:-} + - Cognito__Audience=${COGNITO_AUDIENCE:-} + # SQS settings for LocalStack + - Sqs__ServiceUrl=http://localstack:4566 + - Sqs__QueueUrl=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/LayoutProcessingQueue + - Sqs__Region=us-east-1 + # Upstream service URLs (Docker Compose DNS) + - RawLayoutService__BaseUrl=http://rawlayoutservice:8080 + - CompiledLayoutService__BaseUrl=http://compiledlayoutservice:8080 + - AWS_ACCESS_KEY_ID=test + - AWS_SECRET_ACCESS_KEY=test + - LocalStack__BaseUrl=http://localstack:4566 + depends_on: + - localstack + - rawlayoutservice + - compiledlayoutservice + networks: + - backend + networks: backend: driver: bridge diff --git a/localstack-init/01-create-tables.sh b/localstack-init/01-create-tables.sh index de32f24a..2e9ad8a0 100755 --- a/localstack-init/01-create-tables.sh +++ b/localstack-init/01-create-tables.sh @@ -13,3 +13,31 @@ awslocal dynamodb create-table \ --region us-east-1 echo "DynamoDB table 'RawLayouts' created successfully" + +# Create the Dead Letter Queue for layout processing (must be created before the main queue) +awslocal sqs create-queue \ + --queue-name LayoutProcessingDLQ \ + --attributes MessageRetentionPeriod=1209600 \ + --region us-east-1 + +echo "SQS DLQ 'LayoutProcessingDLQ' created successfully" + +# Get the DLQ ARN +DLQ_ARN=$(awslocal sqs get-queue-attributes \ + --queue-url http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/LayoutProcessingDLQ \ + --attribute-names QueueArn \ + --query 'Attributes.QueueArn' \ + --output text \ + --region us-east-1) + +echo "DLQ ARN: $DLQ_ARN" + +# Create the main layout processing queue with redrive policy (max receive count = 3) +awslocal sqs create-queue \ + --queue-name LayoutProcessingQueue \ + --attributes \ + VisibilityTimeout=60 \ + "RedrivePolicy={\"deadLetterTargetArn\":\"$DLQ_ARN\",\"maxReceiveCount\":\"3\"}" \ + --region us-east-1 + +echo "SQS queue 'LayoutProcessingQueue' created successfully" diff --git a/scripts/validate-unit-tests.proj b/scripts/validate-unit-tests.proj index 6274f3aa..56fa7985 100644 --- a/scripts/validate-unit-tests.proj +++ b/scripts/validate-unit-tests.proj @@ -1,6 +1,7 @@ <Project Sdk="Microsoft.Build.Traversal"> <ItemGroup> <ProjectReference Include="../test/**/*.csproj" /> + <ProjectReference Remove="../test/*TestUtilities*/*.csproj" /> <ProjectReference Remove="../test/*EndToEndTests*/*.csproj" /> <ProjectReference Remove="../test/*EndtoEndTests*/*.csproj" /> </ItemGroup> diff --git a/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs b/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs index 7e4224d1..d34ca12b 100644 --- a/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs +++ b/src/AdaptiveRemote.App/Services/Broadlink/UdpService.cs @@ -106,6 +106,18 @@ IAsyncEnumerable<ScanResponsePacket> IUdpService.BroadcastAsync(ScanRequestPacke _logger.UdpService_Cancelled(packet); responseChannel.Writer.TryComplete(error); } + catch (UdpException error) when (cancellationToken.IsCancellationRequested) + { + OperationCanceledException cancelled = new("UDP broadcast was cancelled.", error, cancellationToken); + _logger.UdpService_Cancelled(packet); + responseChannel.Writer.TryComplete(cancelled); + } + catch (Exception error) when (cancellationToken.IsCancellationRequested) + { + OperationCanceledException cancelled = new("UDP broadcast was cancelled.", error, cancellationToken); + _logger.UdpService_Cancelled(packet); + responseChannel.Writer.TryComplete(cancelled); + } catch (UdpException error) { _logger.UdpService_Failed(packet, error); @@ -184,6 +196,16 @@ async Task<ResponsePacket> IUdpService.SendAsync(SendPacket packet, Cancellation _logger.UdpService_Cancelled(packet); throw; } + catch (UdpException error) when (cancellationToken.IsCancellationRequested) + { + _logger.UdpService_Cancelled(packet); + throw new OperationCanceledException("UDP send was cancelled.", error, cancellationToken); + } + catch (Exception error) when (cancellationToken.IsCancellationRequested) + { + _logger.UdpService_Cancelled(packet); + throw new OperationCanceledException("UDP send was cancelled.", error, cancellationToken); + } catch (UdpException error) { _logger.UdpService_Failed(packet, error); diff --git a/src/AdaptiveRemote.Backend.Common/AdaptiveRemote.Backend.Common.csproj b/src/AdaptiveRemote.Backend.Common/AdaptiveRemote.Backend.Common.csproj new file mode 100644 index 00000000..4b0c8a75 --- /dev/null +++ b/src/AdaptiveRemote.Backend.Common/AdaptiveRemote.Backend.Common.csproj @@ -0,0 +1,13 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Logging" /> + </ItemGroup> + +</Project> diff --git a/src/AdaptiveRemote.Backend.Common/Logging/FileLoggerExtensions.cs b/src/AdaptiveRemote.Backend.Common/Logging/FileLoggerExtensions.cs new file mode 100644 index 00000000..2d0b86e3 --- /dev/null +++ b/src/AdaptiveRemote.Backend.Common/Logging/FileLoggerExtensions.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.Backend.Common.Logging; + +public static class FileLoggerExtensions +{ + public static ILoggingBuilder AddFile(this ILoggingBuilder builder, string filePath) + { + builder.AddProvider(new SimpleFileLoggerProvider(filePath)); + return builder; + } +} + +internal sealed class SimpleFileLoggerProvider : ILoggerProvider +{ + private readonly string _filePath; + private readonly object _lock = new(); + + public SimpleFileLoggerProvider(string filePath) + { + _filePath = filePath; + } + + public ILogger CreateLogger(string categoryName) => new SimpleFileLogger(_filePath, _lock, categoryName); + + public void Dispose() { } + + private class SimpleFileLogger : ILogger + { + private readonly string _filePath; + private readonly object _lock; + private readonly string _categoryName; + + public SimpleFileLogger(string filePath, object lockObj, string categoryName) + { + _filePath = filePath; + _lock = lockObj; + _categoryName = categoryName; + } + + IDisposable ILogger.BeginScope<TState>(TState state) => null!; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) + { + string message = $"[{DateTime.Now:O}] [{logLevel}] [{_categoryName}] {formatter(state, exception)}"; + lock (_lock) + { + File.AppendAllText(_filePath, message + "\n"); + if (exception != null) + { + File.AppendAllText(_filePath, exception + "\n"); + } + } + } + } +} diff --git a/src/AdaptiveRemote.Backend.Common/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.Common/Logging/MessageLogger.cs new file mode 100644 index 00000000..a7e0ed58 --- /dev/null +++ b/src/AdaptiveRemote.Backend.Common/Logging/MessageLogger.cs @@ -0,0 +1,131 @@ +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.Backend.Common.Logging; + +/// <summary> +/// Centralized logging messages for CompiledLayoutService. +/// All log messages MUST be defined here as [LoggerMessage] source-generated methods. +/// Event ID ranges: +/// 1100-1199: CompiledLayoutService +/// </summary> +public static partial class MessageLogger +{ + // Common service messages + [LoggerMessage(EventId = 1100, Level = LogLevel.Information, Message = "{ServiceName} starting")] + public static partial void ServiceStarting(this ILogger logger, string serviceName); + + [LoggerMessage(EventId = 1101, Level = LogLevel.Information, Message = "{ServiceName} started successfully on {ListenAddress}")] + public static partial void ServiceStarted(this ILogger logger, string serviceName, string listenAddress); + + [LoggerMessage(EventId = 1102, Level = LogLevel.Information, Message = "{Method} {Path} request received for userId={UserId}")] + public static partial void AuthenticatedRequestStarted(this ILogger logger, string method, string path, string userId); + + [LoggerMessage(EventId = 1103, Level = LogLevel.Information, Message = "{Method} {Path} request received")] + public static partial void UnauthenticatedRequestStarted(this ILogger logger, string method, string path); + + [LoggerMessage(EventId = 1104, Level = LogLevel.Information, Message = "{Method} {Path} request handled")] + public static partial void RequestHandled(this ILogger logger, string method, string path); + + [LoggerMessage(EventId = 1105, Level = LogLevel.Information, Message = "Health check successful")] + public static partial void HealthCheckSuccessful(this ILogger logger); + + [LoggerMessage(EventId = 1106, Level = LogLevel.Error, Message = "Error processing health check request")] + public static partial void ErrorProcessingHealthCheck(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 1107, + Level = LogLevel.Error, + Message = "LocalStack dependency check failed at {HealthUrl}: {FailureReason}. LocalStack is required for local development. See docs/local-dev.md for setup instructions")] + public static partial void LocalStackDependencyUnavailable(this ILogger logger, string healthUrl, string failureReason, Exception? exception); + + // CompiledLayoutService-specific messages + [LoggerMessage(EventId = 1301, Level = LogLevel.Information, Message = "Returning active compiled layout Id={LayoutId}")] + public static partial void ReturningActiveLayout(this ILogger logger, Guid layoutId); + + [LoggerMessage(EventId = 1303, Level = LogLevel.Error, Message = "Error retrieving active layout for userId={UserId}")] + public static partial void ErrorRetrievingActiveLayout(this ILogger logger, string userId, Exception exception); + + // RawLayoutService-specific messages + [LoggerMessage(EventId = 1201, Level = LogLevel.Information, Message = "Raw layout created successfully: Id={LayoutId}")] + public static partial void RawLayoutCreated(this ILogger logger, Guid layoutId); + + [LoggerMessage(EventId = 1202, Level = LogLevel.Information, Message = "Raw layout updated successfully: Id={LayoutId}")] + public static partial void RawLayoutUpdated(this ILogger logger, Guid layoutId); + + [LoggerMessage(EventId = 1203, Level = LogLevel.Information, Message = "Raw layout deleted successfully: Id={LayoutId}")] + public static partial void RawLayoutDeleted(this ILogger logger, Guid layoutId); + + [LoggerMessage(EventId = 1204, Level = LogLevel.Error, Message = "Error retrieving raw layouts for userId={UserId}")] + public static partial void ErrorRetrievingRawLayouts(this ILogger logger, string userId, Exception exception); + + [LoggerMessage(EventId = 1205, Level = LogLevel.Error, Message = "Error retrieving raw layout Id={LayoutId} for userId={UserId}")] + public static partial void ErrorRetrievingRawLayout(this ILogger logger, Guid layoutId, string userId, Exception exception); + + [LoggerMessage(EventId = 1206, Level = LogLevel.Error, Message = "Error creating raw layout for userId={UserId}")] + public static partial void ErrorCreatingRawLayout(this ILogger logger, string userId, Exception exception); + + [LoggerMessage(EventId = 1207, Level = LogLevel.Error, Message = "Error updating raw layout Id={LayoutId} for userId={UserId}")] + public static partial void ErrorUpdatingRawLayout(this ILogger logger, Guid layoutId, string userId, Exception exception); + + [LoggerMessage(EventId = 1208, Level = LogLevel.Error, Message = "Error deleting raw layout Id={LayoutId} for userId={UserId}")] + public static partial void ErrorDeletingRawLayout(this ILogger logger, Guid layoutId, string userId, Exception exception); + + [LoggerMessage(EventId = 1209, Level = LogLevel.Information, Message = "SQS trigger enqueued; rawLayoutId={RawLayoutId} queueUrl={QueueUrl}")] + public static partial void SqsTriggerEnqueued(this ILogger logger, Guid rawLayoutId, string queueUrl); + + [LoggerMessage(EventId = 1210, Level = LogLevel.Error, Message = "Failed to enqueue SQS trigger; rawLayoutId={RawLayoutId}")] + public static partial void ErrorEnqueuingSqsTrigger(this ILogger logger, Guid rawLayoutId, Exception exception); + + [LoggerMessage(EventId = 1211, Level = LogLevel.Information, Message = "Validation result updated for raw layout Id={LayoutId}")] + public static partial void ValidationResultUpdated(this ILogger logger, Guid layoutId); + + [LoggerMessage(EventId = 1212, Level = LogLevel.Error, Message = "Error updating validation result for raw layout Id={LayoutId}")] + public static partial void ErrorUpdatingValidationResult(this ILogger logger, Guid layoutId, Exception exception); + + // LayoutProcessingService-specific messages + [LoggerMessage(EventId = 1706, Level = LogLevel.Information, Message = "SQS polling loop started; queue={QueueUrl}")] + public static partial void SqsPollingStarted(this ILogger logger, string queueUrl); + + [LoggerMessage(EventId = 1707, Level = LogLevel.Information, Message = "SQS polling loop stopped")] + public static partial void SqsPollingStopped(this ILogger logger); + + [LoggerMessage(EventId = 1708, Level = LogLevel.Information, Message = "SQS message received; rawLayoutId={RawLayoutId} receiptHandle={ReceiptHandle}")] + public static partial void SqsMessageReceived(this ILogger logger, Guid rawLayoutId, string receiptHandle); + + [LoggerMessage(EventId = 1709, Level = LogLevel.Information, Message = "Layout compiled successfully; rawLayoutId={RawLayoutId}")] + public static partial void LayoutCompiled(this ILogger logger, Guid rawLayoutId); + + [LoggerMessage(EventId = 1710, Level = LogLevel.Information, Message = "Layout validation passed; rawLayoutId={RawLayoutId}")] + public static partial void LayoutValidationPassed(this ILogger logger, Guid rawLayoutId); + + [LoggerMessage(EventId = 1711, Level = LogLevel.Warning, Message = "Layout validation failed; rawLayoutId={RawLayoutId} issueCount={IssueCount}")] + public static partial void LayoutValidationFailed(this ILogger logger, Guid rawLayoutId, int issueCount); + + [LoggerMessage(EventId = 1712, Level = LogLevel.Information, Message = "Compiled layout stored; rawLayoutId={RawLayoutId} compiledLayoutId={CompiledLayoutId}")] + public static partial void CompiledLayoutStored(this ILogger logger, Guid rawLayoutId, Guid compiledLayoutId); + + [LoggerMessage(EventId = 1713, Level = LogLevel.Information, Message = "Layout-ready notification published; userId={UserId} compiledLayoutId={CompiledLayoutId}")] + public static partial void LayoutReadyPublished(this ILogger logger, string userId, Guid compiledLayoutId); + + [LoggerMessage(EventId = 1714, Level = LogLevel.Information, Message = "SQS message processed successfully; rawLayoutId={RawLayoutId}")] + public static partial void SqsMessageProcessedSuccessfully(this ILogger logger, Guid rawLayoutId); + + [LoggerMessage(EventId = 1715, Level = LogLevel.Error, Message = "Failed to process SQS message; rawLayoutId={RawLayoutId} receiptHandle={ReceiptHandle}")] + public static partial void ErrorProcessingSqsMessage(this ILogger logger, Guid rawLayoutId, string receiptHandle, Exception exception); + + [LoggerMessage(EventId = 1716, Level = LogLevel.Warning, Message = "SQS message is being retried; rawLayoutId={RawLayoutId} approximateReceiveCount={ApproximateReceiveCount}")] + public static partial void SqsMessageRetry(this ILogger logger, Guid rawLayoutId, int approximateReceiveCount); + + [LoggerMessage(EventId = 1717, Level = LogLevel.Error, Message = "SQS polling error; will retry")] + public static partial void SqsPollingError(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = 1718, Level = LogLevel.Warning, Message = "Raw layout not found; rawLayoutId={RawLayoutId}")] + public static partial void RawLayoutNotFound(this ILogger logger, Guid rawLayoutId); + + [LoggerMessage(EventId = 1719, Level = LogLevel.Information, Message = "Validation result written back to raw layout; rawLayoutId={RawLayoutId}")] + public static partial void ValidationResultWrittenBack(this ILogger logger, Guid rawLayoutId); + + [LoggerMessage(EventId = 1720, Level = LogLevel.Warning, Message = "SQS message unrecognized and deleted; receiptHandle={ReceiptHandle}")] + public static partial void SqsUnrecognizedMessageWarning(this ILogger logger, string receiptHandle, Exception exception); + +} diff --git a/src/AdaptiveRemote.Backend.Common/Logging/RequestHandlerScope.cs b/src/AdaptiveRemote.Backend.Common/Logging/RequestHandlerScope.cs new file mode 100644 index 00000000..7b4eb2cb --- /dev/null +++ b/src/AdaptiveRemote.Backend.Common/Logging/RequestHandlerScope.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.Backend.Common.Logging; + +public static class RequestHandlerScopeExtensions +{ + public static IDisposable StartRequestScope(this ILogger logger, string method, string path, string? userId = null) + { + if (userId != null) + { + logger.AuthenticatedRequestStarted(method, path, userId); + } + else + { + logger.UnauthenticatedRequestStarted(method, path); + } + + return new RequestHandlerScope(logger, method, path); + } +} + +internal class RequestHandlerScope : IDisposable +{ + private readonly ILogger _logger; + private readonly string _method; + private readonly string _path; + private bool _disposed = false; + + public RequestHandlerScope(ILogger logger, string method, string path) + { + _logger = logger; + _method = method; + _path = path; + } + + public void Dispose() + { + if (!Interlocked.Exchange(ref _disposed, true)) + { + _logger.RequestHandled(_method, _path); + } + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj index 8199e544..84f6068f 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj @@ -15,6 +15,7 @@ </ItemGroup> <ItemGroup> + <ProjectReference Include="..\AdaptiveRemote.Backend.Common\AdaptiveRemote.Backend.Common.csproj" /> <ProjectReference Include="..\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj" /> </ItemGroup> diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs index 7a69c4a7..03eafd66 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs @@ -1,6 +1,7 @@ using System.Reflection; -using AdaptiveRemote.Backend.CompiledLayoutService.Logging; +using AdaptiveRemote.Backend.Common.Logging; using AdaptiveRemote.Contracts; +using Microsoft.OpenApi; namespace AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; @@ -15,7 +16,7 @@ public static void MapHealthEndpoints(this IEndpointRouteBuilder app) private static IResult GetHealth(ILogger<Program> logger) { - logger.HealthCheckRequested(); + using IDisposable scope = logger.StartRequestScope("GET", "/health"); string? version = Assembly.GetExecutingAssembly() .GetCustomAttribute<AssemblyInformationalVersionAttribute>() diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs index e18b116a..9fd0e325 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using AdaptiveRemote.Backend.CompiledLayoutService.Logging; +using AdaptiveRemote.Backend.Common.Logging; using AdaptiveRemote.Contracts; namespace AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; @@ -12,6 +12,28 @@ public static void MapLayoutEndpoints(this IEndpointRouteBuilder app) .WithName(nameof(GetActiveLayout)) .Produces<CompiledLayout>(StatusCodes.Status200OK) .RequireAuthorization(); + + app.MapPost("/layouts/compiled", CreateOrUpdateLayout) + .WithName(nameof(CreateOrUpdateLayout)) + .Produces<CompiledLayout>(StatusCodes.Status201Created); + } + + private static async Task<IResult> CreateOrUpdateLayout( + ILogger<Program> logger, + CompiledLayout layout, + CancellationToken cancellationToken) + { + using IDisposable scope = logger.StartRequestScope("POST", "/layouts/compiled"); + + // Stub implementation to support E2E testing + if (layout is null) + { + return Results.BadRequest(); + } + + // Assign a new ID to simulate storage + CompiledLayout stored = layout with { Id = Guid.NewGuid() }; + return Results.Created($"/layouts/compiled/{stored.Id}", stored); } private static async Task<IResult> GetActiveLayout( @@ -28,7 +50,7 @@ private static async Task<IResult> GetActiveLayout( return Results.Unauthorized(); } - logger.GetActiveLayoutRequested(userId); + using IDisposable scope = logger.StartRequestScope("GET", "/layouts/compiled/active", userId); CompiledLayout? layout = await repository.GetActiveForUserAsync(userId, cancellationToken); diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs deleted file mode 100644 index a1ff5f46..00000000 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace AdaptiveRemote.Backend.CompiledLayoutService.Logging; - -/// <summary> -/// Centralized logging messages for CompiledLayoutService. -/// All log messages MUST be defined here as [LoggerMessage] source-generated methods. -/// Event ID ranges: -/// 1100-1199: CompiledLayoutService -/// </summary> -public static partial class MessageLogger -{ - [LoggerMessage(EventId = 1100, Level = LogLevel.Information, Message = "CompiledLayoutService starting")] - public static partial void ServiceStarting(this ILogger logger); - - [LoggerMessage(EventId = 1101, Level = LogLevel.Information, Message = "CompiledLayoutService started successfully on {ListenAddress}")] - public static partial void ServiceStarted(this ILogger logger, string listenAddress); - - [LoggerMessage(EventId = 1102, Level = LogLevel.Information, Message = "GET /layouts/compiled/active request received for userId={UserId}")] - public static partial void GetActiveLayoutRequested(this ILogger logger, string userId); - - [LoggerMessage(EventId = 1103, Level = LogLevel.Information, Message = "Returning active compiled layout Id={LayoutId}")] - public static partial void ReturningActiveLayout(this ILogger logger, Guid layoutId); - - [LoggerMessage(EventId = 1104, Level = LogLevel.Information, Message = "GET /health request received")] - public static partial void HealthCheckRequested(this ILogger logger); - - [LoggerMessage(EventId = 1105, Level = LogLevel.Information, Message = "Health check successful")] - public static partial void HealthCheckSuccessful(this ILogger logger); - - [LoggerMessage(EventId = 1106, Level = LogLevel.Error, Message = "Error retrieving active layout for userId={UserId}")] - public static partial void ErrorRetrievingActiveLayout(this ILogger logger, string userId, Exception exception); - - [LoggerMessage(EventId = 1107, Level = LogLevel.Error, Message = "Error processing health check request")] - public static partial void ErrorProcessingHealthCheck(this ILogger logger, Exception exception); - - [LoggerMessage( - EventId = 1108, - Level = LogLevel.Error, - Message = "LocalStack dependency check failed at {HealthUrl}: {FailureReason}. LocalStack is required for local development. See docs/local-dev.md for setup instructions")] - public static partial void LocalStackDependencyUnavailable(this ILogger logger, string healthUrl, string failureReason, Exception? exception); -} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index df0627e8..fc8db7a6 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -1,18 +1,31 @@ +using System.Text.Json; +using AdaptiveRemote.Backend.Common.Logging; using AdaptiveRemote.Backend.CompiledLayoutService.Configuration; using AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; -using AdaptiveRemote.Backend.CompiledLayoutService.Logging; using AdaptiveRemote.Backend.CompiledLayoutService.Services; using AdaptiveRemote.Contracts; using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Scalar.AspNetCore; -using System.Net.Http; -using System.Text.Json; + +string? logFilePath = null; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i] == "--logFile") + { + logFilePath = args[i + 1]; + break; + } +} WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +if (!string.IsNullOrEmpty(logFilePath)) +{ + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(); + builder.Logging.AddFile(logFilePath); +} + // Register services builder.Services.AddSingleton<ICompiledLayoutRepository, HardcodedLayoutProvider>(); @@ -49,7 +62,7 @@ WebApplication app = builder.Build(); ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>(); -logger.ServiceStarting(); +logger.ServiceStarting("CompiledLayoutService"); if (app.Environment.IsDevelopment()) { @@ -75,7 +88,7 @@ string listenAddress = app.Configuration["ASPNETCORE_URLS"] ?? app.Configuration["urls"] ?? "http://localhost:5000"; -logger.ServiceStarted(listenAddress); +logger.ServiceStarted("CompiledLayoutService", listenAddress); app.Run(); diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs index 2fca7dc8..3524eb58 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs @@ -86,4 +86,29 @@ public class HardcodedLayoutProvider : ICompiledLayoutRepository return Task.FromResult<CompiledLayout?>(layout); } + + public Task<IReadOnlyList<CompiledLayout>> ListByUserAsync(string userId, CancellationToken cancellationToken = default) + { + // Hardcoded MVP — real DynamoDB implementation in ADR-173 + IReadOnlyList<CompiledLayout> empty = Array.Empty<CompiledLayout>(); + return Task.FromResult(empty); + } + + public Task<CompiledLayout?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + // Hardcoded MVP — real DynamoDB implementation in ADR-173 + return Task.FromResult<CompiledLayout?>(null); + } + + public Task<CompiledLayout> SaveAsync(CompiledLayout layout, CancellationToken cancellationToken = default) + { + // Hardcoded MVP — real DynamoDB implementation in ADR-173 + return Task.FromResult(layout); + } + + public Task SetActiveAsync(Guid id, string userId, CancellationToken cancellationToken = default) + { + // Hardcoded MVP — real DynamoDB implementation in ADR-173 + return Task.CompletedTask; + } } diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/AdaptiveRemote.Backend.LayoutProcessingService.csproj b/src/AdaptiveRemote.Backend.LayoutProcessingService/AdaptiveRemote.Backend.LayoutProcessingService.csproj new file mode 100644 index 00000000..1566976e --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/AdaptiveRemote.Backend.LayoutProcessingService.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <RootNamespace>AdaptiveRemote.Backend.LayoutProcessingService</RootNamespace> + <UserSecretsId>3d8a2e1f-c947-4b5a-8d0e-g632eb4c9f58</UserSecretsId> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> + <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> + <PackageReference Include="Scalar.AspNetCore" /> + <PackageReference Include="AWSSDK.SQS" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\AdaptiveRemote.Backend.Common\AdaptiveRemote.Backend.Common.csproj" /> + <ProjectReference Include="..\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/CognitoSettings.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/CognitoSettings.cs new file mode 100644 index 00000000..5d6b4f50 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/CognitoSettings.cs @@ -0,0 +1,19 @@ +namespace AdaptiveRemote.Backend.LayoutProcessingService.Configuration; + +/// <summary> +/// Configuration for AWS Cognito JWT validation. +/// Maps to the "Cognito" section in appsettings.json. +/// </summary> +public class CognitoSettings +{ + /// <summary> + /// The Cognito user pool authority URL, e.g. + /// https://cognito-idp.{region}.amazonaws.com/{userPoolId} + /// </summary> + public string Authority { get; set; } = string.Empty; + + /// <summary> + /// The OAuth2 audience (app client ID). If empty, audience validation is skipped. + /// </summary> + public string? Audience { get; set; } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/CompiledLayoutServiceSettings.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/CompiledLayoutServiceSettings.cs new file mode 100644 index 00000000..3181cd4e --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/CompiledLayoutServiceSettings.cs @@ -0,0 +1,13 @@ +namespace AdaptiveRemote.Backend.LayoutProcessingService.Configuration; + +/// <summary> +/// Configuration for HTTP communication with CompiledLayoutService. +/// Maps to the "CompiledLayoutService" section in appsettings.json. +/// </summary> +public class CompiledLayoutServiceSettings +{ + /// <summary> + /// Base URL of CompiledLayoutService, e.g. http://compiledlayoutservice:8080 + /// </summary> + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/RawLayoutServiceSettings.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/RawLayoutServiceSettings.cs new file mode 100644 index 00000000..088351af --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/RawLayoutServiceSettings.cs @@ -0,0 +1,21 @@ +namespace AdaptiveRemote.Backend.LayoutProcessingService.Configuration; + +/// <summary> +/// Configuration for HTTP communication with RawLayoutService. +/// Maps to the "RawLayoutService" section in appsettings.json. +/// </summary> +public class RawLayoutServiceSettings +{ + /// <summary> + /// Base URL of RawLayoutService, e.g. http://rawlayoutservice:8080 + /// </summary> + public string BaseUrl { get; set; } = string.Empty; + + /// <summary> + /// Optional bearer token for service-to-service authentication. + /// In production, this will be replaced by IAM-signed requests or a Cognito + /// machine-to-machine token. For local development and testing, set this to + /// a valid JWT from the same authority as RawLayoutService. + /// </summary> + public string? ServiceAccountToken { get; set; } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/SqsSettings.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/SqsSettings.cs new file mode 100644 index 00000000..f5667d54 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/SqsSettings.cs @@ -0,0 +1,44 @@ +namespace AdaptiveRemote.Backend.LayoutProcessingService.Configuration; + +/// <summary> +/// Configuration for AWS SQS connection. +/// Maps to the "Sqs" section in appsettings.json. +/// </summary> +public class SqsSettings +{ + /// <summary> + /// The SQS service URL. For LocalStack: http://localhost:4566 + /// For AWS: leave empty to use default AWS endpoint. + /// </summary> + public string? ServiceUrl { get; set; } + + /// <summary> + /// The URL of the layout processing queue. + /// </summary> + public string QueueUrl { get; set; } = string.Empty; + + /// <summary> + /// AWS region (e.g. "us-east-1"). + /// </summary> + public string Region { get; set; } = "us-east-1"; + + /// <summary> + /// Maximum number of messages to retrieve per SQS poll. Must be between 1 and 10. + /// </summary> + public int MaxNumberOfMessages { get; set; } = 10; + + /// <summary> + /// Visibility timeout in seconds. How long a dequeued message is hidden from other consumers. + /// </summary> + public int VisibilityTimeoutSeconds { get; set; } = 60; + + /// <summary> + /// Long-poll wait time in seconds. 0 disables long polling. + /// </summary> + public int WaitTimeSeconds { get; set; } = 5; + + /// <summary> + /// Delay between SQS polls when no messages are returned, in milliseconds. + /// </summary> + public int EmptyQueueDelayMs { get; set; } = 2000; +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Dockerfile b/src/AdaptiveRemote.Backend.LayoutProcessingService/Dockerfile new file mode 100644 index 00000000..6e017cb1 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Dockerfile @@ -0,0 +1,24 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy csproj files and restore +COPY ["src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj", "AdaptiveRemote.Contracts/"] +COPY ["src/AdaptiveRemote.Backend.LayoutProcessingService/AdaptiveRemote.Backend.LayoutProcessingService.csproj", "AdaptiveRemote.Backend.LayoutProcessingService/"] +COPY ["Directory.Build.props", "./"] +COPY ["Directory.Packages.props", "./"] +RUN dotnet restore "AdaptiveRemote.Backend.LayoutProcessingService/AdaptiveRemote.Backend.LayoutProcessingService.csproj" + +# Copy source and build +COPY ["src/AdaptiveRemote.Contracts/", "AdaptiveRemote.Contracts/"] +COPY ["src/AdaptiveRemote.Backend.LayoutProcessingService/", "AdaptiveRemote.Backend.LayoutProcessingService/"] +WORKDIR "/src/AdaptiveRemote.Backend.LayoutProcessingService" +RUN dotnet build "AdaptiveRemote.Backend.LayoutProcessingService.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "AdaptiveRemote.Backend.LayoutProcessingService.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=publish /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "AdaptiveRemote.Backend.LayoutProcessingService.dll"] diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Endpoints/HealthEndpoints.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Endpoints/HealthEndpoints.cs new file mode 100644 index 00000000..6421afa4 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Endpoints/HealthEndpoints.cs @@ -0,0 +1,44 @@ +using AdaptiveRemote.Backend.Common.Logging; +using AdaptiveRemote.Contracts; +using System.Reflection; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Endpoints; + +/// <summary> +/// Health check endpoint. Returns service name, version, and status. +/// </summary> +public static class HealthEndpoints +{ + public static void MapHealthEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/health", GetHealth) + .WithName(nameof(GetHealth)) + .Produces<HealthResponse>(StatusCodes.Status200OK) + .AllowAnonymous(); + } + + private static IResult GetHealth(ILogger<Program> logger) + { + using IDisposable scope = logger.StartRequestScope("GET", "/health"); + + try + { + HealthResponse response = new( + ServiceName: "LayoutProcessingService", + Version: Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown", + Status: "Healthy" + ); + + logger.HealthCheckSuccessful(); + + return Results.Json( + response, + LayoutContractsJsonContext.Default.HealthResponse); + } + catch (Exception ex) + { + logger.ErrorProcessingHealthCheck(ex); + return Results.Problem("Health check failed"); + } + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs new file mode 100644 index 00000000..9c662f02 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs @@ -0,0 +1,306 @@ +using System.Text.Json; +using AdaptiveRemote.Backend.Common.Logging; +using AdaptiveRemote.Backend.LayoutProcessingService.Configuration; +using AdaptiveRemote.Backend.LayoutProcessingService.Endpoints; +using AdaptiveRemote.Backend.LayoutProcessingService.Services; +using AdaptiveRemote.Contracts; +using Amazon.SQS; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Scalar.AspNetCore; + +string? logFilePath = null; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i] == "--logFile") + { + logFilePath = args[i + 1]; + break; + } +} + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +if (!string.IsNullOrEmpty(logFilePath)) +{ + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(); + builder.Logging.AddFile(logFilePath); +} + +// Configure SQS settings +SqsSettings sqsSettings = builder.Configuration + .GetSection("Sqs") + .Get<SqsSettings>() ?? new SqsSettings(); + +builder.Services.Configure<SqsSettings>(builder.Configuration.GetSection("Sqs")); + +// Create SQS client +IAmazonSQS sqsClient; + +if (!string.IsNullOrEmpty(sqsSettings.ServiceUrl)) +{ + // LocalStack or custom endpoint + AmazonSQSConfig sqsConfig = new() + { + ServiceURL = sqsSettings.ServiceUrl, + AuthenticationRegion = sqsSettings.Region + }; + + string? accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID"); + string? secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY"); + + if (!string.IsNullOrEmpty(accessKey) && !string.IsNullOrEmpty(secretKey)) + { + sqsClient = new AmazonSQSClient( + new Amazon.Runtime.BasicAWSCredentials(accessKey, secretKey), + sqsConfig); + } + else + { + sqsClient = new AmazonSQSClient(sqsConfig); + } +} +else +{ + // Production AWS — use default credential chain (IAM roles, etc.) + AmazonSQSConfig sqsConfig = new() + { + RegionEndpoint = Amazon.RegionEndpoint.GetBySystemName(sqsSettings.Region) + }; + sqsClient = new AmazonSQSClient(sqsConfig); +} + +builder.Services.AddSingleton(sqsClient); + +// Configure HTTP client for RawLayoutService +RawLayoutServiceSettings rawLayoutSettings = builder.Configuration + .GetSection("RawLayoutService") + .Get<RawLayoutServiceSettings>() ?? new RawLayoutServiceSettings(); + +builder.Services.Configure<RawLayoutServiceSettings>(builder.Configuration.GetSection("RawLayoutService")); + +// If a service account token is configured, attach it as a bearer token on outgoing requests. +// In production this will be replaced by IAM-signed requests or a Cognito M2M token. +bool hasServiceAccountToken = !string.IsNullOrEmpty(rawLayoutSettings.ServiceAccountToken); +if (hasServiceAccountToken) +{ + builder.Services.AddTransient(_ => + new ServiceAccountTokenHandler(rawLayoutSettings.ServiceAccountToken!)); +} + +void ConfigureRawLayoutClient(HttpClient client) +{ + if (!string.IsNullOrEmpty(rawLayoutSettings.BaseUrl)) + { + client.BaseAddress = new Uri(rawLayoutSettings.BaseUrl); + } +} + +IHttpClientBuilder rawLayoutRepoBuilder = + builder.Services.AddHttpClient<IRawLayoutRepository, HttpRawLayoutRepository>(ConfigureRawLayoutClient); +IHttpClientBuilder rawLayoutWriterBuilder = + builder.Services.AddHttpClient<IRawLayoutStatusWriter, HttpRawLayoutStatusWriter>(ConfigureRawLayoutClient); + +if (hasServiceAccountToken) +{ + rawLayoutRepoBuilder.AddHttpMessageHandler<ServiceAccountTokenHandler>(); + rawLayoutWriterBuilder.AddHttpMessageHandler<ServiceAccountTokenHandler>(); +} + +// Configure HTTP client for CompiledLayoutService +CompiledLayoutServiceSettings compiledLayoutSettings = builder.Configuration + .GetSection("CompiledLayoutService") + .Get<CompiledLayoutServiceSettings>() ?? new CompiledLayoutServiceSettings(); + +builder.Services.Configure<CompiledLayoutServiceSettings>(builder.Configuration.GetSection("CompiledLayoutService")); + +builder.Services.AddHttpClient<ICompiledLayoutRepository, HttpCompiledLayoutRepository>(client => +{ + if (!string.IsNullOrEmpty(compiledLayoutSettings.BaseUrl)) + { + client.BaseAddress = new Uri(compiledLayoutSettings.BaseUrl); + } +}); + +// Register stub implementations (to be replaced in later tasks) +builder.Services.AddSingleton<ILayoutCompilerClient, StubLayoutCompilerClient>(); +builder.Services.AddSingleton<ILayoutValidationClient, StubLayoutValidationClient>(); +builder.Services.AddSingleton<INotificationPublisher, StubNotificationPublisher>(); + +// Register the orchestration background service. +// Set Orchestrator:Enabled=false to skip registration (e.g. health-check-only E2E tests). +bool orchestratorEnabled = builder.Configuration.GetValue("Orchestrator:Enabled", defaultValue: true); +if (orchestratorEnabled) +{ + builder.Services.AddHostedService<LayoutProcessingOrchestrator>(); +} + +// Configure JWT Bearer authentication with AWS Cognito +CognitoSettings cognitoSettings = builder.Configuration + .GetSection("Cognito") + .Get<CognitoSettings>() ?? new CognitoSettings(); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = cognitoSettings.Authority; + + if (!string.IsNullOrEmpty(cognitoSettings.Audience)) + { + options.Audience = cognitoSettings.Audience; + } + else + { + // When no audience is configured, skip audience validation. + options.TokenValidationParameters.ValidateAudience = false; + } + + // Preserve original claim names from the JWT (don't remap to .NET claim types). + options.MapInboundClaims = false; + + // Allow HTTP metadata endpoints in non-production environments (local dev and tests). + options.RequireHttpsMetadata = builder.Environment.IsProduction(); + }); + +builder.Services.AddAuthorization(); +builder.Services.AddOpenApi(); + +WebApplication app = builder.Build(); + +ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>(); +logger.ServiceStarting("LayoutProcessingService"); + +if (app.Environment.IsDevelopment()) +{ + await EnsureLocalStackRunningAsync(app, logger).ConfigureAwait(false); +} + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapOpenApi(); + +if (app.Environment.IsDevelopment()) +{ + app.MapScalarApiReference(); +} + +// Map endpoints +app.MapHealthEndpoints(); + +// Log the configured listen address; fall back to Kestrel's default. +string listenAddress = app.Configuration["ASPNETCORE_URLS"] + ?? app.Configuration["urls"] + ?? "http://localhost:5000"; +logger.ServiceStarted("LayoutProcessingService", listenAddress); + +app.Run(); + +static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logger) +{ + const int LocalStackHealthCheckTimeoutSeconds = 5; + const int LocalStackStartupWaitTimeoutSeconds = 30; + const int LocalStackRetryDelaySeconds = 2; + TimeSpan localStackStartupWaitTimeout = TimeSpan.FromSeconds(LocalStackStartupWaitTimeoutSeconds); + TimeSpan localStackRetryDelay = TimeSpan.FromSeconds(LocalStackRetryDelaySeconds); + string[] requiredServices = ["sqs"]; + + string baseUrl = app.Configuration["LocalStack:BaseUrl"] ?? "http://localhost:4566"; + + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? baseUri)) + { + logger.LocalStackDependencyUnavailable(baseUrl, "configuration value is not a valid absolute URL", exception: null); + Environment.Exit(1); + } + + Uri healthUri = new(baseUri, "/_localstack/health"); + + using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(LocalStackHealthCheckTimeoutSeconds) }; + Exception? lastException = null; + string? lastFailureReason = null; + DateTime deadlineUtc = DateTime.UtcNow.Add(localStackStartupWaitTimeout); + + while (DateTime.UtcNow < deadlineUtc) + { + try + { + using HttpResponseMessage response = await client.GetAsync(healthUri).ConfigureAwait(false); + string body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + lastFailureReason = $"HTTP {(int)response.StatusCode}"; + } + else + { + using JsonDocument json = JsonDocument.Parse(body); + if (IsLocalStackRunning(json.RootElement, requiredServices, out string failureReason)) + { + return; + } + + lastFailureReason = failureReason; + } + + lastException = null; + } + catch (Exception ex) + { + lastException = ex; + lastFailureReason = ex.Message; + } + + await Task.Delay(localStackRetryDelay).ConfigureAwait(false); + } + + logger.LocalStackDependencyUnavailable( + healthUri.ToString(), + $"did not become healthy within {LocalStackStartupWaitTimeoutSeconds}s; last check result: {lastFailureReason ?? "unknown health check failure"}", + lastException); + Environment.Exit(1); +} + +static bool IsLocalStackRunning(JsonElement root, IReadOnlyList<string> requiredServices, out string failureReason) +{ + if (root.TryGetProperty("status", out JsonElement statusElement)) + { + string status = statusElement.GetString() ?? string.Empty; + if (string.Equals(status, "running", StringComparison.OrdinalIgnoreCase)) + { + failureReason = string.Empty; + return true; + } + + failureReason = $"status='{status}'"; + return false; + } + + if (!root.TryGetProperty("services", out JsonElement servicesElement) || servicesElement.ValueKind != JsonValueKind.Object) + { + failureReason = "health response did not contain a running status or services object"; + return false; + } + + foreach (string service in requiredServices) + { + if (!servicesElement.TryGetProperty(service, out JsonElement serviceStatusElement)) + { + failureReason = $"service '{service}' was missing from health response"; + return false; + } + + string serviceStatus = serviceStatusElement.GetString() ?? string.Empty; + if (!string.Equals(serviceStatus, "available", StringComparison.OrdinalIgnoreCase) && + !string.Equals(serviceStatus, "running", StringComparison.OrdinalIgnoreCase)) + { + failureReason = $"service '{service}' status was '{serviceStatus}'"; + return false; + } + } + + failureReason = string.Empty; + return true; +} + +// Make Program visible for testing +public partial class Program { } diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Properties/launchSettings.json b/src/AdaptiveRemote.Backend.LayoutProcessingService/Properties/launchSettings.json new file mode 100644 index 00000000..ebd646c6 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Properties/launchSettings.json @@ -0,0 +1,24 @@ +{ + "profiles": { + "Development": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "scalar", + "outputCapture": "None", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54437;http://localhost:54438" + }, + "AdaptiveRemote.Backend.LayoutProcessingService": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "scalar", + "outputCapture": "None", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54437;http://localhost:54438" + } + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpCompiledLayoutRepository.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpCompiledLayoutRepository.cs new file mode 100644 index 00000000..b4540cee --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpCompiledLayoutRepository.cs @@ -0,0 +1,100 @@ +using AdaptiveRemote.Backend.LayoutProcessingService.Configuration; +using AdaptiveRemote.Contracts; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; + +/// <summary> +/// HTTP client implementation of ICompiledLayoutRepository. +/// Calls CompiledLayoutService over HTTP to store and retrieve compiled layouts. +/// </summary> +public class HttpCompiledLayoutRepository : ICompiledLayoutRepository +{ + private readonly HttpClient _httpClient; + + public HttpCompiledLayoutRepository(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task<CompiledLayout?> GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default) + { + // TODO (ADR-171 / CompiledLayoutService DynamoDB task): Pass userId as a query parameter + // (e.g. ?userId={userId}) once the real DynamoDB backend lands in CompiledLayoutService. + // The current stub ignores userId, so this is harmless now, but will silently return + // wrong-user data if this TODO is not addressed before real storage is wired up. + HttpResponseMessage response = await _httpClient + .GetAsync("/layouts/compiled/active", cancellationToken) + .ConfigureAwait(false); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, LayoutContractsJsonContext.Default.CompiledLayout); + } + + public async Task<IReadOnlyList<CompiledLayout>> ListByUserAsync(string userId, CancellationToken cancellationToken = default) + { + // TODO (ADR-171 / CompiledLayoutService DynamoDB task): Pass userId as a query parameter + // (e.g. ?userId={userId}) once the real DynamoDB backend lands in CompiledLayoutService. + // The current stub ignores userId, so this is harmless now, but will silently return + // wrong-user data if this TODO is not addressed before real storage is wired up. + HttpResponseMessage response = await _httpClient + .GetAsync("/layouts/compiled", cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, LayoutContractsJsonContext.Default.IReadOnlyListCompiledLayout) + ?? Array.Empty<CompiledLayout>(); + } + + public async Task<CompiledLayout?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + HttpResponseMessage response = await _httpClient + .GetAsync($"/layouts/compiled/{id}", cancellationToken) + .ConfigureAwait(false); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, LayoutContractsJsonContext.Default.CompiledLayout); + } + + public async Task<CompiledLayout> SaveAsync(CompiledLayout layout, CancellationToken cancellationToken = default) + { + string json = JsonSerializer.Serialize(layout, LayoutContractsJsonContext.Default.CompiledLayout); + StringContent content = new(json, System.Text.Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient + .PostAsync("/layouts/compiled", content, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + string responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(responseJson, LayoutContractsJsonContext.Default.CompiledLayout) + ?? throw new InvalidOperationException("SaveAsync returned null from CompiledLayoutService"); + } + + public async Task SetActiveAsync(Guid id, string userId, CancellationToken cancellationToken = default) + { + HttpResponseMessage response = await _httpClient + .PutAsync($"/layouts/compiled/{id}/active", content: null, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpRawLayoutRepository.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpRawLayoutRepository.cs new file mode 100644 index 00000000..9a7efea0 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpRawLayoutRepository.cs @@ -0,0 +1,78 @@ +using AdaptiveRemote.Contracts; +using System.Text.Json; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; + +/// <summary> +/// HTTP client implementation of IRawLayoutRepository. +/// Calls RawLayoutService over HTTP to fetch and manage raw layouts. +/// </summary> +public class HttpRawLayoutRepository : IRawLayoutRepository +{ + private readonly HttpClient _httpClient; + + public HttpRawLayoutRepository(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task<RawLayout?> GetAsync(Guid id, CancellationToken ct) + { + HttpResponseMessage response = await _httpClient + .GetAsync($"/layouts/raw/{id}", ct) + .ConfigureAwait(false); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, LayoutContractsJsonContext.Default.RawLayout); + } + + public async Task<IReadOnlyList<RawLayout>> ListByUserAsync(string userId, CancellationToken ct) + { + // userId is intentionally not passed as a query parameter here. LayoutProcessingService + // authenticates with a service account JWT, and RawLayoutService derives the caller identity + // from that token rather than from a userId parameter. The userId parameter exists on the + // interface for use by other callers (e.g. the app front-end) where per-user scoping is + // driven by a user JWT instead. + HttpResponseMessage response = await _httpClient + .GetAsync("/layouts/raw", ct) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, LayoutContractsJsonContext.Default.IReadOnlyListRawLayout) + ?? Array.Empty<RawLayout>(); + } + + public async Task<RawLayout> SaveAsync(RawLayout layout, CancellationToken ct) + { + string json = JsonSerializer.Serialize(layout, LayoutContractsJsonContext.Default.RawLayout); + StringContent content = new(json, System.Text.Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient + .PostAsync("/layouts/raw", content, ct) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + string responseJson = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(responseJson, LayoutContractsJsonContext.Default.RawLayout) + ?? throw new InvalidOperationException("SaveAsync returned null from RawLayoutService"); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct) + { + HttpResponseMessage response = await _httpClient + .DeleteAsync($"/layouts/raw/{id}", ct) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpRawLayoutStatusWriter.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpRawLayoutStatusWriter.cs new file mode 100644 index 00000000..e7e2f05d --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpRawLayoutStatusWriter.cs @@ -0,0 +1,30 @@ +using AdaptiveRemote.Contracts; +using System.Text.Json; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; + +/// <summary> +/// HTTP client implementation of IRawLayoutStatusWriter. +/// Calls RawLayoutService over HTTP to write back validation results. +/// </summary> +public class HttpRawLayoutStatusWriter : IRawLayoutStatusWriter +{ + private readonly HttpClient _httpClient; + + public HttpRawLayoutStatusWriter(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task UpdateValidationResultAsync(Guid rawLayoutId, ValidationResult result, CancellationToken ct) + { + string json = JsonSerializer.Serialize(result, LayoutContractsJsonContext.Default.ValidationResult); + StringContent content = new(json, System.Text.Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient + .PatchAsync($"/layouts/raw/{rawLayoutId}/validation-result", content, ct) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/LayoutProcessingOrchestrator.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/LayoutProcessingOrchestrator.cs new file mode 100644 index 00000000..26fd214b --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/LayoutProcessingOrchestrator.cs @@ -0,0 +1,231 @@ +using AdaptiveRemote.Backend.Common.Logging; +using AdaptiveRemote.Backend.LayoutProcessingService.Configuration; +using AdaptiveRemote.Contracts; +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; + +/// <summary> +/// Background hosted service that polls an SQS queue for layout compilation requests +/// and orchestrates the full pipeline: fetch → compile → validate → store or write-back. +/// +/// Pipeline on success: +/// 1. Dequeue SQS message containing rawLayoutId +/// 2. Fetch RawLayout from IRawLayoutRepository +/// 3. Compile via ILayoutCompilerClient +/// 4. Validate via ILayoutValidationClient +/// 5. Store compiled layout via ICompiledLayoutRepository +/// 6. Notify via INotificationPublisher +/// 7. Delete the SQS message +/// +/// Pipeline on validation failure: +/// 1–4 same as above +/// 5. Write ValidationResult back to RawLayout via IRawLayoutStatusWriter +/// 6. Do not store compiled layout; do not notify +/// 7. Delete the SQS message (failure is recorded on the raw layout, not retried) +/// +/// Pipeline on processing error: +/// - Do not delete the message; SQS visibility timeout expires and the message +/// becomes visible again for retry (max receive count = 3; then DLQ). +/// </summary> +public class LayoutProcessingOrchestrator : BackgroundService +{ + private readonly IAmazonSQS _sqsClient; + private readonly SqsSettings _sqsSettings; + private readonly IRawLayoutRepository _rawLayoutRepository; + private readonly IRawLayoutStatusWriter _rawLayoutStatusWriter; + private readonly ILayoutCompilerClient _compilerClient; + private readonly ILayoutValidationClient _validationClient; + private readonly ICompiledLayoutRepository _compiledLayoutRepository; + private readonly INotificationPublisher _notificationPublisher; + private readonly ILogger<LayoutProcessingOrchestrator> _logger; + + public LayoutProcessingOrchestrator( + IAmazonSQS sqsClient, + IOptions<SqsSettings> sqsSettings, + IRawLayoutRepository rawLayoutRepository, + IRawLayoutStatusWriter rawLayoutStatusWriter, + ILayoutCompilerClient compilerClient, + ILayoutValidationClient validationClient, + ICompiledLayoutRepository compiledLayoutRepository, + INotificationPublisher notificationPublisher, + ILogger<LayoutProcessingOrchestrator> logger) + { + _sqsClient = sqsClient; + _sqsSettings = sqsSettings.Value; + _rawLayoutRepository = rawLayoutRepository; + _rawLayoutStatusWriter = rawLayoutStatusWriter; + _compilerClient = compilerClient; + _validationClient = validationClient; + _compiledLayoutRepository = compiledLayoutRepository; + _notificationPublisher = notificationPublisher; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.SqsPollingStarted(_sqsSettings.QueueUrl); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + ReceiveMessageRequest request = new() + { + QueueUrl = _sqsSettings.QueueUrl, + MaxNumberOfMessages = _sqsSettings.MaxNumberOfMessages, + VisibilityTimeout = _sqsSettings.VisibilityTimeoutSeconds, + WaitTimeSeconds = _sqsSettings.WaitTimeSeconds, + MessageSystemAttributeNames = ["ApproximateReceiveCount"] + }; + + ReceiveMessageResponse response = await _sqsClient + .ReceiveMessageAsync(request, stoppingToken) + .ConfigureAwait(false); + + if (response.Messages.Count == 0) + { + await Task.Delay(_sqsSettings.EmptyQueueDelayMs, stoppingToken).ConfigureAwait(false); + continue; + } + + foreach (Message message in response.Messages) + { + await ProcessMessageAsync(message, stoppingToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Graceful shutdown; exit the loop. + break; + } + catch (Exception ex) + { + _logger.SqsPollingError(ex); + + // Back off briefly before retrying, to avoid a tight error loop. + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false); + } + } + + _logger.SqsPollingStopped(); + } + + private async Task ProcessMessageAsync(Message message, CancellationToken ct) + { + string receiptHandle = message.ReceiptHandle; + + if (!Guid.TryParse(message.Body, out Guid rawLayoutId)) + { + _logger.SqsUnrecognizedMessageWarning(receiptHandle, new FormatException($"Message body is not a valid GUID: '{message.Body}'")); + + // Delete unrecognized messages to avoid them blocking the queue. + await DeleteMessageAsync(receiptHandle, ct).ConfigureAwait(false); + return; + } + + _logger.SqsMessageReceived(rawLayoutId, receiptHandle); + + // Log retry warning after establishing message identity: if ApproximateReceiveCount > 1 + // the message has already been attempted; the next failure will route it to the DLQ. + if (message.Attributes.TryGetValue("ApproximateReceiveCount", out string? receiveCountStr) + && int.TryParse(receiveCountStr, out int receiveCount) + && receiveCount > 1) + { + _logger.SqsMessageRetry(rawLayoutId, receiveCount); + } + + try + { + // Step 1: Fetch raw layout + RawLayout? rawLayout = await _rawLayoutRepository.GetAsync(rawLayoutId, ct).ConfigureAwait(false); + if (rawLayout is null) + { + _logger.RawLayoutNotFound(rawLayoutId); + + // Delete the message — there is no layout to process. + await DeleteMessageAsync(receiptHandle, ct).ConfigureAwait(false); + return; + } + + // Step 2: Compile + CompiledLayout compiledLayout = await _compilerClient + .CompileAsync(rawLayout, ct) + .ConfigureAwait(false); + + _logger.LayoutCompiled(rawLayoutId); + + // Step 3: Validate + ValidationResult validationResult = await _validationClient + .ValidateAsync(compiledLayout, ct) + .ConfigureAwait(false); + + if (!validationResult.IsValid) + { + _logger.LayoutValidationFailed(rawLayoutId, validationResult.Issues.Count); + + // Write validation failure back to the raw layout so the editor can display it. + await _rawLayoutStatusWriter + .UpdateValidationResultAsync(rawLayoutId, validationResult, ct) + .ConfigureAwait(false); + + _logger.ValidationResultWrittenBack(rawLayoutId); + + // Delete the message — failure is recorded on the raw layout; retrying the + // same layout without changes will produce the same result. + await DeleteMessageAsync(receiptHandle, ct).ConfigureAwait(false); + + // Log success only after the message is confirmed deleted. + _logger.SqsMessageProcessedSuccessfully(rawLayoutId); + return; + } + + _logger.LayoutValidationPassed(rawLayoutId); + + // Step 4: Store compiled layout + CompiledLayout savedLayout = await _compiledLayoutRepository + .SaveAsync(compiledLayout, ct) + .ConfigureAwait(false); + + _logger.CompiledLayoutStored(rawLayoutId, savedLayout.Id); + + // Step 5: Notify clients + await _notificationPublisher + .PublishLayoutReadyAsync(rawLayout.UserId, savedLayout.Id, ct) + .ConfigureAwait(false); + + _logger.LayoutReadyPublished(rawLayout.UserId, savedLayout.Id); + + // Delete the message only on full success. + await DeleteMessageAsync(receiptHandle, ct).ConfigureAwait(false); + + // Log success only after the message is confirmed deleted. + _logger.SqsMessageProcessedSuccessfully(rawLayoutId); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Graceful shutdown — do not delete; let visibility timeout expire for retry. + throw; + } + catch (Exception ex) + { + _logger.ErrorProcessingSqsMessage(rawLayoutId, receiptHandle, ex); + + // Do not delete the message. SQS will make it visible again after the + // visibility timeout, up to the max receive count, then route it to the DLQ. + } + } + + private async Task DeleteMessageAsync(string receiptHandle, CancellationToken ct) + { + DeleteMessageRequest deleteRequest = new() + { + QueueUrl = _sqsSettings.QueueUrl, + ReceiptHandle = receiptHandle + }; + + await _sqsClient.DeleteMessageAsync(deleteRequest, ct).ConfigureAwait(false); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/ServiceAccountTokenHandler.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/ServiceAccountTokenHandler.cs new file mode 100644 index 00000000..b99281cc --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/ServiceAccountTokenHandler.cs @@ -0,0 +1,29 @@ +using System.Net.Http.Headers; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; + +/// <summary> +/// DelegatingHandler that attaches a pre-configured bearer token to every outgoing request. +/// Used for service-to-service calls (e.g. LayoutProcessingService → RawLayoutService) in +/// environments where IAM-signed requests or Cognito M2M tokens are not yet wired up. +/// +/// When registered with IHttpClientFactory via AddHttpMessageHandler, the factory manages +/// the inner handler; do not set InnerHandler in the constructor. +/// </summary> +public sealed class ServiceAccountTokenHandler : DelegatingHandler +{ + private readonly string _token; + + public ServiceAccountTokenHandler(string token) + { + _token = token; + } + + protected override Task<HttpResponseMessage> SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + return base.SendAsync(request, cancellationToken); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs new file mode 100644 index 00000000..31d2902c --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs @@ -0,0 +1,77 @@ +using System.Collections.ObjectModel; +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; + +/// <summary> +/// Stub implementation of ILayoutCompilerClient. +/// Returns a hardcoded CompiledLayout derived from the input RawLayout elements +/// (names and labels pass through; no real CSS generation). +/// To be replaced with a real Lambda-backed HTTP client in ADR-171. +/// </summary> +public class StubLayoutCompilerClient : ILayoutCompilerClient +{ + public Task<CompiledLayout> CompileAsync(RawLayout raw, CancellationToken ct) + { + IReadOnlyList<LayoutElementDto> compiledElements = ConvertElements(raw.Elements); + + // This is a special check to simulate a validation failure + // for testing purposes + bool invalid = raw.Name == "Invalid Pipeline Test Layout"; + + CompiledLayout compiled = new( + Id: Guid.NewGuid(), + RawLayoutId: raw.Id, + UserId: raw.UserId, + IsActive: false, + Version: raw.Version, + Elements: compiledElements, + CssDefinitions: invalid ? "INVALID" : string.Empty, // Stub: no real CSS generation until ADR-171 + CompiledAt: DateTimeOffset.UtcNow + ); + + return Task.FromResult(compiled); + } + + public Task<PreviewLayout> CompilePreviewAsync(IReadOnlyList<RawLayoutElementDto> elements, CancellationToken ct) + { + PreviewLayout preview = new( + RawLayoutId: Guid.Empty, + Version: 0, + RenderedHtml: "<div><!-- Stub preview --></div>", + RenderedCss: string.Empty, + CompiledAt: DateTimeOffset.UtcNow, + ValidationResult: new ValidationResult(true, Array.Empty<ValidationIssue>()) + ); + + return Task.FromResult(preview); + } + + private static ReadOnlyCollection<LayoutElementDto> ConvertElements(IReadOnlyList<RawLayoutElementDto> rawElements) + { + List<LayoutElementDto> result = new(rawElements.Count); + + foreach (RawLayoutElementDto element in rawElements) + { + LayoutElementDto compiled = element switch + { + RawCommandDefinitionDto cmd => new CommandDefinitionDto( + Type: cmd.Type, + Name: cmd.Name, + Label: cmd.Label, + Glyph: cmd.Glyph, + SpeakPhrase: cmd.SpeakPhrase, + Reverse: cmd.Reverse, + CssId: cmd.CssId), + RawLayoutGroupDefinitionDto group => new LayoutGroupDefinitionDto( + CssId: group.CssId, + Children: ConvertElements(group.Children)), + _ => throw new InvalidOperationException($"Unknown element type: {element.GetType().Name}") + }; + + result.Add(compiled); + } + + return result.AsReadOnly(); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs new file mode 100644 index 00000000..82fcb6ec --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs @@ -0,0 +1,43 @@ +using AdaptiveRemote.Contracts; +using Microsoft.Extensions.Configuration; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; + +/// <summary> +/// Stub implementation of ILayoutValidationClient. +/// Returns a valid ValidationResult for all inputs by default. +/// When Validation:ForceInvalid is set to true (e.g. in integration tests), returns an +/// invalid result with a single stub issue, enabling end-to-end testing of the failure path. +/// To be replaced with a real Lambda-backed HTTP client in ADR-172. +/// </summary> +public class StubLayoutValidationClient : ILayoutValidationClient +{ + private readonly bool _forceInvalid; + + public StubLayoutValidationClient(IConfiguration configuration) + { + _forceInvalid = configuration.GetValue("Validation:ForceInvalid", defaultValue: false); + } + + public Task<ValidationResult> ValidateAsync(CompiledLayout compiled, CancellationToken ct) + { + // This check allows tests to force an invalid result by using the + // StubLayoutCompilerClient with a special RawLayout name. + if (compiled.CssDefinitions == "INVALID") + { + ValidationResult failure = new( + IsValid: false, + Issues: [new ValidationIssue("STUB_INVALID", "Stub validation forced invalid for testing", null)] + ); + return Task.FromResult(failure); + } + + // Stub: always valid until real validation Lambda is wired in ADR-172 + ValidationResult result = new( + IsValid: true, + Issues: Array.Empty<ValidationIssue>() + ); + + return Task.FromResult(result); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubNotificationPublisher.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubNotificationPublisher.cs new file mode 100644 index 00000000..eb69e92b --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubNotificationPublisher.cs @@ -0,0 +1,22 @@ +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; + +/// <summary> +/// Stub implementation of INotificationPublisher. +/// No-op; real SSE notification wiring deferred to ADR-174 (Task 9). +/// </summary> +public class StubNotificationPublisher : INotificationPublisher +{ + public Task PublishLayoutSavedAsync(string userId, Guid rawLayoutId, CancellationToken ct) + { + // No-op stub; notification wiring deferred to Task 9 + return Task.CompletedTask; + } + + public Task PublishLayoutReadyAsync(string userId, Guid compiledLayoutId, CancellationToken ct) + { + // No-op stub; notification wiring deferred to Task 9 + return Task.CompletedTask; + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.Development.json b/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.Development.json new file mode 100644 index 00000000..bbecec60 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.Development.json @@ -0,0 +1,22 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "Sqs": { + "ServiceUrl": "http://localhost:4566", + "QueueUrl": "http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/LayoutProcessingQueue", + "Region": "us-east-1" + }, + "RawLayoutService": { + "BaseUrl": "http://localhost:8081" + }, + "CompiledLayoutService": { + "BaseUrl": "http://localhost:8080" + }, + "LocalStack": { + "BaseUrl": "http://localhost:4566" + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.json b/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.json new file mode 100644 index 00000000..bdc19468 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.json @@ -0,0 +1,28 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cognito": { + "Authority": "", + "Audience": "" + }, + "Sqs": { + "ServiceUrl": "", + "QueueUrl": "", + "Region": "us-east-1", + "MaxNumberOfMessages": 10, + "VisibilityTimeoutSeconds": 60, + "WaitTimeSeconds": 5, + "EmptyQueueDelayMs": 2000 + }, + "RawLayoutService": { + "BaseUrl": "" + }, + "CompiledLayoutService": { + "BaseUrl": "" + } +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj b/src/AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj index ca5c8ff6..fe0d6a04 100644 --- a/src/AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj +++ b/src/AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj @@ -13,9 +13,11 @@ <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> <PackageReference Include="Scalar.AspNetCore" /> <PackageReference Include="AWSSDK.DynamoDBv2" /> + <PackageReference Include="AWSSDK.SQS" /> </ItemGroup> <ItemGroup> + <ProjectReference Include="..\AdaptiveRemote.Backend.Common\AdaptiveRemote.Backend.Common.csproj" /> <ProjectReference Include="..\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj" /> </ItemGroup> diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Configuration/SqsSettings.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Configuration/SqsSettings.cs new file mode 100644 index 00000000..e0d709b4 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Configuration/SqsSettings.cs @@ -0,0 +1,24 @@ +namespace AdaptiveRemote.Backend.RawLayoutService.Configuration; + +/// <summary> +/// Configuration for AWS SQS connection used to trigger layout processing. +/// Maps to the "Sqs" section in appsettings.json. +/// </summary> +public class SqsSettings +{ + /// <summary> + /// The SQS service URL. For LocalStack: http://localhost:4566 + /// For AWS: leave empty to use default AWS endpoint. + /// </summary> + public string? ServiceUrl { get; set; } + + /// <summary> + /// The URL of the layout processing queue. + /// </summary> + public string QueueUrl { get; set; } = string.Empty; + + /// <summary> + /// AWS region (e.g. "us-east-1"). + /// </summary> + public string Region { get; set; } = "us-east-1"; +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/HealthEndpoints.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/HealthEndpoints.cs index 14f8dcd8..0206068f 100644 --- a/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/HealthEndpoints.cs +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/HealthEndpoints.cs @@ -1,4 +1,4 @@ -using AdaptiveRemote.Backend.RawLayoutService.Logging; +using AdaptiveRemote.Backend.Common.Logging; using AdaptiveRemote.Contracts; using System.Reflection; @@ -16,7 +16,7 @@ public static void MapHealthEndpoints(this IEndpointRouteBuilder app) private static IResult GetHealth(ILogger<Program> logger) { - logger.HealthCheckRequested(); + using IDisposable scope = logger.StartRequestScope("GET", "/health"); HealthResponse response = new( ServiceName: "RawLayoutService", diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/LayoutEndpoints.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/LayoutEndpoints.cs index 3ce3655e..e8f41f13 100644 --- a/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/LayoutEndpoints.cs +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/LayoutEndpoints.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using AdaptiveRemote.Backend.RawLayoutService.Logging; +using AdaptiveRemote.Backend.Common.Logging; using AdaptiveRemote.Contracts; namespace AdaptiveRemote.Backend.RawLayoutService.Endpoints; @@ -35,6 +35,17 @@ public static void MapLayoutEndpoints(this IEndpointRouteBuilder app) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(); + + // NOTE: This endpoint is intended for machine-to-machine calls from LayoutProcessingService. + // RequireAuthorization() enforces that a valid JWT is present, but does not currently + // restrict callers to a service-account identity. A dedicated authorization policy + // (e.g., checking a Cognito M2M client_credentials claim) will be added when Cognito M2M + // token support is implemented in a later task. + app.MapPatch("/layouts/raw/{id:guid}/validation-result", UpdateValidationResult) + .WithName(nameof(UpdateValidationResult)) + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); } private static async Task<IResult> ListRawLayouts( @@ -49,7 +60,7 @@ private static async Task<IResult> ListRawLayouts( return Results.Unauthorized(); } - logger.ListRawLayoutsRequested(userId); + using IDisposable scope = logger.StartRequestScope("GET", "/layouts/raw", userId); try { @@ -79,13 +90,13 @@ private static async Task<IResult> GetRawLayout( return Results.Unauthorized(); } - logger.GetRawLayoutRequested(userId, id); + using IDisposable scope = logger.StartRequestScope("GET", $"/layouts/raw/{id}", userId); try { RawLayout? layout = await repository.GetAsync(id, cancellationToken); - if (layout == null || layout.UserId != userId) + if (layout is null) { return Results.NotFound(); } @@ -116,7 +127,7 @@ private static async Task<IResult> CreateRawLayout( return Results.Unauthorized(); } - logger.CreateRawLayoutRequested(userId); + using IDisposable scope = logger.StartRequestScope("POST", "/layouts/raw", userId); // Validate required fields if (string.IsNullOrWhiteSpace(layout.Name)) @@ -177,7 +188,7 @@ private static async Task<IResult> UpdateRawLayout( return Results.Unauthorized(); } - logger.UpdateRawLayoutRequested(userId, id); + using IDisposable scope = logger.StartRequestScope("PUT", $"/layouts/raw/{id}", userId); try { @@ -233,7 +244,7 @@ private static async Task<IResult> DeleteRawLayout( return Results.Unauthorized(); } - logger.DeleteRawLayoutRequested(userId, id); + using IDisposable scope = logger.StartRequestScope("DELETE", $"/layouts/raw/{id}", userId); try { @@ -256,4 +267,30 @@ private static async Task<IResult> DeleteRawLayout( return Results.Problem("Error deleting raw layout"); } } + + private static async Task<IResult> UpdateValidationResult( + Guid id, + ValidationResult result, + ILogger<Program> logger, + IRawLayoutStatusWriter statusWriter, + CancellationToken cancellationToken) + { + using IDisposable scope = logger.StartRequestScope("PATCH", $"/layouts/raw/{id}/validation-result", null); + + try + { + await statusWriter.UpdateValidationResultAsync(id, result, cancellationToken); + logger.ValidationResultUpdated(id); + return Results.NoContent(); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("not found")) + { + return Results.NotFound(); + } + catch (Exception ex) + { + logger.ErrorUpdatingValidationResult(id, ex); + return Results.Problem("Error updating validation result"); + } + } } diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Logging/MessageLogger.cs deleted file mode 100644 index 93b9a54d..00000000 --- a/src/AdaptiveRemote.Backend.RawLayoutService/Logging/MessageLogger.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace AdaptiveRemote.Backend.RawLayoutService.Logging; - -/// <summary> -/// Centralized logging messages for RawLayoutService. -/// All log messages MUST be defined here as [LoggerMessage] source-generated methods. -/// Event ID ranges: -/// 1200-1299: RawLayoutService -/// </summary> -public static partial class MessageLogger -{ - [LoggerMessage(EventId = 1200, Level = LogLevel.Information, Message = "RawLayoutService starting")] - public static partial void ServiceStarting(this ILogger logger); - - [LoggerMessage(EventId = 1201, Level = LogLevel.Information, Message = "RawLayoutService started successfully on {ListenAddress}")] - public static partial void ServiceStarted(this ILogger logger, string listenAddress); - - [LoggerMessage(EventId = 1202, Level = LogLevel.Information, Message = "GET /layouts/raw request received for userId={UserId}")] - public static partial void ListRawLayoutsRequested(this ILogger logger, string userId); - - [LoggerMessage(EventId = 1203, Level = LogLevel.Information, Message = "GET /layouts/raw/{LayoutId} request received for userId={UserId}")] - public static partial void GetRawLayoutRequested(this ILogger logger, string userId, Guid layoutId); - - [LoggerMessage(EventId = 1204, Level = LogLevel.Information, Message = "POST /layouts/raw request received for userId={UserId}")] - public static partial void CreateRawLayoutRequested(this ILogger logger, string userId); - - [LoggerMessage(EventId = 1205, Level = LogLevel.Information, Message = "PUT /layouts/raw/{LayoutId} request received for userId={UserId}")] - public static partial void UpdateRawLayoutRequested(this ILogger logger, string userId, Guid layoutId); - - [LoggerMessage(EventId = 1206, Level = LogLevel.Information, Message = "DELETE /layouts/raw/{LayoutId} request received for userId={UserId}")] - public static partial void DeleteRawLayoutRequested(this ILogger logger, string userId, Guid layoutId); - - [LoggerMessage(EventId = 1207, Level = LogLevel.Information, Message = "Raw layout created successfully: Id={LayoutId}")] - public static partial void RawLayoutCreated(this ILogger logger, Guid layoutId); - - [LoggerMessage(EventId = 1208, Level = LogLevel.Information, Message = "Raw layout updated successfully: Id={LayoutId}")] - public static partial void RawLayoutUpdated(this ILogger logger, Guid layoutId); - - [LoggerMessage(EventId = 1209, Level = LogLevel.Information, Message = "Raw layout deleted successfully: Id={LayoutId}")] - public static partial void RawLayoutDeleted(this ILogger logger, Guid layoutId); - - [LoggerMessage(EventId = 1210, Level = LogLevel.Information, Message = "GET /health request received")] - public static partial void HealthCheckRequested(this ILogger logger); - - [LoggerMessage(EventId = 1211, Level = LogLevel.Information, Message = "Health check successful")] - public static partial void HealthCheckSuccessful(this ILogger logger); - - [LoggerMessage(EventId = 1212, Level = LogLevel.Error, Message = "Error retrieving raw layouts for userId={UserId}")] - public static partial void ErrorRetrievingRawLayouts(this ILogger logger, string userId, Exception exception); - - [LoggerMessage(EventId = 1213, Level = LogLevel.Error, Message = "Error retrieving raw layout Id={LayoutId} for userId={UserId}")] - public static partial void ErrorRetrievingRawLayout(this ILogger logger, Guid layoutId, string userId, Exception exception); - - [LoggerMessage(EventId = 1214, Level = LogLevel.Error, Message = "Error creating raw layout for userId={UserId}")] - public static partial void ErrorCreatingRawLayout(this ILogger logger, string userId, Exception exception); - - [LoggerMessage(EventId = 1215, Level = LogLevel.Error, Message = "Error updating raw layout Id={LayoutId} for userId={UserId}")] - public static partial void ErrorUpdatingRawLayout(this ILogger logger, Guid layoutId, string userId, Exception exception); - - [LoggerMessage(EventId = 1216, Level = LogLevel.Error, Message = "Error deleting raw layout Id={LayoutId} for userId={UserId}")] - public static partial void ErrorDeletingRawLayout(this ILogger logger, Guid layoutId, string userId, Exception exception); - - [LoggerMessage(EventId = 1217, Level = LogLevel.Error, Message = "Error processing health check request")] - public static partial void ErrorProcessingHealthCheck(this ILogger logger, Exception exception); - - [LoggerMessage( - EventId = 1218, - Level = LogLevel.Error, - Message = "LocalStack dependency check failed at {HealthUrl}: {FailureReason}. LocalStack is required for local development. See docs/local-dev.md for setup instructions")] - public static partial void LocalStackDependencyUnavailable(this ILogger logger, string healthUrl, string failureReason, Exception? exception); -} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Program.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Program.cs index cd4ea6c5..cd3e1fe7 100644 --- a/src/AdaptiveRemote.Backend.RawLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Program.cs @@ -1,20 +1,41 @@ +using System.Text.Json; +using AdaptiveRemote.Backend.Common.Logging; using AdaptiveRemote.Backend.RawLayoutService.Configuration; using AdaptiveRemote.Backend.RawLayoutService.Endpoints; -using AdaptiveRemote.Backend.RawLayoutService.Logging; using AdaptiveRemote.Backend.RawLayoutService.Repositories; using AdaptiveRemote.Backend.RawLayoutService.Services; using AdaptiveRemote.Contracts; using Amazon.DynamoDBv2; +using Amazon.SQS; using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.DataProtection; using Scalar.AspNetCore; -using System.Net.Http; -using System.Text.Json; + +string? logFilePath = null; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i] == "--logFile") + { + logFilePath = args[i + 1]; + break; + } +} WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +if (!string.IsNullOrEmpty(logFilePath)) +{ + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(); + builder.Logging.AddFile(logFilePath); +} + +if (!builder.Environment.IsProduction()) +{ + builder.Services.AddDataProtection() + .UseEphemeralDataProtectionProvider(); +} + // Configure DynamoDB DynamoDbSettings dynamoDbSettings = builder.Configuration .GetSection("DynamoDB") @@ -63,13 +84,69 @@ builder.Services.AddSingleton(dynamoDbClient); +// Configure SQS for the layout processing trigger +SqsSettings sqsSettings = builder.Configuration + .GetSection("Sqs") + .Get<SqsSettings>() ?? new SqsSettings(); + +builder.Services.Configure<SqsSettings>(builder.Configuration.GetSection("Sqs")); + +// Create SQS client +IAmazonSQS sqsClient; + +if (!string.IsNullOrEmpty(sqsSettings.ServiceUrl)) +{ + // LocalStack or custom endpoint + AmazonSQSConfig sqsConfig = new() + { + ServiceURL = sqsSettings.ServiceUrl, + AuthenticationRegion = sqsSettings.Region + }; + + string? sqsAccessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID"); + string? sqsSecretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY"); + + if (!string.IsNullOrEmpty(sqsAccessKey) && !string.IsNullOrEmpty(sqsSecretKey)) + { + sqsClient = new AmazonSQSClient( + new Amazon.Runtime.BasicAWSCredentials(sqsAccessKey, sqsSecretKey), + sqsConfig); + } + else + { + sqsClient = new AmazonSQSClient(sqsConfig); + } +} +else +{ + // Production AWS — use default credential chain + AmazonSQSConfig sqsConfig = new() + { + RegionEndpoint = Amazon.RegionEndpoint.GetBySystemName(sqsSettings.Region) + }; + sqsClient = new AmazonSQSClient(sqsConfig); +} + +builder.Services.AddSingleton(sqsClient); + // Register repositories and services builder.Services.AddSingleton<DynamoDbRawLayoutRepository>(); builder.Services.AddSingleton<IRawLayoutRepository>(sp => sp.GetRequiredService<DynamoDbRawLayoutRepository>()); builder.Services.AddSingleton<IRawLayoutStatusWriter>(sp => sp.GetRequiredService<DynamoDbRawLayoutRepository>()); -// Register stub implementations (to be replaced in later tasks) -builder.Services.AddSingleton<ILayoutProcessingTrigger, StubLayoutProcessingTrigger>(); +// Register the layout processing trigger: use SQS if configured, otherwise fall back to no-op stub. +// SQS wiring requires a QueueUrl; environments without SQS (e.g. integration tests without LocalStack) +// continue using the stub so CRUD endpoints remain functional. +if (!string.IsNullOrEmpty(sqsSettings.QueueUrl)) +{ + builder.Services.AddSingleton<ILayoutProcessingTrigger, SqsLayoutProcessingTrigger>(); +} +else +{ + builder.Services.AddSingleton<ILayoutProcessingTrigger, StubLayoutProcessingTrigger>(); +} + +// Register stub notification publisher (to be replaced in Task 9) builder.Services.AddSingleton<INotificationPublisher, StubNotificationPublisher>(); // Configure JWT Bearer authentication with AWS Cognito @@ -102,10 +179,17 @@ builder.Services.AddAuthorization(); builder.Services.AddOpenApi(); +// Register the source-generated JSON context so minimal-API model binding can +// deserialize request bodies (e.g. RawLayout on POST/PUT) without reflection. +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.TypeInfoResolverChain.Insert(0, LayoutContractsJsonContext.Default); +}); + WebApplication app = builder.Build(); ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>(); -logger.ServiceStarting(); +logger.ServiceStarting("RawLayoutService"); if (app.Environment.IsDevelopment()) { @@ -131,7 +215,7 @@ string listenAddress = app.Configuration["ASPNETCORE_URLS"] ?? app.Configuration["urls"] ?? "http://localhost:5000"; -logger.ServiceStarted(listenAddress); +logger.ServiceStarted("RawLayoutService", listenAddress); app.Run(); diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/Services/SqsLayoutProcessingTrigger.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Services/SqsLayoutProcessingTrigger.cs new file mode 100644 index 00000000..d442d782 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Services/SqsLayoutProcessingTrigger.cs @@ -0,0 +1,51 @@ +using AdaptiveRemote.Backend.Common.Logging; +using AdaptiveRemote.Backend.RawLayoutService.Configuration; +using AdaptiveRemote.Contracts; +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Backend.RawLayoutService.Services; + +/// <summary> +/// SQS-backed implementation of ILayoutProcessingTrigger. +/// Enqueues a message to the layout processing SQS queue when a raw layout is saved. +/// Replaces StubLayoutProcessingTrigger from Task 4. +/// </summary> +public class SqsLayoutProcessingTrigger : ILayoutProcessingTrigger +{ + private readonly IAmazonSQS _sqsClient; + private readonly SqsSettings _settings; + private readonly ILogger<SqsLayoutProcessingTrigger> _logger; + + public SqsLayoutProcessingTrigger( + IAmazonSQS sqsClient, + IOptions<SqsSettings> settings, + ILogger<SqsLayoutProcessingTrigger> logger) + { + _sqsClient = sqsClient; + _settings = settings.Value; + _logger = logger; + } + + public async Task TriggerAsync(Guid rawLayoutId, CancellationToken ct) + { + try + { + SendMessageRequest request = new() + { + QueueUrl = _settings.QueueUrl, + MessageBody = rawLayoutId.ToString() + }; + + await _sqsClient.SendMessageAsync(request, ct).ConfigureAwait(false); + + _logger.SqsTriggerEnqueued(rawLayoutId, _settings.QueueUrl); + } + catch (Exception ex) + { + _logger.ErrorEnqueuingSqsTrigger(rawLayoutId, ex); + throw; + } + } +} diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.Development.json b/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.Development.json index 0c85ab57..8d6742c7 100644 --- a/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.Development.json +++ b/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.Development.json @@ -10,6 +10,11 @@ "TableName": "RawLayouts", "Region": "us-east-1" }, + "Sqs": { + "ServiceUrl": "http://localhost:4566", + "QueueUrl": "http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/LayoutProcessingQueue", + "Region": "us-east-1" + }, "LocalStack": { "BaseUrl": "http://localhost:4566" } diff --git a/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.json b/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.json index cfbc7ddb..26818f5d 100644 --- a/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.json +++ b/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.json @@ -14,5 +14,10 @@ "ServiceUrl": "", "TableName": "RawLayouts", "Region": "us-east-1" + }, + "Sqs": { + "ServiceUrl": "", + "QueueUrl": "", + "Region": "us-east-1" } } diff --git a/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs b/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs index 80cd7f7d..b1032c08 100644 --- a/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs +++ b/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs @@ -9,4 +9,25 @@ public interface ICompiledLayoutRepository /// Gets the active compiled layout for the specified user. /// </summary> Task<CompiledLayout?> GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default); + + /// <summary> + /// Gets all compiled layouts for the specified user. + /// </summary> + Task<IReadOnlyList<CompiledLayout>> ListByUserAsync(string userId, CancellationToken cancellationToken = default); + + /// <summary> + /// Gets a specific compiled layout by ID. + /// </summary> + Task<CompiledLayout?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// <summary> + /// Saves a compiled layout. Creates or replaces the record. + /// </summary> + Task<CompiledLayout> SaveAsync(CompiledLayout layout, CancellationToken cancellationToken = default); + + /// <summary> + /// Sets the specified compiled layout as active for the user, clearing + /// IsActive on all other layouts for the same user. + /// </summary> + Task SetActiveAsync(Guid id, string userId, CancellationToken cancellationToken = default); } diff --git a/src/AdaptiveRemote.Contracts/ILayoutCompilerClient.cs b/src/AdaptiveRemote.Contracts/ILayoutCompilerClient.cs new file mode 100644 index 00000000..43b826c6 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/ILayoutCompilerClient.cs @@ -0,0 +1,11 @@ +namespace AdaptiveRemote.Contracts; + +/// <summary> +/// LayoutCompilerService — stateless; called by LayoutProcessingService to compile a raw layout +/// into a compiled layout, and by RawLayoutService to produce a live preview. +/// </summary> +public interface ILayoutCompilerClient +{ + Task<CompiledLayout> CompileAsync(RawLayout raw, CancellationToken ct); + Task<PreviewLayout> CompilePreviewAsync(IReadOnlyList<RawLayoutElementDto> elements, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.Contracts/ILayoutValidationClient.cs b/src/AdaptiveRemote.Contracts/ILayoutValidationClient.cs new file mode 100644 index 00000000..23b79a0c --- /dev/null +++ b/src/AdaptiveRemote.Contracts/ILayoutValidationClient.cs @@ -0,0 +1,10 @@ +namespace AdaptiveRemote.Contracts; + +/// <summary> +/// LayoutValidationService — stateless; called by LayoutProcessingService to validate a +/// compiled layout before storing it. +/// </summary> +public interface ILayoutValidationClient +{ + Task<ValidationResult> ValidateAsync(CompiledLayout compiled, CancellationToken ct); +} diff --git a/src/_doc_BackendDevelopment.md b/src/_doc_BackendDevelopment.md index e3bb6090..d76d2785 100644 --- a/src/_doc_BackendDevelopment.md +++ b/src/_doc_BackendDevelopment.md @@ -3,6 +3,43 @@ This document defines the standing development pattern for backend services introduced by Task 5 ([ADR-187](https://jodasoft.atlassian.net/browse/ADR-187)). +## Services + +| Service | Port (dev) | Notes | +|---------|------------|-------| +| `AdaptiveRemote.Backend.CompiledLayoutService` | 54433 (HTTPS) / 54434 (HTTP) | Compiled layout storage and retrieval | +| `AdaptiveRemote.Backend.RawLayoutService` | 54435 (HTTPS) / 54436 (HTTP) | Raw layout CRUD; enqueues SQS trigger on save | +| `AdaptiveRemote.Backend.LayoutProcessingService` | 54437 (HTTPS) / 54438 (HTTP) | SQS polling; orchestrates compile → validate → store → notify pipeline | + +### LayoutProcessingService + +`AdaptiveRemote.Backend.LayoutProcessingService` is the orchestration service for the layout +compilation pipeline. It polls an SQS queue (`LayoutProcessingQueue`) for raw layout IDs, +then drives: fetch raw layout → compile → validate → store compiled layout → publish notification. + +**Pipeline steps:** + +1. Dequeue SQS message containing `rawLayoutId` +2. Fetch `RawLayout` from `RawLayoutService` via `IRawLayoutRepository` +3. Compile via `ILayoutCompilerClient` (stub: `StubLayoutCompilerClient`) +4. Validate via `ILayoutValidationClient` (stub: `StubLayoutValidationClient`) +5a. On validation failure: write result back via `IRawLayoutStatusWriter`; delete message +5b. On success: store compiled layout via `ICompiledLayoutRepository`; publish notification via `INotificationPublisher`; delete message +5c. On error: do NOT delete message; SQS retry → DLQ (max receive count = 3; DLQ retention = 14 days) + +**Stub implementations (current task):** + +- `StubLayoutCompilerClient` — derives a `CompiledLayout` from `RawLayout` elements; no real CSS +- `StubLayoutValidationClient` — always returns `IsValid=true`; set `Validation:ForceInvalid=true` to exercise the failure path +- `StubNotificationPublisher` — no-op + +**Service-to-service auth:** When calling `RawLayoutService`, the HTTP clients attach a bearer +token if `RawLayoutService:ServiceAccountToken` is configured. In production this will be +replaced by Cognito M2M or IAM-signed requests. + +**SQS queue config (LocalStack):** provisioned by `docker-compose`; max receive count = 3; +DLQ retention = 14 days; DLQ name = `LayoutProcessingQueue-dlq`. + ## ECS/Fargate-style API services All backend API services must follow this local development pattern: diff --git a/src/_doc_Projects.md b/src/_doc_Projects.md index 97fb460a..77673b8f 100644 --- a/src/_doc_Projects.md +++ b/src/_doc_Projects.md @@ -50,6 +50,16 @@ Backend services live under `src/` alongside client projects. Use `backend.slnf` - **Authentication:** JWT Bearer via AWS Cognito. See [`AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md`](AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md). - **Pattern:** All backend services follow the logging, health endpoint, and structured log patterns established here (see ADR-167/ADR-168). +### AdaptiveRemote.Backend.RawLayoutService +- **Purpose:** Stores and manages raw (user-authored) layout definitions. Accepts layout saves from the client, persists them to DynamoDB, and enqueues a processing request to SQS when a layout is ready for compilation. +- **Authentication:** JWT Bearer via AWS Cognito. +- **Pattern:** Follows the same logging, health endpoint, and structured log patterns as CompiledLayoutService (ADR-167/ADR-168). + +### AdaptiveRemote.Backend.LayoutProcessingService +- **Purpose:** Background processing service that polls an SQS queue for layout compilation requests, runs each raw layout through the compile → validate → store pipeline, and publishes a layout-ready notification on success. +- **Authentication:** Service-to-service (no user-facing endpoints); communicates with RawLayoutService and CompiledLayoutService over HTTP using a service account token. +- **Pattern:** Follows the same logging and health endpoint patterns as the other backend services. Orchestration logic lives in `LayoutProcessingOrchestrator` (a `BackgroundService`). + ## Test Projects ### AdaptiveRemote.App.Tests diff --git a/test/AdaptiveRemote.App.Tests/Services/Broadlink/UdpServiceTests.cs b/test/AdaptiveRemote.App.Tests/Services/Broadlink/UdpServiceTests.cs index 920e650d..40bedc39 100644 --- a/test/AdaptiveRemote.App.Tests/Services/Broadlink/UdpServiceTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/Broadlink/UdpServiceTests.cs @@ -1,6 +1,7 @@ -using System.Net; +using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; +using System.Runtime.CompilerServices; using Microsoft.Extensions.Options; using Moq; @@ -104,12 +105,57 @@ public void UdpService_SendAsync_CreatesSocketSendsPacketAndReturnsResponse() }); } + [TestMethod] + public void UdpService_BroadcastAsync_WhenSocketThrowsAfterCancellation_LogsCancelledInsteadOfUnexpectedError() + { + // Arrange + IUdpService sut = CreateSut(); + + ScanRequestPacket inputPacket = new() + { + RequestTime = new DateTimeOffset(2026, 5, 11, 0, 0, 0, TimeSpan.Zero) + }; + TaskCompletionSource receiveStarted = new(); + CancellationTokenSource cts = new(); + + Expect_SocketFactory_CreateForBroadcast(); + Expect_Socket_LocalEndPoint(IPEndPoint.Parse("10.0.0.2:54321")); + Expect_Socket_SendTo(inputPacket.GetBuffer()); + Expect_Socket_ReceiveFrom_ThrowsAfterCancellation(receiveStarted); + + // Act + Task resultTask = ConsumeAsync(sut.BroadcastAsync(inputPacket, cts.Token), cts.Token); + receiveStarted.Task.Should().BeCompleteWithin(TimeSpan.FromMilliseconds(100), + because: "BroadcastAsync should start waiting for a response"); + cts.Cancel(); + + // Assert + resultTask.Should().BeCanceled(because: "the broadcast should surface cancellation to the caller"); + MockLogger.VerifyMessages(messageLogger => + { + messageLogger.UdpService_Sending(inputPacket.ToString(), inputPacket.Size, IPEndPoint.Parse("255.255.255.255:80")); + messageLogger.UdpService_Sent(inputPacket.ToString()); + messageLogger.UdpService_Cancelled(inputPacket.ToString()); + }); + } + private void Expect_SocketFactory_Create() => MockSocketFactory .Setup(x => x.Create()) .Returns(MockSocket.Object) .Verifiable(Times.Once); + private void Expect_SocketFactory_CreateForBroadcast() + => MockSocketFactory + .Setup(x => x.CreateForBroadcast()) + .Returns(MockSocket.Object) + .Verifiable(Times.Once); + + private void Expect_Socket_LocalEndPoint(EndPoint localEndPoint) + => MockSocket + .SetupGet(x => x.LocalEndPoint) + .Returns(localEndPoint); + private void Expect_Socket_SendTo(ReadOnlyMemory<byte> expectedBytes) => MockSocket .Setup(x => x.SendToAsync(It.IsAny<ReadOnlyMemory<byte>>(), It.IsAny<EndPoint>(), It.IsAny<CancellationToken>())) @@ -138,4 +184,24 @@ private void Expect_Socket_ReadFrom(ReadOnlyMemory<byte> responseBytes, EndPoint }) .WithStandardTaskBehavior(new SocketReceiveFromResult() { ReceivedBytes = responseBytes.Length, RemoteEndPoint = responseEndPoint }) .Verifiable(Times.Once); + + private void Expect_Socket_ReceiveFrom_ThrowsAfterCancellation(TaskCompletionSource receiveStarted) + => MockSocket + .Setup(x => x.ReceiveFromAsync(It.IsAny<Memory<byte>>(), It.IsAny<EndPoint>(), It.IsAny<CancellationToken>())) + .Returns(async (Memory<byte> _, EndPoint _, CancellationToken cancellationToken) => + { + receiveStarted.TrySetResult(); + await cancellationToken.WaitForCancelledAsync(); + throw new ObjectDisposedException("socket"); + }) + .Verifiable(Times.Once); + + private static async Task ConsumeAsync( + IAsyncEnumerable<ScanResponsePacket> responses, + CancellationToken cancellationToken) + { + await foreach (ScanResponsePacket _ in responses.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + } + } } diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs b/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs index da8e1165..c8dfb1b4 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs +++ b/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs @@ -1,168 +1,33 @@ -using AdaptiveRemote.Logging; +using AdaptiveRemote.Logging; using Microsoft.Extensions.Logging; namespace AdaptiveRemote.TestUtilities; -internal class MockLogger<LoggerType1, LoggerType2> : MockLogger<LoggerType1>, ILogger<LoggerType2> +// Adds App-specific VerifyMessages overload that takes an Action<MessageLogger>, +// where MessageLogger is the App's internal source-generated logger wrapper. +// This overload is only usable from App.Tests because MessageLogger is internal to AdaptiveRemote.App. +internal static class MockLoggerAppExtensions { -} - -internal class MockLogger<LoggerType> : ILogger<LoggerType> -{ - private readonly List<string> _messages = new(); - private readonly object _lock = new(); - private Exception? _assertException = null; - - public IEnumerable<string> Messages => _messages; - public TestContext? OutputWriter { get; set; } - - public List<(string find, string replace)> ReplaceStrings = new(); - - IDisposable? ILogger.BeginScope<TState>(TState state) => throw new NotImplementedException(); - bool ILogger.IsEnabled(LogLevel logLevel) => true; - void ILogger.Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) - { - if (exception is AssertFailedException || - exception is AssertInconclusiveException || - exception is Moq.MockException) - { - _assertException = _assertException ?? exception; - return; - } - - string message = $"{logLevel}[{eventId.Id}]: {formatter(state, exception)}"; - foreach ((string find, string replace) in ReplaceStrings) - { - message = message.Replace(find, replace); - } - lock (_lock) - { - _messages.Add(message); - } - OutputWriter?.WriteLine(message); - } - - public void VerifyMessages(Action<MessageLogger> expected) + public static void VerifyMessages<T>(this MockLogger<T> mockLogger, Action<MessageLogger> expected) { - MockLogger<LoggerType> expectedLog = new(); - expectedLog.ReplaceStrings.AddRange(ReplaceStrings); + MockLogger<T> expectedLog = new(); + expectedLog.ReplaceStrings.AddRange(mockLogger.ReplaceStrings); MessageLogger messageLogger = new(expectedLog); expected(messageLogger); - VerifyMessages(expectedLog._messages.ToArray()); + mockLogger.VerifyMessages(expectedLog.Messages.ToArray()); } - public void VerifyMessages(params string[] expected) - { - // Retry a few times, in case messages are still being logged on a background thread - for (int i = 0; i < 10; i++) - { - if (_assertException is not null) - { - throw _assertException; - } - - if (_messages.Count >= expected.Length) - { - break; - } - - Thread.Sleep(i * 5); - } - - IEnumerator<string> expectedIter = expected.AsEnumerable().GetEnumerator(); - List<string>.Enumerator actualIter = _messages.GetEnumerator(); - - int count = 0; - - while (expectedIter.MoveNext()) - { - if (!actualIter.MoveNext()) - { - int expectedCount = count; - List<string> missingMessages = GetRemaining(expectedIter, ref expectedCount); - Assert.AreEqual(expectedCount, count, "Wrong number of messages. Did not find:\n{0}", - string.Join("\n", missingMessages)); - } - - if (!actualIter.Current.StartsWith(expectedIter.Current)) - { - Assert.AreEqual($"\n{expectedIter.Current}", $"\n{actualIter.Current}", "MockLogger.Messages[{0}]", count); - } - - count++; - } + public static Task WaitForMessageAsync<T>(this MockLogger<T> mockLogger, Action<MessageLogger> expected) + => mockLogger.WaitForMessageAsync(expected, TimeSpan.FromSeconds(5)); - if (actualIter.MoveNext()) - { - List<string> unexpectedMessages = GetRemaining(actualIter, ref count); - Assert.AreEqual(expected.Length, count, - "Wrong number of messages. Did not expect to find:\n{0}", - string.Join("\n", unexpectedMessages)); - } - } - - private static List<string> GetRemaining(IEnumerator<string> iter, ref int count) + public static Task WaitForMessageAsync<T>(this MockLogger<T> mockLogger, Action<MessageLogger> expected, TimeSpan timeout) { - List<string> remaining = new(); - - do - { - remaining.Add($"[{count}]: {iter.Current}"); - count++; - } while (iter.MoveNext()); - - return remaining; - } - - internal Task WaitForMessageAsync(Action<MessageLogger> expected) - => WaitForMessageAsync(expected, TimeSpan.FromSeconds(5)); - internal Task WaitForMessageAsync(Action<MessageLogger> expected, TimeSpan timeout) - { - MockLogger<LoggerType> expectedLog = new(); - expectedLog.ReplaceStrings.AddRange(ReplaceStrings); + MockLogger<T> expectedLog = new(); + expectedLog.ReplaceStrings.AddRange(mockLogger.ReplaceStrings); MessageLogger messageLogger = new(expectedLog); expected(messageLogger); Assert.AreEqual(1, expectedLog.Messages.Count(), "Expected exactly one message to wait for"); - return WaitForMessageAsync(expectedLog.Messages.First(), timeout); - } - - internal Task WaitForMessageAsync(string expectedMessage) - => WaitForMessageAsync(expectedMessage, TimeSpan.FromSeconds(5)); - internal async Task WaitForMessageAsync(string expectedMessage, TimeSpan timeout) - { - DateTime startTime = DateTime.Now; - - bool found = false; - while (!found) - { - if (_assertException is not null) - { - throw _assertException; - } - - List<string> messages; - lock (_lock) - { - messages = _messages.ToList(); // Make a copy - } - foreach (string message in messages) - { - if (message.StartsWith(expectedMessage)) - { - found = true; - break; - } - } - - await Task.Delay(100); - - Assert.IsTrue(DateTime.Now - startTime < timeout, "Timed out waiting for log message '{0}'", expectedMessage); - } - } - - internal void ClearMessages() - { - _messages.Clear(); + return mockLogger.WaitForMessageAsync(expectedLog.Messages.First(), timeout); } } diff --git a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj index 62d82992..0aa80567 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj +++ b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj @@ -16,16 +16,20 @@ <PackageReference Include="Reqnroll" /> <PackageReference Include="Reqnroll.MSTest" /> <PackageReference Include="FluentAssertions" /> - <PackageReference Include="System.IdentityModel.Tokens.Jwt" /> - <PackageReference Include="AWSSDK.DynamoDBv2" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\..\src\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj" /> + <ProjectReference Include="..\AdaptiveRemote.EndToEndTests.Steps\AdaptiveRemote.EndToEndTests.Steps.csproj" /> + <ProjectReference Include="..\AdaptiveRemote.TestUtilities\AdaptiveRemote.TestUtilities.csproj" /> </ItemGroup> <ItemGroup> <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" /> </ItemGroup> + <ItemGroup> + <Folder Include="StepDefinitions\" /> + </ItemGroup> + </Project> diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature index 61d24d7b..59015706 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature @@ -2,20 +2,28 @@ Feature: CompiledLayoutService Authentication Scenario: Unauthenticated request is rejected Given CompiledLayoutService is running - When a test client with no Authorization header calls GET /layouts/compiled/active - Then the response is 401 Unauthorized + And the client has no Authorization token + When the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoint + Then the response is 401 Unauthorized + And I should not see any warning or error messages in the CompiledLayoutService logs Scenario: Request with valid JWT is accepted Given CompiledLayoutService is running - When a test client with a valid JWT calls GET /layouts/compiled/active + And the client has a valid Authorization token + When the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoint Then the response is 200 OK + And I should not see any warning or error messages in the CompiledLayoutService logs Scenario: Request with expired JWT is rejected Given CompiledLayoutService is running - When a test client with an expired JWT calls GET /layouts/compiled/active + And the client has an expired Authorization token + When the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoint Then the response is 401 Unauthorized + And I should not see any warning or error messages in the CompiledLayoutService logs Scenario: Health endpoint is accessible without authentication Given CompiledLayoutService is running - When a test client with no Authorization header calls GET /health - Then the response is 200 OK + And the client has no Authorization token + When the client calls GET /health on the CompiledLayoutService endpoint + Then the response is 200 OK + And I should not see any warning or error messages in the CompiledLayoutService logs diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs index 2fc44389..23b40e60 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs @@ -145,10 +145,17 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden #line 5 - await testRunner.WhenAsync("a test client with no Authorization header calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); + await testRunner.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden #line 6 - await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); + await testRunner.WhenAsync("the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoi" + + "nt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 7 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 8 + await testRunner.AndAsync("I should not see any warning or error messages in the CompiledLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -165,7 +172,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Request with valid JWT is accepted", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 8 +#line 10 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -175,14 +182,21 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 9 +#line 11 await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 10 - await testRunner.WhenAsync("a test client with a valid JWT calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 12 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 11 +#line 13 + await testRunner.WhenAsync("the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoi" + + "nt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 14 await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 15 + await testRunner.AndAsync("I should not see any warning or error messages in the CompiledLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -199,7 +213,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Request with expired JWT is rejected", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 13 +#line 17 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -209,14 +223,21 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 14 +#line 18 await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 15 - await testRunner.WhenAsync("a test client with an expired JWT calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 19 + await testRunner.AndAsync("the client has an expired Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 16 +#line 20 + await testRunner.WhenAsync("the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoi" + + "nt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 21 await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 22 + await testRunner.AndAsync("I should not see any warning or error messages in the CompiledLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -233,7 +254,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Health endpoint is accessible without authentication", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 18 +#line 24 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -243,14 +264,20 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 19 +#line 25 await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 20 - await testRunner.WhenAsync("a test client with no Authorization header calls GET /health", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 26 + await testRunner.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 21 - await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line 27 + await testRunner.WhenAsync("the client calls GET /health on the CompiledLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 28 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 29 + await testRunner.AndAsync("I should not see any warning or error messages in the CompiledLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature index 392864ad..ce2f53fc 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature @@ -1,10 +1,31 @@ Feature: CompiledLayoutService Endpoints + Scenario: Get active compiled layout Given CompiledLayoutService is running - When a test client calls GET /layouts/compiled/active + And the client has a valid Authorization token + When the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoint Then the response is 200 OK - And the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext - And the CompiledLayout contains the expected hardcoded commands - And the service logs contain a request log entry for GET /layouts/compiled/active - And the service logs contain no warnings or errors + And the response body is valid JSON + And the response body represents a CompiledLayout + And the CompiledLayout in the response body has a TiVo command named "Up" + And the CompiledLayout in the response body has a TiVo command named "Select" + And the CompiledLayout in the response body has an IR command named "Power" + And the CompiledLayout in the response body has a Lifecycle command named "Learn" + And the CompiledLayout in the response body has a Lifecycle command named "Exit" + And I should see a message that contains "GET /layouts/compiled/active" in the CompiledLayoutService logs + And I should not see any warning or error messages in the CompiledLayoutService logs + +Scenario: Get active compiled layout without authentication + Given CompiledLayoutService is running + And the client has no Authorization token + When the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoint + Then the response is 401 Unauthorized + And I should not see any warning or error messages in the CompiledLayoutService logs + +Scenario: Get active compiled layout with expired token + Given CompiledLayoutService is running + And the client has an expired Authorization token + When the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoint + Then the response is 401 Unauthorized + And I should not see any warning or error messages in the CompiledLayoutService logs diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs index 72246dc2..ec2aec67 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs @@ -117,7 +117,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() { - return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/CompiledLayoutEndpoints.feature.ndjson", 3); + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/CompiledLayoutEndpoints.feature.ndjson", 5); } [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get active compiled layout")] @@ -131,7 +131,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get active compiled layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 3 +#line 4 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -141,26 +141,128 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 4 - await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); -#line hidden #line 5 - await testRunner.WhenAsync("a test client calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden #line 6 - await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden #line 7 - await testRunner.AndAsync("the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); + await testRunner.WhenAsync("the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoi" + + "nt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); #line hidden #line 8 - await testRunner.AndAsync("the CompiledLayout contains the expected hardcoded commands", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden #line 9 - await testRunner.AndAsync("the service logs contain a request log entry for GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); + await testRunner.AndAsync("the response body is valid JSON", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden #line 10 - await testRunner.AndAsync("the service logs contain no warnings or errors", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); + await testRunner.AndAsync("the response body represents a CompiledLayout", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync("the CompiledLayout in the response body has a TiVo command named \"Up\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync("the CompiledLayout in the response body has a TiVo command named \"Select\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync("the CompiledLayout in the response body has an IR command named \"Power\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync("the CompiledLayout in the response body has a Lifecycle command named \"Learn\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.AndAsync("the CompiledLayout in the response body has a Lifecycle command named \"Exit\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.AndAsync("I should see a message that contains \"GET /layouts/compiled/active\" in the Compil" + + "edLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 17 + await testRunner.AndAsync("I should not see any warning or error messages in the CompiledLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get active compiled layout without authentication")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get active compiled layout without authentication")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Endpoints")] + public async global::System.Threading.Tasks.Task GetActiveCompiledLayoutWithoutAuthentication() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get active compiled layout without authentication", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 19 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 20 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 21 + await testRunner.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 22 + await testRunner.WhenAsync("the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoi" + + "nt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 23 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 24 + await testRunner.AndAsync("I should not see any warning or error messages in the CompiledLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get active compiled layout with expired token")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get active compiled layout with expired token")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Endpoints")] + public async global::System.Threading.Tasks.Task GetActiveCompiledLayoutWithExpiredToken() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "2"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get active compiled layout with expired token", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 26 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 27 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 28 + await testRunner.AndAsync("the client has an expired Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 29 + await testRunner.WhenAsync("the client calls GET /layouts/compiled/active on the CompiledLayoutService endpoi" + + "nt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 30 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 31 + await testRunner.AndAsync("I should not see any warning or error messages in the CompiledLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature index 0437ee86..c5e8e2f9 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature @@ -1,7 +1,20 @@ Feature: Health Endpoints + Scenario: Get service health status Given CompiledLayoutService is running - When a test client calls GET /health + When the client calls GET /health on the CompiledLayoutService endpoint + Then the response is 200 OK + And the response body is valid JSON + And the response body represents a HealthResponse + And the HealthResponse in the response body has "serviceName"="CompiledLayoutService" + And the HealthResponse in the response body has "status"="healthy" + And the HealthResponse in the response body has a "version" property + +Scenario: Get service health status with expired token + Given CompiledLayoutService is running + And the client has an expired Authorization token + When the client calls GET /health on the CompiledLayoutService endpoint Then the response is 200 OK - And the body contains the service name and version + And the response body is valid JSON + And the response body represents a HealthResponse diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs index fb6375e0..e19393c0 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs @@ -117,7 +117,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() { - return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/HealthEndpoints.feature.ndjson", 3); + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/HealthEndpoints.feature.ndjson", 4); } [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get service health status")] @@ -131,7 +131,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get service health status", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 3 +#line 4 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -141,17 +141,73 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 4 - await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); -#line hidden #line 5 - await testRunner.WhenAsync("a test client calls GET /health", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden #line 6 - await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); + await testRunner.WhenAsync("the client calls GET /health on the CompiledLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); #line hidden #line 7 - await testRunner.AndAsync("the body contains the service name and version", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 8 + await testRunner.AndAsync("the response body is valid JSON", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 9 + await testRunner.AndAsync("the response body represents a HealthResponse", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync("the HealthResponse in the response body has \"serviceName\"=\"CompiledLayoutService\"" + + "", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync("the HealthResponse in the response body has \"status\"=\"healthy\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync("the HealthResponse in the response body has a \"version\" property", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get service health status with expired token")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get service health status with expired token")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Health Endpoints")] + public async global::System.Threading.Tasks.Task GetServiceHealthStatusWithExpiredToken() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get service health status with expired token", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 14 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 15 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 16 + await testRunner.AndAsync("the client has an expired Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 17 + await testRunner.WhenAsync("the client calls GET /health on the CompiledLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 18 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 19 + await testRunner.AndAsync("the response body is valid JSON", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 20 + await testRunner.AndAsync("the response body represents a HealthResponse", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/LayoutProcessingServiceEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/LayoutProcessingServiceEndpoints.feature new file mode 100644 index 00000000..3f46357e --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/LayoutProcessingServiceEndpoints.feature @@ -0,0 +1,82 @@ +@ApiIntegrationTest +Feature: LayoutProcessingService Endpoints + + +Scenario: Health check returns 200 OK + Given LayoutProcessingService is running + And the client has no Authorization token + When the client calls GET /health on the LayoutProcessingService endpoint + Then the response is 200 OK + And the response body is valid JSON + And the response body represents a HealthResponse + And the HealthResponse in the response body has "serviceName"="LayoutProcessingService" + And the HealthResponse in the response body has "status"="Healthy" + And the HealthResponse in the response body has a "version" property + And I should not see any warning or error messages in the LayoutProcessingService logs + And I should not see any warning or error messages in the RawLayoutService logs + +@PipelineTest +Scenario: End-to-end layout processing success path + Given LayoutProcessingService is running + And the client has a valid Authorization token + When this layout is created via RawLayoutService: + """ + { + "userId": "test-user", + "name": "Pipeline Test Layout", + "elements": [ + { + "$type": "command", + "type": 1, + "name": "Up", + "label": "Up", + "speakPhrase": "up", + "reverse": "Down", + "cssId": "up-btn", + "gridRow": 0, + "gridColumn": 0 + } + ] + } + """ + Then I should see a message that contains "Layout compiled successfully" in the LayoutProcessingService logs + And I should see a message that contains "Layout validation passed" in the LayoutProcessingService logs + And I should see a message that contains "Compiled layout stored" in the LayoutProcessingService logs + And I should see a message that contains "Layout-ready notification published" in the LayoutProcessingService logs + And I should not see any warning or error messages in the LayoutProcessingService logs + And I should not see any warning or error messages in the RawLayoutService logs + +@PipelineTest +Scenario: End-to-end layout processing validation failure path + Given LayoutProcessingService is running + And the client has a valid Authorization token + When this layout is created via RawLayoutService: + # Invalid because it has a special "name" that is considered invalid + # for testing purposes + """ + { + "userId": "test-user", + "name": "Invalid Pipeline Test Layout", + "elements": [ + { + "$type": "command", + "type": 1, + "name": "Up", + "label": "Up", + "speakPhrase": "up", + "reverse": "Down", + "cssId": "up-btn", + "gridRow": 0, + "gridColumn": 0 + } + ] + } + """ + Then I should see a message that contains "Layout compiled successfully" in the LayoutProcessingService logs + And I should see a warning message in the LayoutProcessingService logs: + """ + Layout validation failed + """ + And I should see a message that contains "Validation result written back to raw layout" in the LayoutProcessingService logs + And I should not see any warning or error messages in the LayoutProcessingService logs + And I should not see any warning or error messages in the RawLayoutService logs diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/LayoutProcessingServiceEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/LayoutProcessingServiceEndpoints.feature.cs new file mode 100644 index 00000000..8472fa45 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/LayoutProcessingServiceEndpoints.feature.cs @@ -0,0 +1,334 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace AdaptiveRemote.Backend.ApiTests.Features +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class LayoutProcessingServiceEndpointsFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "ApiIntegrationTest"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "LayoutProcessingService Endpoints", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "LayoutProcessingServiceEndpoints.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute(Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupBehavior.EndOfClass)] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/LayoutProcessingServiceEndpoints.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Health check returns 200 OK")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Health check returns 200 OK")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "LayoutProcessingService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task HealthCheckReturns200OK() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Health check returns 200 OK", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 5 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 6 + await testRunner.GivenAsync("LayoutProcessingService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 7 + await testRunner.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 8 + await testRunner.WhenAsync("the client calls GET /health on the LayoutProcessingService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 9 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 10 + await testRunner.AndAsync("the response body is valid JSON", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync("the response body represents a HealthResponse", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync("the HealthResponse in the response body has \"serviceName\"=\"LayoutProcessingServic" + + "e\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync("the HealthResponse in the response body has \"status\"=\"Healthy\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync("the HealthResponse in the response body has a \"version\" property", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.AndAsync("I should not see any warning or error messages in the LayoutProcessingService log" + + "s", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("End-to-end layout processing success path")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("End-to-end layout processing success path")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "LayoutProcessingService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("PipelineTest")] + public async global::System.Threading.Tasks.Task End_To_EndLayoutProcessingSuccessPath() + { + string[] tagsOfScenario = new string[] { + "PipelineTest"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("End-to-end layout processing success path", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 19 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 20 + await testRunner.GivenAsync("LayoutProcessingService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 21 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 22 + await testRunner.WhenAsync("this layout is created via RawLayoutService:", @"{ + ""userId"": ""test-user"", + ""name"": ""Pipeline Test Layout"", + ""elements"": [ + { + ""$type"": ""command"", + ""type"": 1, + ""name"": ""Up"", + ""label"": ""Up"", + ""speakPhrase"": ""up"", + ""reverse"": ""Down"", + ""cssId"": ""up-btn"", + ""gridRow"": 0, + ""gridColumn"": 0 + } + ] +}", ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 42 + await testRunner.ThenAsync("I should see a message that contains \"Layout compiled successfully\" in the Layout" + + "ProcessingService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 43 + await testRunner.AndAsync("I should see a message that contains \"Layout validation passed\" in the LayoutProc" + + "essingService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 44 + await testRunner.AndAsync("I should see a message that contains \"Compiled layout stored\" in the LayoutProces" + + "singService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 45 + await testRunner.AndAsync("I should see a message that contains \"Layout-ready notification published\" in the" + + " LayoutProcessingService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 46 + await testRunner.AndAsync("I should not see any warning or error messages in the LayoutProcessingService log" + + "s", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 47 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("End-to-end layout processing validation failure path")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("End-to-end layout processing validation failure path")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "LayoutProcessingService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("PipelineTest")] + public async global::System.Threading.Tasks.Task End_To_EndLayoutProcessingValidationFailurePath() + { + string[] tagsOfScenario = new string[] { + "PipelineTest"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "2"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("End-to-end layout processing validation failure path", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 50 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 51 + await testRunner.GivenAsync("LayoutProcessingService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 52 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 53 + await testRunner.WhenAsync("this layout is created via RawLayoutService:", @"{ + ""userId"": ""test-user"", + ""name"": ""Invalid Pipeline Test Layout"", + ""elements"": [ + { + ""$type"": ""command"", + ""type"": 1, + ""name"": ""Up"", + ""label"": ""Up"", + ""speakPhrase"": ""up"", + ""reverse"": ""Down"", + ""cssId"": ""up-btn"", + ""gridRow"": 0, + ""gridColumn"": 0 + } + ] +}", ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 75 + await testRunner.ThenAsync("I should see a message that contains \"Layout compiled successfully\" in the Layout" + + "ProcessingService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 76 + await testRunner.AndAsync("I should see a warning message in the LayoutProcessingService logs:", "Layout validation failed", ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 80 + await testRunner.AndAsync("I should see a message that contains \"Validation result written back to raw layou" + + "t\" in the LayoutProcessingService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 81 + await testRunner.AndAsync("I should not see any warning or error messages in the LayoutProcessingService log" + + "s", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 82 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature index 7971ce52..04e1261a 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature @@ -3,66 +3,282 @@ Feature: RawLayoutService Endpoints Scenario: List raw layouts when user has no layouts Given RawLayoutService is running - When a test client calls GET /layouts/raw + And the client has a valid Authorization token + When the client calls GET /layouts/raw on the RawLayoutService endpoint Then the response is 200 OK - And the body is an empty RawLayout array + And the response body is "[]" + And I should not see any warning or error messages in the RawLayoutService logs + +Scenario: List raw layouts when unauthenticated + Given RawLayoutService is running + And the client has no Authorization token + When the client calls GET /layouts/raw on the RawLayoutService endpoint + Then the response is 401 Unauthorized + And I should not see any warning or error messages in the RawLayoutService logs + +Scenario: List raw layouts with expired token + Given RawLayoutService is running + And the client has an expired Authorization token + When the client calls GET /layouts/raw on the RawLayoutService endpoint + Then the response is 401 Unauthorized + And I should not see any warning or error messages in the RawLayoutService logs Scenario: Create a new raw layout Given RawLayoutService is running - When a test client calls POST /layouts/raw with a valid RawLayout body - Then the response is 201 Created - And the body contains the created RawLayout with a generated Id - And the service logs contain a request log entry for POST /layouts/raw - And the service logs contain no warnings or errors + And the client has a valid Authorization token + When the client calls POST /layouts/raw on the RawLayoutService endpoint with + """ + { + "userId": "test-user", + "name": "New Test Layout", + "elements": [ + { + "$type": "command", + "type": 1, + "name": "Up", + "label": "Up", + "glyph": "↑", + "speakPhrase": "up", + "reverse": "Down", + "cssId": "up-btn", + "gridRow": 0, + "gridColumn": 1 + } + ], + "version": 1, + "createdAt": "2026-05-06T08:30:00Z", + "updatedAt": "2026-05-06T08:30:00Z", + "validationResult": null + } + """ + Then the response is 201 Created + And the response body is valid JSON + And the response body represents a RawLayout + And I should see a message that contains "POST /layouts/raw" in the RawLayoutService logs + And I should not see any warning or error messages in the RawLayoutService logs Scenario: Get raw layout by ID Given RawLayoutService is running - And a raw layout exists with name "Test Layout" - When a test client calls GET /layouts/raw/{id} for the created layout + And the client has a valid Authorization token + And RawLayoutService has a raw layout with the name "Test Layout" + When the client calls GET /layouts/raw/{id} on the RawLayoutService endpoint Then the response is 200 OK - And the body deserializes to the created RawLayout + And the response body is valid JSON + And the response body represents a RawLayout + And the RawLayout in the response body has "name"="Test Layout" + And I should not see any warning or error messages in the RawLayoutService logs + +Scenario: Get raw layout by ID when unauthenticated + Given RawLayoutService is running + And the client has a valid Authorization token + And RawLayoutService has a raw layout with the name "Test Layout" + And the client has no Authorization token + When the client calls GET /layouts/raw/{id} on the RawLayoutService endpoint + Then the response is 401 Unauthorized + And I should not see any warning or error messages in the RawLayoutService logs + +Scenario: Get raw layout by ID with expired token + Given RawLayoutService is running + And the client has a valid Authorization token + And RawLayoutService has a raw layout with the name "Test Layout" + And the client has an expired Authorization token + When the client calls GET /layouts/raw/{id} on the RawLayoutService endpoint + Then the response is 401 Unauthorized + And I should not see any warning or error messages in the RawLayoutService logs Scenario: Update an existing raw layout Given RawLayoutService is running - And a raw layout exists with name "Original Layout" - When a test client calls PUT /layouts/raw/{id} with updated name "Updated Layout" + And the client has a valid Authorization token + And RawLayoutService has a raw layout with the name "Original Layout" + When the client calls PUT /layouts/raw/{id} on the RawLayoutService endpoint with + """ + { + "userId": "test-user", + "name": "Updated Layout", + "elements": [ + { + "$type": "command", + "type": 1, + "name": "Up", + "label": "Up", + "glyph": "↑", + "speakPhrase": "up", + "reverse": "Down", + "cssId": "up-btn", + "gridRow": 0, + "gridColumn": 1 + } + ], + "version": 1, + "createdAt": "2026-05-06T08:30:00Z", + "updatedAt": "2026-05-06T08:30:00Z", + "validationResult": null + } + """ + Then the response is 200 OK + And the response body is valid JSON + And the response body represents a RawLayout + And the RawLayout in the response body has "name"="Updated Layout" + And I should not see any warning or error messages in the RawLayoutService logs + + # Get the updated layout + When the client calls GET /layouts/raw/{id} on the RawLayoutService endpoint Then the response is 200 OK - And the returned layout has name "Updated Layout" - And the layout version is incremented + And the response body represents a RawLayout + And the RawLayout in the response body has "name"="Updated Layout" + And I should not see any warning or error messages in the RawLayoutService logs Scenario: Delete a raw layout Given RawLayoutService is running - And a raw layout exists with name "Layout to Delete" - When a test client calls DELETE /layouts/raw/{id} for the created layout + And the client has a valid Authorization token + And RawLayoutService has a raw layout with the name "Layout to Delete" + When the client calls DELETE /layouts/raw/{id} on the RawLayoutService endpoint Then the response is 204 No Content - And getting the layout by ID returns 404 Not Found + And the response body is "" + + # Verify the layout was deleted + When the client calls GET /layouts/raw/{id} on the RawLayoutService endpoint + Then the response is 404 Not Found + And I should not see any warning or error messages in the RawLayoutService logs + +Scenario: Delete a raw layout when unauthenticated + Given RawLayoutService is running + And the client has a valid Authorization token + And RawLayoutService has a raw layout with the name "Layout to Delete" + And the client has no Authorization token + When the client calls DELETE /layouts/raw/{id} on the RawLayoutService endpoint + Then the response is 401 Unauthorized + And I should not see any warning or error messages in the RawLayoutService logs + +Scenario: Delete a raw layout with expired token + Given RawLayoutService is running + And the client has a valid Authorization token + And RawLayoutService has a raw layout with the name "Layout to Delete" + And the client has an expired Authorization token + When the client calls DELETE /layouts/raw/{id} on the RawLayoutService endpoint + Then the response is 401 Unauthorized + And I should not see any warning or error messages in the RawLayoutService logs Scenario: Access raw layouts without authentication Given RawLayoutService is running - When an unauthenticated client calls GET /layouts/raw + And the client has no Authorization token + When the client calls GET /layouts/raw on the RawLayoutService endpoint Then the response is 401 Unauthorized + And I should not see any warning or error messages in the RawLayoutService logs Scenario: Get non-existent layout by ID Given RawLayoutService is running - When a test client calls GET /layouts/raw/{id} with a random GUID + And the client has a valid Authorization token + When the client calls GET /layouts/raw/{random} on the RawLayoutService endpoint Then the response is 404 Not Found + And I should not see any warning or error messages in the RawLayoutService logs Scenario: Update non-existent layout Given RawLayoutService is running - When a test client calls PUT /layouts/raw/{id} with a random GUID and name "Updated" + And the client has a valid Authorization token + When the client calls PUT /layouts/raw/{random} on the RawLayoutService endpoint with + """ + { + "userId": "test-user", + "name": "Non-existent Layout", + "elements": [ + { + "$type": "command", + "type": 1, + "name": "Up", + "label": "Up", + "glyph": "↑", + "speakPhrase": "up", + "reverse": "Down", + "cssId": "up-btn", + "gridRow": 0, + "gridColumn": 1 + } + ], + "version": 1, + "createdAt": "2026-05-06T08:30:00Z", + "updatedAt": "2026-05-06T08:30:00Z", + "validationResult": null + } + """ Then the response is 404 Not Found + And I should not see any warning or error messages in the RawLayoutService logs Scenario: Delete non-existent layout Given RawLayoutService is running - When a test client calls DELETE /layouts/raw/{id} with a random GUID + And the client has a valid Authorization token + When the client calls DELETE /layouts/raw/{random} on the RawLayoutService endpoint Then the response is 404 Not Found + And I should not see any warning or error messages in the RawLayoutService logs Scenario: Create layout with invalid data Given RawLayoutService is running - When a test client calls POST /layouts/raw with an invalid RawLayout body + And the client has a valid Authorization token + When the client calls POST /layouts/raw on the RawLayoutService endpoint with + # Missing comma between "glyph" and "speakPhrase" fields + """ + { + "userId": "test-user", + "name": "Updated Layout", + "elements": [ + { + "$type": "command", + "type": 1, + "name": "Up", + "label": "Up", + "glyph": "↑" + "speakPhrase": "up", + "reverse": "Down", + "cssId": "up-btn", + "gridRow": 0, + "gridColumn": 1 + } + ], + "version": 1, + "createdAt": "2026-05-06T08:30:00Z", + "updatedAt": "2026-05-06T08:30:00Z", + "validationResult": null + } + """ Then the response is 400 Bad Request + And the response body contains "Expected either ',', '}', or ']'." + And I should see an error message in the RawLayoutService logs: + """ + [Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware] An unhandled exception has occurred while executing the request. + """ Scenario: Create layout with missing required fields Given RawLayoutService is running - When a test client calls POST /layouts/raw with a RawLayout missing required fields - Then the response is 400 Bad Request + And the client has a valid Authorization token + When the client calls POST /layouts/raw on the RawLayoutService endpoint with + # Element missing "$type" field + """ + { + "userId": "test-user", + "name": "Updated Layout", + "elements": [ + { + "type": 1, + "name": "Up", + "label": "Up", + "glyph": "↑", + "speakPhrase": "up", + "reverse": "Down", + "cssId": "up-btn", + "gridRow": 0, + "gridColumn": 1 + } + ], + "version": 1, + "createdAt": "2026-05-06T08:30:00Z", + "updatedAt": "2026-05-06T08:30:00Z", + "validationResult": null + } + """ + # TODO: I think this should be 400 Bad Request, but .NET is the one throwing + Then the response is 500 Internal Server Error + And the response body contains "The JSON payload for polymorphic interface or abstract type 'AdaptiveRemote.Contracts.RawLayoutElementDto' must specify a type discriminator." + And I should see an error message in the RawLayoutService logs: + """ + [Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware] An unhandled exception has occurred while executing the request. + """ diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature.cs index adf35c35..f65a7549 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature.cs @@ -118,7 +118,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() { - return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/RawLayoutEndpoints.feature.ndjson", 13); + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/RawLayoutEndpoints.feature.ndjson", 19); } [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("List raw layouts when user has no layouts")] @@ -147,13 +147,101 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden #line 6 - await testRunner.WhenAsync("a test client calls GET /layouts/raw", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden #line 7 - await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); + await testRunner.WhenAsync("the client calls GET /layouts/raw on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); #line hidden #line 8 - await testRunner.AndAsync("the body is an empty RawLayout array", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 9 + await testRunner.AndAsync("the response body is \"[]\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("List raw layouts when unauthenticated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("List raw layouts when unauthenticated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task ListRawLayoutsWhenUnauthenticated() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("List raw layouts when unauthenticated", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 12 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 13 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 14 + await testRunner.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.WhenAsync("the client calls GET /layouts/raw on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 16 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 17 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("List raw layouts with expired token")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("List raw layouts with expired token")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task ListRawLayoutsWithExpiredToken() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "2"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("List raw layouts with expired token", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 19 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 20 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 21 + await testRunner.AndAsync("the client has an expired Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 22 + await testRunner.WhenAsync("the client calls GET /layouts/raw on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 23 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 24 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -167,11 +255,11 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { string[] tagsOfScenario = ((string[])(null)); global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "1"; + string pickleIndex = "3"; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create a new raw layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 10 +#line 26 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -181,23 +269,51 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 11 +#line 27 await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 12 - await testRunner.WhenAsync("a test client calls POST /layouts/raw with a valid RawLayout body", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 28 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 13 - await testRunner.ThenAsync("the response is 201 Created", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line 29 + await testRunner.WhenAsync("the client calls POST /layouts/raw on the RawLayoutService endpoint with", @"{ + ""userId"": ""test-user"", + ""name"": ""New Test Layout"", + ""elements"": [ + { + ""$type"": ""command"", + ""type"": 1, + ""name"": ""Up"", + ""label"": ""Up"", + ""glyph"": ""↑"", + ""speakPhrase"": ""up"", + ""reverse"": ""Down"", + ""cssId"": ""up-btn"", + ""gridRow"": 0, + ""gridColumn"": 1 + } + ], + ""version"": 1, + ""createdAt"": ""2026-05-06T08:30:00Z"", + ""updatedAt"": ""2026-05-06T08:30:00Z"", + ""validationResult"": null +}", ((global::Reqnroll.Table)(null)), "When "); #line hidden -#line 14 - await testRunner.AndAsync("the body contains the created RawLayout with a generated Id", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line 54 + await testRunner.ThenAsync("the response is 201 Created", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden -#line 15 - await testRunner.AndAsync("the service logs contain a request log entry for POST /layouts/raw", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line 55 + await testRunner.AndAsync("the response body is valid JSON", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 16 - await testRunner.AndAsync("the service logs contain no warnings or errors", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line 56 + await testRunner.AndAsync("the response body represents a RawLayout", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 57 + await testRunner.AndAsync("I should see a message that contains \"POST /layouts/raw\" in the RawLayoutService " + + "logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 58 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -211,11 +327,11 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { string[] tagsOfScenario = ((string[])(null)); global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "2"; + string pickleIndex = "4"; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get raw layout by ID", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 18 +#line 60 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -225,20 +341,126 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 19 +#line 61 await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 20 - await testRunner.AndAsync("a raw layout exists with name \"Test Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line 62 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 21 - await testRunner.WhenAsync("a test client calls GET /layouts/raw/{id} for the created layout", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 63 + await testRunner.AndAsync("RawLayoutService has a raw layout with the name \"Test Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 22 +#line 64 + await testRunner.WhenAsync("the client calls GET /layouts/raw/{id} on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 65 await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden -#line 23 - await testRunner.AndAsync("the body deserializes to the created RawLayout", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line 66 + await testRunner.AndAsync("the response body is valid JSON", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 67 + await testRunner.AndAsync("the response body represents a RawLayout", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 68 + await testRunner.AndAsync("the RawLayout in the response body has \"name\"=\"Test Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 69 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get raw layout by ID when unauthenticated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get raw layout by ID when unauthenticated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task GetRawLayoutByIDWhenUnauthenticated() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "5"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get raw layout by ID when unauthenticated", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 71 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 72 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 73 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 74 + await testRunner.AndAsync("RawLayoutService has a raw layout with the name \"Test Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 75 + await testRunner.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 76 + await testRunner.WhenAsync("the client calls GET /layouts/raw/{id} on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 77 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 78 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get raw layout by ID with expired token")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get raw layout by ID with expired token")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task GetRawLayoutByIDWithExpiredToken() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "6"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get raw layout by ID with expired token", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 80 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 81 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 82 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 83 + await testRunner.AndAsync("RawLayoutService has a raw layout with the name \"Test Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 84 + await testRunner.AndAsync("the client has an expired Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 85 + await testRunner.WhenAsync("the client calls GET /layouts/raw/{id} on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 86 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 87 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -252,11 +474,11 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { string[] tagsOfScenario = ((string[])(null)); global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "3"; + string pickleIndex = "7"; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Update an existing raw layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 25 +#line 89 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -266,23 +488,68 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 26 +#line 90 await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 27 - await testRunner.AndAsync("a raw layout exists with name \"Original Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line 91 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 28 - await testRunner.WhenAsync("a test client calls PUT /layouts/raw/{id} with updated name \"Updated Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 92 + await testRunner.AndAsync("RawLayoutService has a raw layout with the name \"Original Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 29 +#line 93 + await testRunner.WhenAsync("the client calls PUT /layouts/raw/{id} on the RawLayoutService endpoint with", @"{ + ""userId"": ""test-user"", + ""name"": ""Updated Layout"", + ""elements"": [ + { + ""$type"": ""command"", + ""type"": 1, + ""name"": ""Up"", + ""label"": ""Up"", + ""glyph"": ""↑"", + ""speakPhrase"": ""up"", + ""reverse"": ""Down"", + ""cssId"": ""up-btn"", + ""gridRow"": 0, + ""gridColumn"": 1 + } + ], + ""version"": 1, + ""createdAt"": ""2026-05-06T08:30:00Z"", + ""updatedAt"": ""2026-05-06T08:30:00Z"", + ""validationResult"": null +}", ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 118 await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden -#line 30 - await testRunner.AndAsync("the returned layout has name \"Updated Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line 119 + await testRunner.AndAsync("the response body is valid JSON", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 120 + await testRunner.AndAsync("the response body represents a RawLayout", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 31 - await testRunner.AndAsync("the layout version is incremented", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line 121 + await testRunner.AndAsync("the RawLayout in the response body has \"name\"=\"Updated Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 122 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 125 + await testRunner.WhenAsync("the client calls GET /layouts/raw/{id} on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 126 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 127 + await testRunner.AndAsync("the response body represents a RawLayout", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 128 + await testRunner.AndAsync("the RawLayout in the response body has \"name\"=\"Updated Layout\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 129 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -296,11 +563,11 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { string[] tagsOfScenario = ((string[])(null)); global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "4"; + string pickleIndex = "8"; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete a raw layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 33 +#line 131 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -310,20 +577,126 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 34 +#line 132 await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 35 - await testRunner.AndAsync("a raw layout exists with name \"Layout to Delete\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line 133 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 36 - await testRunner.WhenAsync("a test client calls DELETE /layouts/raw/{id} for the created layout", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 134 + await testRunner.AndAsync("RawLayoutService has a raw layout with the name \"Layout to Delete\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 37 +#line 135 + await testRunner.WhenAsync("the client calls DELETE /layouts/raw/{id} on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 136 await testRunner.ThenAsync("the response is 204 No Content", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden -#line 38 - await testRunner.AndAsync("getting the layout by ID returns 404 Not Found", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line 137 + await testRunner.AndAsync("the response body is \"\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 140 + await testRunner.WhenAsync("the client calls GET /layouts/raw/{id} on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 141 + await testRunner.ThenAsync("the response is 404 Not Found", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 142 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Delete a raw layout when unauthenticated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Delete a raw layout when unauthenticated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task DeleteARawLayoutWhenUnauthenticated() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "9"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete a raw layout when unauthenticated", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 144 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 145 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 146 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 147 + await testRunner.AndAsync("RawLayoutService has a raw layout with the name \"Layout to Delete\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 148 + await testRunner.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 149 + await testRunner.WhenAsync("the client calls DELETE /layouts/raw/{id} on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 150 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 151 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Delete a raw layout with expired token")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Delete a raw layout with expired token")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "RawLayoutService Endpoints")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("ApiIntegrationTest")] + public async global::System.Threading.Tasks.Task DeleteARawLayoutWithExpiredToken() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "10"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete a raw layout with expired token", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 153 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 154 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 155 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 156 + await testRunner.AndAsync("RawLayoutService has a raw layout with the name \"Layout to Delete\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 157 + await testRunner.AndAsync("the client has an expired Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 158 + await testRunner.WhenAsync("the client calls DELETE /layouts/raw/{id} on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 159 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 160 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -337,11 +710,11 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { string[] tagsOfScenario = ((string[])(null)); global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "5"; + string pickleIndex = "11"; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Access raw layouts without authentication", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 40 +#line 162 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -351,14 +724,20 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 41 +#line 163 await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 42 - await testRunner.WhenAsync("an unauthenticated client calls GET /layouts/raw", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 164 + await testRunner.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 43 +#line 165 + await testRunner.WhenAsync("the client calls GET /layouts/raw on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 166 await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 167 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -372,11 +751,11 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { string[] tagsOfScenario = ((string[])(null)); global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "6"; + string pickleIndex = "12"; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get non-existent layout by ID", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 45 +#line 169 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -386,14 +765,20 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 46 +#line 170 await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 47 - await testRunner.WhenAsync("a test client calls GET /layouts/raw/{id} with a random GUID", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 171 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 172 + await testRunner.WhenAsync("the client calls GET /layouts/raw/{random} on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); #line hidden -#line 48 +#line 173 await testRunner.ThenAsync("the response is 404 Not Found", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 174 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -407,11 +792,11 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { string[] tagsOfScenario = ((string[])(null)); global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "7"; + string pickleIndex = "13"; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Update non-existent layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 50 +#line 176 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -421,14 +806,41 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 51 +#line 177 await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 52 - await testRunner.WhenAsync("a test client calls PUT /layouts/raw/{id} with a random GUID and name \"Updated\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 178 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 53 +#line 179 + await testRunner.WhenAsync("the client calls PUT /layouts/raw/{random} on the RawLayoutService endpoint with", @"{ + ""userId"": ""test-user"", + ""name"": ""Non-existent Layout"", + ""elements"": [ + { + ""$type"": ""command"", + ""type"": 1, + ""name"": ""Up"", + ""label"": ""Up"", + ""glyph"": ""↑"", + ""speakPhrase"": ""up"", + ""reverse"": ""Down"", + ""cssId"": ""up-btn"", + ""gridRow"": 0, + ""gridColumn"": 1 + } + ], + ""version"": 1, + ""createdAt"": ""2026-05-06T08:30:00Z"", + ""updatedAt"": ""2026-05-06T08:30:00Z"", + ""validationResult"": null +}", ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 204 await testRunner.ThenAsync("the response is 404 Not Found", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 205 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -442,11 +854,11 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { string[] tagsOfScenario = ((string[])(null)); global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "8"; + string pickleIndex = "14"; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete non-existent layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 55 +#line 207 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -456,14 +868,20 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 56 +#line 208 await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 57 - await testRunner.WhenAsync("a test client calls DELETE /layouts/raw/{id} with a random GUID", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 209 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 58 +#line 210 + await testRunner.WhenAsync("the client calls DELETE /layouts/raw/{random} on the RawLayoutService endpoint", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 211 await testRunner.ThenAsync("the response is 404 Not Found", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 212 + await testRunner.AndAsync("I should not see any warning or error messages in the RawLayoutService logs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -477,11 +895,11 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { string[] tagsOfScenario = ((string[])(null)); global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "9"; + string pickleIndex = "15"; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create layout with invalid data", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 60 +#line 214 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -491,14 +909,45 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 61 +#line 215 await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 62 - await testRunner.WhenAsync("a test client calls POST /layouts/raw with an invalid RawLayout body", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 216 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 63 +#line 217 + await testRunner.WhenAsync("the client calls POST /layouts/raw on the RawLayoutService endpoint with", @"{ + ""userId"": ""test-user"", + ""name"": ""Updated Layout"", + ""elements"": [ + { + ""$type"": ""command"", + ""type"": 1, + ""name"": ""Up"", + ""label"": ""Up"", + ""glyph"": ""↑"" + ""speakPhrase"": ""up"", + ""reverse"": ""Down"", + ""cssId"": ""up-btn"", + ""gridRow"": 0, + ""gridColumn"": 1 + } + ], + ""version"": 1, + ""createdAt"": ""2026-05-06T08:30:00Z"", + ""updatedAt"": ""2026-05-06T08:30:00Z"", + ""validationResult"": null +}", ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 243 await testRunner.ThenAsync("the response is 400 Bad Request", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 244 + await testRunner.AndAsync("the response body contains \"Expected either \',\', \'}\', or \']\'.\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 245 + await testRunner.AndAsync("I should see an error message in the RawLayoutService logs:", "[Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware] An unhandled " + + "exception has occurred while executing the request.", ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -512,11 +961,11 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { string[] tagsOfScenario = ((string[])(null)); global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "10"; + string pickleIndex = "16"; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create layout with missing required fields", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 65 +#line 250 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -526,14 +975,46 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); -#line 66 +#line 251 await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); #line hidden -#line 67 - await testRunner.WhenAsync("a test client calls POST /layouts/raw with a RawLayout missing required fields", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line 252 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden -#line 68 - await testRunner.ThenAsync("the response is 400 Bad Request", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line 253 + await testRunner.WhenAsync("the client calls POST /layouts/raw on the RawLayoutService endpoint with", @"{ + ""userId"": ""test-user"", + ""name"": ""Updated Layout"", + ""elements"": [ + { + ""type"": 1, + ""name"": ""Up"", + ""label"": ""Up"", + ""glyph"": ""↑"", + ""speakPhrase"": ""up"", + ""reverse"": ""Down"", + ""cssId"": ""up-btn"", + ""gridRow"": 0, + ""gridColumn"": 1 + } + ], + ""version"": 1, + ""createdAt"": ""2026-05-06T08:30:00Z"", + ""updatedAt"": ""2026-05-06T08:30:00Z"", + ""validationResult"": null +}", ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 279 + await testRunner.ThenAsync("the response is 500 Internal Server Error", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 280 + await testRunner.AndAsync("the response body contains \"The JSON payload for polymorphic interface or abstrac" + + "t type \'AdaptiveRemote.Contracts.RawLayoutElementDto\' must specify a type discri" + + "minator.\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 281 + await testRunner.AndAsync("I should see an error message in the RawLayoutService logs:", "[Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware] An unhandled " + + "exception has occurred while executing the request.", ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); diff --git a/test/AdaptiveRemote.Backend.ApiTests/Hooks/ApiTestHooks.cs b/test/AdaptiveRemote.Backend.ApiTests/Hooks/ApiTestHooks.cs new file mode 100644 index 00000000..4c2889fa --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Hooks/ApiTestHooks.cs @@ -0,0 +1,18 @@ +using AdaptiveRemote.EndtoEndTests.Host; +using Reqnroll; +using Reqnroll.BoDi; + +namespace AdaptiveRemote.Backend.ApiTests.Hooks; + +[Binding] +public static class ApiTestHooks +{ + [BeforeTestRun] + public static void ConfigureHostSettings(IObjectContainer objectContainer) + { + // AdaptiveRemoteHost is not configured for this test project + objectContainer.RegisterInstanceAs(new AdaptiveRemoteHostSettings( + UIService: UIServiceType.BlazorWebView, + ExePath: string.Empty)); + } +} diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs deleted file mode 100644 index 9c9ebfe2..00000000 --- a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Net; -using AdaptiveRemote.Backend.ApiTests.Support; -using FluentAssertions; -using Reqnroll; - -namespace AdaptiveRemote.Backend.ApiTests.StepDefinitions; - -[Binding] -public class AuthenticationSteps : IDisposable -{ - private readonly ServiceContext _context; - - public AuthenticationSteps(ServiceContext context) - { - _context = context; - } - - [When(@"a test client with no Authorization header calls GET (.*)")] - public async Task WhenAnonymousClientCallsGet(string endpoint) - { - using HttpClient client = _context.Fixture.CreateAnonymousHttpClient(); - _context.LastResponse = await client.GetAsync(endpoint); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"a test client with a valid JWT calls GET (.*)")] - public async Task WhenAuthenticatedClientCallsGet(string endpoint) - { - string token = _context.Fixture.CreateToken(); - using HttpClient client = _context.Fixture.CreateBearerHttpClient(token); - _context.LastResponse = await client.GetAsync(endpoint); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"a test client with an expired JWT calls GET (.*)")] - public async Task WhenExpiredJwtClientCallsGet(string endpoint) - { - string token = _context.Fixture.CreateExpiredToken(); - using HttpClient client = _context.Fixture.CreateBearerHttpClient(token); - _context.LastResponse = await client.GetAsync(endpoint); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - public void Dispose() - { - // ServiceContext owns LastResponse and Fixture; nothing to dispose here. - GC.SuppressFinalize(this); - } -} diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs deleted file mode 100644 index 38ee35b1..00000000 --- a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System.Net; -using System.Text.Json; -using AdaptiveRemote.Backend.ApiTests.Support; -using AdaptiveRemote.Contracts; -using FluentAssertions; -using Reqnroll; - -namespace AdaptiveRemote.Backend.ApiTests.StepDefinitions; - -[Binding] -public class CommonSteps : IDisposable -{ - private readonly ServiceContext _context; - - public CommonSteps(ServiceContext context) - { - _context = context; - } - - [Given(@"CompiledLayoutService is running")] - public async Task GivenCompiledLayoutServiceIsRunning() - { - await _context.Fixture.StartServiceAsync(); - } - - [When(@"a test client calls GET (/\S+)")] - public async Task WhenATestClientCallsGet(string endpoint) - { - _context.LastResponse = await _context.Fixture.HttpClient.GetAsync(endpoint); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [Then(@"the response is (\d+) OK")] - public void ThenTheResponseIsOk(int statusCode) - { - _context.LastResponse.Should().NotBeNull(); - ((int)_context.LastResponse!.StatusCode).Should().Be(statusCode); - } - - [Then(@"the response is 404 Not Found")] - public void ThenTheResponseIsNotFound() - { - _context.LastResponse.Should().NotBeNull(); - _context.LastResponse!.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Then(@"the response is 400 Bad Request")] - public void ThenTheResponseIsBadRequest() - { - _context.LastResponse.Should().NotBeNull(); - _context.LastResponse!.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Then(@"the response is 401 Unauthorized")] - public void ThenTheResponseIsUnauthorized() - { - _context.LastResponse.Should().NotBeNull(); - _context.LastResponse!.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Then(@"the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext")] - public void ThenTheBodyDeserializesToValidCompiledLayout() - { - _context.LastResponseBody.Should().NotBeNullOrEmpty(); - - CompiledLayout? layout = JsonSerializer.Deserialize<CompiledLayout>( - _context.LastResponseBody!, - LayoutContractsJsonContext.Default.CompiledLayout); - - layout.Should().NotBeNull(); - layout!.Id.Should().NotBeEmpty(); - layout.Elements.Should().NotBeEmpty(); - } - - [Then(@"the CompiledLayout contains the expected hardcoded commands")] - public void ThenTheCompiledLayoutContainsExpectedCommands() - { - _context.LastResponseBody.Should().NotBeNullOrEmpty(); - - CompiledLayout? layout = JsonSerializer.Deserialize<CompiledLayout>( - _context.LastResponseBody!, - LayoutContractsJsonContext.Default.CompiledLayout); - - layout.Should().NotBeNull(); - - // Verify key commands from StaticCommandGroupProvider exist - List<CommandDefinitionDto> commands = ExtractAllCommands(layout!.Elements); - - commands.Should().Contain(c => c.Name == "Up" && c.Type == CommandType.TiVo); - commands.Should().Contain(c => c.Name == "Select" && c.Type == CommandType.TiVo); - commands.Should().Contain(c => c.Name == "Power" && c.Type == CommandType.IR); - commands.Should().Contain(c => c.Name == "Learn" && c.Type == CommandType.Lifecycle); - commands.Should().Contain(c => c.Name == "Exit" && c.Type == CommandType.Lifecycle); - } - - [Then(@"the service logs contain a request log entry for (?:GET|POST|PUT|DELETE|PATCH) (.*)")] - public void ThenTheServiceLogsContainRequestLogEntry(string endpoint) - { - string logs = _context.Fixture.GetLogs(); - logs.Should().Contain(endpoint); - } - - [Then(@"the service logs contain no warnings or errors")] - public void ThenTheServiceLogsContainNoWarningsOrErrors() - { - string logs = _context.Fixture.GetLogs(); - logs.Should().NotContain("WARNING", "service should not log warnings"); - logs.Should().NotContain("ERROR", "service should not log errors"); - logs.Should().NotContain("Exception", "service should not log exceptions"); - } - - [Then(@"the body contains the service name and version")] - public void ThenTheBodyContainsServiceNameAndVersion() - { - _context.LastResponseBody.Should().NotBeNullOrEmpty(); - - HealthResponse? healthResponse = JsonSerializer.Deserialize<HealthResponse>( - _context.LastResponseBody!, - LayoutContractsJsonContext.Default.HealthResponse); - - healthResponse.Should().NotBeNull(); - healthResponse!.ServiceName.Should().Be("CompiledLayoutService"); - healthResponse.Version.Should().NotBeNullOrEmpty(); - healthResponse.Status.Should().Be("healthy"); - } - - private static List<CommandDefinitionDto> ExtractAllCommands(IReadOnlyList<LayoutElementDto> elements) - { - List<CommandDefinitionDto> commands = new(); - - foreach (LayoutElementDto element in elements) - { - if (element is CommandDefinitionDto command) - { - commands.Add(command); - } - else if (element is LayoutGroupDefinitionDto group) - { - commands.AddRange(ExtractAllCommands(group.Children)); - } - } - - return commands; - } - - public void Dispose() - { - // ServiceContext owns LastResponse and Fixture; nothing to dispose here. - GC.SuppressFinalize(this); - } -} diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/RawLayoutSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/RawLayoutSteps.cs deleted file mode 100644 index 02536c28..00000000 --- a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/RawLayoutSteps.cs +++ /dev/null @@ -1,303 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using AdaptiveRemote.Backend.ApiTests.Support; -using AdaptiveRemote.Contracts; -using FluentAssertions; -using Reqnroll; - -namespace AdaptiveRemote.Backend.ApiTests.StepDefinitions; - -[Binding] -public class RawLayoutSteps -{ - private readonly ServiceContext _context; - private RawLayout? _createdLayout; - - public RawLayoutSteps(ServiceContext context) - { - _context = context; - } - - [Given(@"RawLayoutService is running")] - public async Task GivenRawLayoutServiceIsRunning() - { - await _context.Fixture.StartServiceAsync("AdaptiveRemote.Backend.RawLayoutService"); - } - - [Given(@"a raw layout exists with name ""(.*)""")] - public async Task GivenARawLayoutExistsWithName(string layoutName) - { - // Create a test layout - RawLayout testLayout = new( - Id: Guid.Empty, // Will be generated by the service - UserId: "test-user", // Will be overridden by the service with authenticated user - Name: layoutName, - Elements: new List<RawLayoutElementDto> - { - new RawCommandDefinitionDto( - Type: CommandType.TiVo, - Name: "TestCommand", - Label: "Test", - Glyph: null, - SpeakPhrase: "test command", - Reverse: null, - CssId: "test-cmd", - GridRow: 0, - GridColumn: 0 - ) - }, - Version: 1, - CreatedAt: DateTimeOffset.UtcNow, - UpdatedAt: DateTimeOffset.UtcNow, - ValidationResult: null - ); - - StringContent content = new( - JsonSerializer.Serialize(testLayout, LayoutContractsJsonContext.Default.RawLayout), - Encoding.UTF8, - "application/json"); - - HttpResponseMessage response = await _context.Fixture.HttpClient.PostAsync("/layouts/raw", content); - response.StatusCode.Should().Be(HttpStatusCode.Created); - - string responseBody = await response.Content.ReadAsStringAsync(); - _createdLayout = JsonSerializer.Deserialize<RawLayout>(responseBody, LayoutContractsJsonContext.Default.RawLayout); - _createdLayout.Should().NotBeNull(); - } - - [When(@"a test client calls POST \/layouts\/raw with a valid RawLayout body")] - public async Task WhenATestClientCallsPostWithValidRawLayout() - { - RawLayout testLayout = new( - Id: Guid.Empty, - UserId: "test-user", - Name: "New Test Layout", - Elements: new List<RawLayoutElementDto> - { - new RawCommandDefinitionDto( - Type: CommandType.TiVo, - Name: "Up", - Label: "Up", - Glyph: "↑", - SpeakPhrase: "up", - Reverse: "Down", - CssId: "up-btn", - GridRow: 0, - GridColumn: 1 - ) - }, - Version: 1, - CreatedAt: DateTimeOffset.UtcNow, - UpdatedAt: DateTimeOffset.UtcNow, - ValidationResult: null - ); - - StringContent content = new( - JsonSerializer.Serialize(testLayout, LayoutContractsJsonContext.Default.RawLayout), - Encoding.UTF8, - "application/json"); - - _context.LastResponse = await _context.Fixture.HttpClient.PostAsync("/layouts/raw", content); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"a test client calls GET \/layouts\/raw\/\{id\} for the created layout")] - public async Task WhenATestClientCallsGetForTheCreatedLayout() - { - _createdLayout.Should().NotBeNull(); - _context.LastResponse = await _context.Fixture.HttpClient.GetAsync($"/layouts/raw/{_createdLayout!.Id}"); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"a test client calls PUT \/layouts\/raw\/\{id\} with updated name ""(.*)""")] - public async Task WhenATestClientCallsPutWithUpdatedName(string newName) - { - _createdLayout.Should().NotBeNull(); - - RawLayout updatedLayout = _createdLayout! with { Name = newName }; - - StringContent content = new( - JsonSerializer.Serialize(updatedLayout, LayoutContractsJsonContext.Default.RawLayout), - Encoding.UTF8, - "application/json"); - - _context.LastResponse = await _context.Fixture.HttpClient.PutAsync($"/layouts/raw/{_createdLayout.Id}", content); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"a test client calls DELETE \/layouts\/raw\/\{id\} for the created layout")] - public async Task WhenATestClientCallsDeleteForTheCreatedLayout() - { - _createdLayout.Should().NotBeNull(); - _context.LastResponse = await _context.Fixture.HttpClient.DeleteAsync($"/layouts/raw/{_createdLayout!.Id}"); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"an unauthenticated client calls GET \/layouts\/raw")] - public async Task WhenAnUnauthenticatedClientCallsGet() - { - HttpClient anonymousClient = _context.Fixture.CreateAnonymousHttpClient(); - _context.LastResponse = await anonymousClient.GetAsync("/layouts/raw"); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"a test client calls GET \/layouts\/raw\/\{id\} with a random GUID")] - public async Task WhenATestClientCallsGetWithRandomGuid() - { - Guid randomId = Guid.NewGuid(); - _context.LastResponse = await _context.Fixture.HttpClient.GetAsync($"/layouts/raw/{randomId}"); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"a test client calls PUT \/layouts\/raw\/\{id\} with a random GUID and name ""(.*)""")] - public async Task WhenATestClientCallsPutWithRandomGuid(string name) - { - Guid randomId = Guid.NewGuid(); - RawLayout layout = new( - Id: randomId, - UserId: "test-user", - Name: name, - Elements: Array.Empty<RawLayoutElementDto>(), - Version: 1, - CreatedAt: DateTimeOffset.UtcNow, - UpdatedAt: DateTimeOffset.UtcNow, - ValidationResult: null - ); - - StringContent content = new( - JsonSerializer.Serialize(layout, LayoutContractsJsonContext.Default.RawLayout), - Encoding.UTF8, - "application/json"); - - _context.LastResponse = await _context.Fixture.HttpClient.PutAsync($"/layouts/raw/{randomId}", content); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"a test client calls DELETE \/layouts\/raw\/\{id\} with a random GUID")] - public async Task WhenATestClientCallsDeleteWithRandomGuid() - { - Guid randomId = Guid.NewGuid(); - _context.LastResponse = await _context.Fixture.HttpClient.DeleteAsync($"/layouts/raw/{randomId}"); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"a test client calls POST \/layouts\/raw with an invalid RawLayout body")] - public async Task WhenATestClientCallsPostWithInvalidRawLayout() - { - // Send malformed JSON - StringContent content = new( - "{\"Name\": \"Test\", \"InvalidField\": true}", - Encoding.UTF8, - "application/json"); - - _context.LastResponse = await _context.Fixture.HttpClient.PostAsync("/layouts/raw", content); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [When(@"a test client calls POST \/layouts\/raw with a RawLayout missing required fields")] - public async Task WhenATestClientCallsPostWithMissingFields() - { - // Send JSON with only partial fields (missing Elements, Version, etc.) - StringContent content = new( - "{\"Name\": \"Incomplete Layout\"}", - Encoding.UTF8, - "application/json"); - - _context.LastResponse = await _context.Fixture.HttpClient.PostAsync("/layouts/raw", content); - _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); - } - - [Then(@"the body is an empty RawLayout array")] - public void ThenTheBodyIsAnEmptyRawLayoutArray() - { - _context.LastResponseBody.Should().NotBeNullOrEmpty(); - - IReadOnlyList<RawLayout>? layouts = JsonSerializer.Deserialize<IReadOnlyList<RawLayout>>( - _context.LastResponseBody!, - LayoutContractsJsonContext.Default.IReadOnlyListRawLayout); - - layouts.Should().NotBeNull(); - layouts.Should().BeEmpty(); - } - - [Then(@"the response is 201 Created")] - public void ThenTheResponseIsCreated() - { - _context.LastResponse.Should().NotBeNull(); - _context.LastResponse!.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Then(@"the response is 204 No Content")] - public void ThenTheResponseIsNoContent() - { - _context.LastResponse.Should().NotBeNull(); - _context.LastResponse!.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Then(@"the body contains the created RawLayout with a generated Id")] - public void ThenTheBodyContainsTheCreatedRawLayout() - { - _context.LastResponseBody.Should().NotBeNullOrEmpty(); - - RawLayout? layout = JsonSerializer.Deserialize<RawLayout>( - _context.LastResponseBody!, - LayoutContractsJsonContext.Default.RawLayout); - - layout.Should().NotBeNull(); - layout!.Id.Should().NotBeEmpty(); - layout.Name.Should().Be("New Test Layout"); - layout.Elements.Should().HaveCount(1); - layout.Version.Should().Be(1); - } - - [Then(@"the body deserializes to the created RawLayout")] - public void ThenTheBodyDeserializesToTheCreatedRawLayout() - { - _context.LastResponseBody.Should().NotBeNullOrEmpty(); - - RawLayout? layout = JsonSerializer.Deserialize<RawLayout>( - _context.LastResponseBody!, - LayoutContractsJsonContext.Default.RawLayout); - - layout.Should().NotBeNull(); - layout!.Id.Should().Be(_createdLayout!.Id); - layout.Name.Should().Be(_createdLayout.Name); - } - - [Then(@"the returned layout has name ""(.*)""")] - public void ThenTheReturnedLayoutHasName(string expectedName) - { - _context.LastResponseBody.Should().NotBeNullOrEmpty(); - - RawLayout? layout = JsonSerializer.Deserialize<RawLayout>( - _context.LastResponseBody!, - LayoutContractsJsonContext.Default.RawLayout); - - layout.Should().NotBeNull(); - layout!.Name.Should().Be(expectedName); - } - - [Then(@"the layout version is incremented")] - public void ThenTheLayoutVersionIsIncremented() - { - _context.LastResponseBody.Should().NotBeNullOrEmpty(); - - RawLayout? layout = JsonSerializer.Deserialize<RawLayout>( - _context.LastResponseBody!, - LayoutContractsJsonContext.Default.RawLayout); - - layout.Should().NotBeNull(); - _createdLayout.Should().NotBeNull(); - layout!.Version.Should().Be(_createdLayout!.Version + 1); - } - - [Then(@"getting the layout by ID returns 404 Not Found")] - public async Task ThenGettingTheLayoutByIdReturnsNotFound() - { - _createdLayout.Should().NotBeNull(); - HttpResponseMessage response = await _context.Fixture.HttpClient.GetAsync($"/layouts/raw/{_createdLayout!.Id}"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } -} diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/LocalStackFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/LocalStackFixture.cs deleted file mode 100644 index ba552a30..00000000 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/LocalStackFixture.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System.Diagnostics; -using Amazon.DynamoDBv2; -using Amazon.DynamoDBv2.Model; - -namespace AdaptiveRemote.Backend.ApiTests.Support; - -/// <summary> -/// Manages a LocalStack Docker container for integration testing. -/// Provides a local DynamoDB instance that services can connect to. -/// </summary> -public class LocalStackFixture : IDisposable -{ - private Process? _dockerProcess; - private bool _isStarted; - private bool _ownsContainer; // Track if we created the container - - public string ServiceUrl { get; } = "http://localhost:4566"; - - public string Region { get; } = "us-east-1"; - - /// <summary> - /// Starts LocalStack in a Docker container and waits for it to be ready. - /// </summary> - public async Task StartAsync() - { - if (_isStarted) - { - return; - } - - // Check if LocalStack container is already running - Process checkProcess = new() - { - StartInfo = new ProcessStartInfo - { - FileName = "docker", - Arguments = "ps --filter name=localstack-test --format {{.Names}}", - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - } - }; - - checkProcess.Start(); - string existingContainer = await checkProcess.StandardOutput.ReadToEndAsync(); - await checkProcess.WaitForExitAsync(); - - if (!string.IsNullOrWhiteSpace(existingContainer)) - { - // Container already running, just mark as started (we don't own it) - _isStarted = true; - _ownsContainer = false; - await WaitForLocalStackReadyAsync(); - return; - } - - // Start LocalStack container - ProcessStartInfo startInfo = new() - { - FileName = "docker", - Arguments = "run --rm -d " + - "--name localstack-test " + - "-p 4566:4566 " + - "-e SERVICES=dynamodb " + - "localstack/localstack:3.0", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - _dockerProcess = new Process { StartInfo = startInfo }; - _dockerProcess.Start(); - - string containerId = await _dockerProcess.StandardOutput.ReadToEndAsync(); - await _dockerProcess.WaitForExitAsync(); - - if (_dockerProcess.ExitCode != 0) - { - string error = await _dockerProcess.StandardError.ReadToEndAsync(); - throw new InvalidOperationException($"Failed to start LocalStack: {error}"); - } - - // Wait for LocalStack to be ready - await WaitForLocalStackReadyAsync(); - - _isStarted = true; - _ownsContainer = true; // We created this container - } - - /// <summary> - /// Creates a DynamoDB table in LocalStack for testing. - /// </summary> - public async Task CreateTableAsync(string tableName, CancellationToken cancellationToken = default) - { - if (!_isStarted) - { - throw new InvalidOperationException("LocalStack must be started before creating tables"); - } - - // Use dummy credentials for LocalStack - Amazon.Runtime.BasicAWSCredentials credentials = new("test", "test"); - - AmazonDynamoDBConfig config = new() - { - ServiceURL = ServiceUrl, - // Don't set RegionEndpoint when using ServiceURL - it overrides the custom endpoint - AuthenticationRegion = Region - }; - - using AmazonDynamoDBClient client = new(credentials, config); - - // Check if table already exists - try - { - await client.DescribeTableAsync(tableName, cancellationToken); - // Table exists, no need to create - return; - } - catch (ResourceNotFoundException) - { - // Table doesn't exist, proceed to create - } - - CreateTableRequest request = new() - { - TableName = tableName, - KeySchema = new List<KeySchemaElement> - { - new KeySchemaElement { AttributeName = "UserId", KeyType = KeyType.HASH }, - new KeySchemaElement { AttributeName = "Id", KeyType = KeyType.RANGE } - }, - AttributeDefinitions = new List<AttributeDefinition> - { - new AttributeDefinition { AttributeName = "UserId", AttributeType = ScalarAttributeType.S }, - new AttributeDefinition { AttributeName = "Id", AttributeType = ScalarAttributeType.S } - }, - BillingMode = BillingMode.PAY_PER_REQUEST - }; - - await client.CreateTableAsync(request, cancellationToken); - - // Wait for table to be active - bool isActive = false; - for (int i = 0; i < 30 && !isActive; i++) - { - try - { - DescribeTableResponse response = await client.DescribeTableAsync(tableName, cancellationToken); - isActive = response.Table.TableStatus == TableStatus.ACTIVE; - if (!isActive) - { - await Task.Delay(500, cancellationToken); - } - } - catch (ResourceNotFoundException) - { - await Task.Delay(500, cancellationToken); - } - } - - if (!isActive) - { - throw new InvalidOperationException($"Table {tableName} did not become active within 15 seconds"); - } - } - - private async Task WaitForLocalStackReadyAsync() - { - // Poll LocalStack health endpoint - using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(2) }; - - for (int i = 0; i < 60; i++) - { - try - { - HttpResponseMessage response = await client.GetAsync($"{ServiceUrl}/_localstack/health"); - if (response.IsSuccessStatusCode) - { - // Give it a bit more time to fully initialize DynamoDB - await Task.Delay(2000); - return; - } - } - catch - { - // Ignore exceptions during startup - } - - await Task.Delay(1000); - } - - throw new InvalidOperationException("LocalStack did not become ready within 60 seconds"); - } - - public void Dispose() - { - // Only stop the container if we created it - if (_ownsContainer && _dockerProcess != null) - { - // Stop and remove the container - Process stopProcess = new() - { - StartInfo = new ProcessStartInfo - { - FileName = "docker", - Arguments = "stop localstack-test", - UseShellExecute = false, - CreateNoWindow = true - } - }; - - stopProcess.Start(); - stopProcess.WaitForExit(10000); - stopProcess.Dispose(); - - _dockerProcess.Dispose(); - } - - GC.SuppressFinalize(this); - } -} diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs deleted file mode 100644 index beb4b325..00000000 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace AdaptiveRemote.Backend.ApiTests.Support; - -/// <summary> -/// Reqnroll context-injection container shared across all step definition classes -/// within a single scenario. -/// -/// Holds: -/// - <see cref="Fixture"/>: the running service instance -/// - <see cref="LastResponse"/> / <see cref="LastResponseBody"/>: the most recent -/// HTTP response, set by <c>When</c> steps and read by <c>Then</c> steps. -/// -/// Reqnroll creates one instance per scenario and disposes it at scenario end. -/// </summary> -public class ServiceContext : IDisposable -{ - public ServiceFixture Fixture { get; } = new(); - - public HttpResponseMessage? LastResponse { get; set; } - public string? LastResponseBody { get; set; } - - public void Dispose() - { - LastResponse?.Dispose(); - Fixture.Dispose(); - GC.SuppressFinalize(this); - } -} diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs deleted file mode 100644 index bc7c983a..00000000 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs +++ /dev/null @@ -1,318 +0,0 @@ -using System.Diagnostics; -using System.Net; -using System.Net.Http.Headers; -using System.Net.Sockets; -using System.Text; - -namespace AdaptiveRemote.Backend.ApiTests.Support; - -/// <summary> -/// Manages the lifecycle of backend services for API integration tests. -/// Starts the service process and captures structured log output. -/// -/// A <see cref="TestJwtAuthority"/> is started before the service so that the -/// service can be configured with a real (but local) JWT authority. The -/// <see cref="HttpClient"/> exposed to tests automatically includes a valid -/// bearer token. For authentication-specific tests, use -/// <see cref="CreateToken"/> and <see cref="CreateExpiredToken"/> to build -/// tokens, and send them via <see cref="CreateAnonymousHttpClient"/> or -/// <see cref="CreateBearerHttpClient"/> directly. -/// </summary> -public class ServiceFixture : IDisposable -{ - // LocalStack is shared across all scenarios to avoid repeated slow startups. - // Data isolation is achieved via unique per-scenario user IDs. - private static LocalStackFixture? _sharedLocalStack; - private static readonly SemaphoreSlim _localStackInitLock = new(1, 1); - - private Process? _serviceProcess; - private readonly StringBuilder _logOutput = new(); - private readonly object _logLock = new(); - private TestJwtAuthority? _jwtAuthority; - private string? _startedServiceName; - - // Use a unique user ID per fixture so each scenario operates on isolated data - // even when DynamoDB is shared across test scenarios via the shared LocalStack. - private readonly string _testUserId = $"test-user-{Guid.NewGuid():N}"; - - public string ServiceUrl { get; } - - /// <summary> - /// HttpClient pre-configured with a valid bearer token for the test user. - /// </summary> - public HttpClient HttpClient { get; private set; } = null!; - - public ServiceFixture() - { - ServiceUrl = $"http://localhost:{GetFreePort()}"; - } - - public async Task StartServiceAsync(string serviceName = "AdaptiveRemote.Backend.CompiledLayoutService") - { - if (_serviceProcess != null) - { - if (_startedServiceName != serviceName) - { - throw new InvalidOperationException($"Service fixture already started with {_startedServiceName}, cannot start {serviceName}"); - } - return; // Already started - } - - _startedServiceName = serviceName; - - // Start LocalStack if this is the RawLayoutService (which needs DynamoDB). - // A single LocalStack instance is shared across all scenarios. - LocalStackFixture? localStack = null; - if (serviceName == "AdaptiveRemote.Backend.RawLayoutService") - { - localStack = await GetSharedLocalStackAsync(); - await localStack.CreateTableAsync("RawLayouts"); - } - - // Start the JWT authority first so its URL is available for service configuration. - _jwtAuthority = new TestJwtAuthority(); - - // Find the repository root by looking for the .git directory - string currentDir = Directory.GetCurrentDirectory(); - string? repoRoot = currentDir; - while (repoRoot != null && !Directory.Exists(Path.Combine(repoRoot, ".git"))) - { - repoRoot = Directory.GetParent(repoRoot)?.FullName; - } - - if (repoRoot == null) - { - throw new InvalidOperationException("Could not find repository root (no .git directory found)"); - } - - string projectPath = Path.Combine( - repoRoot, - "src", serviceName, - $"{serviceName}.csproj"); - - if (!File.Exists(projectPath)) - { - throw new InvalidOperationException($"Project file not found at: {projectPath}"); - } - - ProcessStartInfo startInfo = new() - { - FileName = "dotnet", - // --no-launch-profile prevents launchSettings.json from overriding - // ASPNETCORE_URLS with its applicationUrl setting. - Arguments = $"run --project \"{projectPath}\" --no-build --no-launch-profile", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - Environment = - { - ["ASPNETCORE_ENVIRONMENT"] = "Development", - ["ASPNETCORE_URLS"] = ServiceUrl, - // Point the service at the local test JWT authority. - ["Cognito__Authority"] = _jwtAuthority.Authority, - // Use the same local test authority host for LocalStack health checks. - ["LocalStack__BaseUrl"] = _jwtAuthority.Authority, - } - }; - - // Configure DynamoDB for RawLayoutService - if (localStack != null) - { - startInfo.Environment["DynamoDB__ServiceUrl"] = localStack.ServiceUrl; - startInfo.Environment["DynamoDB__Region"] = localStack.Region; - startInfo.Environment["DynamoDB__TableName"] = "RawLayouts"; - // Provide dummy AWS credentials for LocalStack - startInfo.Environment["AWS_ACCESS_KEY_ID"] = "test"; - startInfo.Environment["AWS_SECRET_ACCESS_KEY"] = "test"; - } - - _serviceProcess = new Process { StartInfo = startInfo }; - - _serviceProcess.OutputDataReceived += (sender, args) => - { - if (args.Data != null) - { - lock (_logLock) - { - _logOutput.AppendLine(args.Data); - } - } - }; - - _serviceProcess.ErrorDataReceived += (sender, args) => - { - if (args.Data != null) - { - lock (_logLock) - { - _logOutput.AppendLine($"ERROR: {args.Data}"); - } - } - }; - - _serviceProcess.Start(); - _serviceProcess.BeginOutputReadLine(); - _serviceProcess.BeginErrorReadLine(); - - // Poll /health with a temporary unauthenticated client (/health is open). - // Use a short per-request timeout so a slow/stuck response doesn't block the loop. - using HttpClient healthClient = new() - { - BaseAddress = new Uri(ServiceUrl), - Timeout = TimeSpan.FromSeconds(5), - }; - - bool isReady = false; - for (int i = 0; i < 30 && !_serviceProcess.HasExited; i++) - { - try - { - HttpResponseMessage response = await healthClient - .GetAsync("/health") - .ConfigureAwait(false); - if (response.IsSuccessStatusCode) - { - isReady = true; - break; - } - - lock (_logLock) - { - _logOutput.AppendLine($"[HealthCheck attempt {i + 1}] HTTP {(int)response.StatusCode} from {ServiceUrl}/health"); - } - } - catch (Exception ex) - { - lock (_logLock) - { - _logOutput.AppendLine($"[HealthCheck attempt {i + 1}] Request failed polling {ServiceUrl}/health: {ex.Message}"); - } - } - - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!isReady) - { - string logs = GetLogs(); - throw new InvalidOperationException($"Service failed to start within 30 seconds (polling {ServiceUrl}/health). Logs:\n{logs}"); - } - - // Default HttpClient includes a valid bearer token for the scenario-unique test user. - HttpClient = CreateBearerHttpClient(CreateToken()); - } - - /// <summary> - /// Creates a valid JWT for the given subject. Defaults to the scenario-unique test user - /// to ensure each scenario operates on isolated DynamoDB data. - /// </summary> - public string CreateToken(string? sub = null) - { - if (_jwtAuthority is null) - { - throw new InvalidOperationException("StartServiceAsync() must be called before CreateToken()"); - } - - return _jwtAuthority.CreateToken(sub ?? _testUserId); - } - - /// <summary> - /// Creates an expired JWT. - /// </summary> - public string CreateExpiredToken() - { - if (_jwtAuthority is null) - { - throw new InvalidOperationException("StartServiceAsync() must be called before CreateExpiredToken()"); - } - - return _jwtAuthority.CreateExpiredToken(); - } - - /// <summary> - /// Creates an HttpClient with no Authorization header (for testing 401 responses). - /// </summary> - public HttpClient CreateAnonymousHttpClient() - => new() { BaseAddress = new Uri(ServiceUrl) }; - - /// <summary> - /// Creates an HttpClient that sends the given bearer token on every request. - /// </summary> - public HttpClient CreateBearerHttpClient(string token) - => new(new BearerTokenHandler(token)) { BaseAddress = new Uri(ServiceUrl) }; - - public string GetLogs() - { - lock (_logLock) - { - return _logOutput.ToString(); - } - } - - public void Dispose() - { - if (_serviceProcess != null && !_serviceProcess.HasExited) - { - _serviceProcess.Kill(entireProcessTree: true); - _serviceProcess.WaitForExit(5000); - _serviceProcess.Dispose(); - } - - HttpClient?.Dispose(); - _jwtAuthority?.Dispose(); - // LocalStack is shared across all scenarios; do not dispose it here. - GC.SuppressFinalize(this); - } - - private static int GetFreePort() - { - using TcpListener listener = new(IPAddress.Loopback, 0); - listener.Start(); - int port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return port; - } - - private static async Task<LocalStackFixture> GetSharedLocalStackAsync() - { - await _localStackInitLock.WaitAsync().ConfigureAwait(false); - try - { - if (_sharedLocalStack == null) - { - LocalStackFixture localStack = new(); - await localStack.StartAsync().ConfigureAwait(false); - _sharedLocalStack = localStack; - } - - return _sharedLocalStack; - } - finally - { - _localStackInitLock.Release(); - } - } - - /// <summary> - /// Adds a bearer token to every outgoing request. - /// </summary> - private sealed class BearerTokenHandler : DelegatingHandler - { - private readonly string _token; - - public BearerTokenHandler(string token) - : base(new HttpClientHandler()) - { - _token = token; - } - - protected override Task<HttpResponseMessage> SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); - return base.SendAsync(request, cancellationToken); - } - } -} diff --git a/test/AdaptiveRemote.Backend.ApiTests/reqnroll.json b/test/AdaptiveRemote.Backend.ApiTests/reqnroll.json new file mode 100644 index 00000000..51755252 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/reqnroll.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schemas.reqnroll.net/reqnroll-config-latest.json", + + "bindingAssemblies": [ + { + "assembly": "AdaptiveRemote.EndToEndTests.Steps" + } + ] +} \ No newline at end of file diff --git a/test/AdaptiveRemote.Backend.LayoutProcessingService.Tests/AdaptiveRemote.Backend.LayoutProcessingService.Tests.csproj b/test/AdaptiveRemote.Backend.LayoutProcessingService.Tests/AdaptiveRemote.Backend.LayoutProcessingService.Tests.csproj new file mode 100644 index 00000000..26c51f8d --- /dev/null +++ b/test/AdaptiveRemote.Backend.LayoutProcessingService.Tests/AdaptiveRemote.Backend.LayoutProcessingService.Tests.csproj @@ -0,0 +1,33 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>AdaptiveRemote.Backend.LayoutProcessingService.Tests</RootNamespace> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="FluentAssertions" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest.TestAdapter" /> + <PackageReference Include="MSTest.TestFramework" /> + <PackageReference Include="AWSSDK.SQS" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\src\AdaptiveRemote.Backend.LayoutProcessingService\AdaptiveRemote.Backend.LayoutProcessingService.csproj" /> + <ProjectReference Include="..\..\src\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj" /> + <ProjectReference Include="..\AdaptiveRemote.TestUtilities\AdaptiveRemote.TestUtilities.csproj" /> + </ItemGroup> + + <ItemGroup> + <Using Include="AdaptiveRemote.TestUtilities" /> + <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" /> + </ItemGroup> + +</Project> diff --git a/test/AdaptiveRemote.Backend.LayoutProcessingService.Tests/Services/LayoutProcessingOrchestratorTests.cs b/test/AdaptiveRemote.Backend.LayoutProcessingService.Tests/Services/LayoutProcessingOrchestratorTests.cs new file mode 100644 index 00000000..4fbcae79 --- /dev/null +++ b/test/AdaptiveRemote.Backend.LayoutProcessingService.Tests/Services/LayoutProcessingOrchestratorTests.cs @@ -0,0 +1,596 @@ +using AdaptiveRemote.Backend.LayoutProcessingService.Configuration; +using AdaptiveRemote.Backend.LayoutProcessingService.Services; +using AdaptiveRemote.Contracts; +using Amazon.SQS; +using Amazon.SQS.Model; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Tests.Services; + +/// <summary> +/// Unit tests for LayoutProcessingOrchestrator covering: +/// - Success path: compile, validate, store, notify, delete +/// - Validation failure path: write-back, no store, no notify, delete +/// - Processing error path: no delete (SQS retry) +/// </summary> +[TestClass] +public class LayoutProcessingOrchestratorTests +{ + private Mock<IAmazonSQS> _mockSqs = null!; + private Mock<IRawLayoutRepository> _mockRawLayoutRepository = null!; + private Mock<IRawLayoutStatusWriter> _mockRawLayoutStatusWriter = null!; + private Mock<ILayoutCompilerClient> _mockCompilerClient = null!; + private Mock<ILayoutValidationClient> _mockValidationClient = null!; + private Mock<ICompiledLayoutRepository> _mockCompiledLayoutRepository = null!; + private Mock<INotificationPublisher> _mockNotificationPublisher = null!; + private MockLogger<LayoutProcessingOrchestrator> _mockLogger = null!; + + private static readonly string TestQueueUrl = "http://localhost:4566/000000000000/TestQueue"; + private static readonly string TestUserId = "user-123"; + private static readonly Guid TestRawLayoutId = Guid.NewGuid(); + + [TestInitialize] + public void Setup() + { + _mockSqs = new Mock<IAmazonSQS>(); + _mockRawLayoutRepository = new Mock<IRawLayoutRepository>(); + _mockRawLayoutStatusWriter = new Mock<IRawLayoutStatusWriter>(); + _mockCompilerClient = new Mock<ILayoutCompilerClient>(); + _mockValidationClient = new Mock<ILayoutValidationClient>(); + _mockCompiledLayoutRepository = new Mock<ICompiledLayoutRepository>(); + _mockNotificationPublisher = new Mock<INotificationPublisher>(); + _mockLogger = new MockLogger<LayoutProcessingOrchestrator>(); + } + + [TestCleanup] + public void Cleanup() + { + // VerifyNoOtherCalls is not applied to _mockSqs because ReceiveMessageAsync is called + // on every poll iteration and the exact call count is non-deterministic in tests that + // use a semaphore to signal completion. DeleteMessageAsync and other SQS calls are + // asserted explicitly in each test. + _mockRawLayoutRepository.VerifyNoOtherCalls(); + _mockRawLayoutStatusWriter.VerifyNoOtherCalls(); + _mockCompilerClient.VerifyNoOtherCalls(); + _mockValidationClient.VerifyNoOtherCalls(); + _mockCompiledLayoutRepository.VerifyNoOtherCalls(); + _mockNotificationPublisher.VerifyNoOtherCalls(); + } + + // ─── Success path ────────────────────────────────────────────────────────────── + + [TestMethod] + public async Task LayoutProcessingOrchestrator_ProcessMessageAsync_SuccessPath_CompilesValidatesStoresAndNotifies() + { + // Arrange + RawLayout rawLayout = CreateTestRawLayout(); + CompiledLayout compiledLayout = CreateTestCompiledLayout(rawLayout.Id); + ValidationResult validResult = new(true, Array.Empty<ValidationIssue>()); + CompiledLayout savedLayout = compiledLayout with { Id = Guid.NewGuid() }; + + // First receive returns one message; second receive (after processing) returns empty to allow cancellation + SemaphoreSlim messageProcessed = new(0, 1); + int receiveCount = 0; + + _mockSqs + .Setup(s => s.ReceiveMessageAsync(It.IsAny<ReceiveMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + if (receiveCount++ == 0) + { + return CreateReceiveMessageResponse(TestRawLayoutId); + } + + // Block until cancellation to allow the test to verify + messageProcessed.Release(); + return new ReceiveMessageResponse { Messages = [] }; + }); + + _mockRawLayoutRepository + .Setup(r => r.GetAsync(TestRawLayoutId, It.IsAny<CancellationToken>())) + .ReturnsAsync(rawLayout); + + _mockCompilerClient + .Setup(c => c.CompileAsync(It.IsAny<RawLayout>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(compiledLayout); + + _mockValidationClient + .Setup(v => v.ValidateAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(validResult); + + _mockCompiledLayoutRepository + .Setup(r => r.SaveAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(savedLayout); + + _mockNotificationPublisher + .Setup(p => p.PublishLayoutReadyAsync(It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + _mockSqs + .Setup(s => s.DeleteMessageAsync(It.IsAny<DeleteMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new DeleteMessageResponse()); + + // Act + LayoutProcessingOrchestrator orchestrator = CreateOrchestrator(); + CancellationTokenSource cts = new(); + await orchestrator.StartAsync(cts.Token); + + // Wait for the message to be processed (second receive poll signals this) + bool processed = await messageProcessed.WaitAsync(TimeSpan.FromSeconds(10)); + processed.Should().BeTrue(because: "message should be processed within 10 seconds"); + + await cts.CancelAsync(); + await orchestrator.StopAsync(CancellationToken.None); + + // Assert + _mockRawLayoutRepository.Verify(r => r.GetAsync(TestRawLayoutId, It.IsAny<CancellationToken>()), Times.Once); + _mockCompilerClient.Verify(c => c.CompileAsync(It.IsAny<RawLayout>(), It.IsAny<CancellationToken>()), Times.Once); + _mockValidationClient.Verify(v => v.ValidateAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), Times.Once); + _mockCompiledLayoutRepository.Verify(r => r.SaveAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), Times.Once); + _mockNotificationPublisher.Verify(p => p.PublishLayoutReadyAsync(It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Once); + _mockSqs.Verify(s => s.DeleteMessageAsync(It.IsAny<DeleteMessageRequest>(), It.IsAny<CancellationToken>()), Times.Once); + + // Verify key log messages for the success pipeline were emitted. + // + // NOTE: MockLogger.VerifyMessages(params string[]) enforces ordered, exact-count matching, + // which is not appropriate here. This is a BackgroundService test: the polling loop emits + // infrastructure messages (SqsPollingStarted, SqsPollingStopped, possibly SqsPollingError) + // at non-deterministic positions relative to pipeline messages, and the exact total message + // count varies with cancellation timing. Containment-based assertions are the correct + // approach for background-service log verification. + _mockLogger.Messages.Should().Contain(m => m.StartsWith($"Information[1706]: SQS polling loop started; queue={TestQueueUrl}"), "polling should start"); + _mockLogger.Messages.Should().Contain(m => m.StartsWith($"Information[1708]: SQS message received; rawLayoutId={TestRawLayoutId}"), "message identity should be logged first"); + _mockLogger.Messages.Should().Contain(m => m.StartsWith($"Information[1709]: Layout compiled successfully; rawLayoutId={TestRawLayoutId}"), "compile should be logged"); + _mockLogger.Messages.Should().Contain(m => m.StartsWith($"Information[1710]: Layout validation passed; rawLayoutId={TestRawLayoutId}"), "validation pass should be logged"); + _mockLogger.Messages.Should().Contain(m => m.StartsWith($"Information[1712]: Compiled layout stored; rawLayoutId={TestRawLayoutId}"), "store should be logged"); + _mockLogger.Messages.Should().Contain(m => m.StartsWith($"Information[1713]: Layout-ready notification published; userId={TestUserId}"), "notify should be logged"); + _mockLogger.Messages.Should().Contain(m => m.StartsWith($"Information[1714]: SQS message processed successfully; rawLayoutId={TestRawLayoutId}"), "success should be logged"); + } + + [TestMethod] + public async Task LayoutProcessingOrchestrator_ProcessMessageAsync_SuccessPath_DoesNotCallStatusWriter() + { + // Arrange + RawLayout rawLayout = CreateTestRawLayout(); + CompiledLayout compiledLayout = CreateTestCompiledLayout(rawLayout.Id); + ValidationResult validResult = new(true, Array.Empty<ValidationIssue>()); + CompiledLayout savedLayout = compiledLayout with { Id = Guid.NewGuid() }; + + SemaphoreSlim messageProcessed = new(0, 1); + int receiveCount = 0; + + _mockSqs + .Setup(s => s.ReceiveMessageAsync(It.IsAny<ReceiveMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + if (receiveCount++ == 0) + { + return CreateReceiveMessageResponse(TestRawLayoutId); + } + + messageProcessed.Release(); + return new ReceiveMessageResponse { Messages = [] }; + }); + + _mockRawLayoutRepository + .Setup(r => r.GetAsync(TestRawLayoutId, It.IsAny<CancellationToken>())) + .ReturnsAsync(rawLayout); + _mockCompilerClient + .Setup(c => c.CompileAsync(It.IsAny<RawLayout>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(compiledLayout); + _mockValidationClient + .Setup(v => v.ValidateAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(validResult); + _mockCompiledLayoutRepository + .Setup(r => r.SaveAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(savedLayout); + _mockNotificationPublisher + .Setup(p => p.PublishLayoutReadyAsync(It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + _mockSqs + .Setup(s => s.DeleteMessageAsync(It.IsAny<DeleteMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new DeleteMessageResponse()); + + // Act + LayoutProcessingOrchestrator orchestrator = CreateOrchestrator(); + CancellationTokenSource cts = new(); + await orchestrator.StartAsync(cts.Token); + + bool processed = await messageProcessed.WaitAsync(TimeSpan.FromSeconds(10)); + processed.Should().BeTrue(); + + await cts.CancelAsync(); + await orchestrator.StopAsync(CancellationToken.None); + + // Assert — IRawLayoutStatusWriter must NOT be called on success + _mockRawLayoutRepository.Verify(r => r.GetAsync(TestRawLayoutId, It.IsAny<CancellationToken>()), Times.Once); + _mockCompilerClient.Verify(c => c.CompileAsync(It.IsAny<RawLayout>(), It.IsAny<CancellationToken>()), Times.Once); + _mockValidationClient.Verify(v => v.ValidateAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), Times.Once); + _mockCompiledLayoutRepository.Verify(r => r.SaveAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), Times.Once); + _mockNotificationPublisher.Verify(p => p.PublishLayoutReadyAsync(It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Once); + _mockRawLayoutStatusWriter.Verify( + w => w.UpdateValidationResultAsync(It.IsAny<Guid>(), It.IsAny<ValidationResult>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + // ─── Validation failure path ──────────────────────────────────────────────────── + + [TestMethod] + public async Task LayoutProcessingOrchestrator_ProcessMessageAsync_ValidationFailure_WritesBackAndDeletesMessage() + { + // Arrange + RawLayout rawLayout = CreateTestRawLayout(); + CompiledLayout compiledLayout = CreateTestCompiledLayout(rawLayout.Id); + ValidationResult invalidResult = new( + IsValid: false, + Issues: new[] { new ValidationIssue("ERR001", "Label is empty", "/elements/0/label") } + ); + + SemaphoreSlim messageProcessed = new(0, 1); + int receiveCount = 0; + + _mockSqs + .Setup(s => s.ReceiveMessageAsync(It.IsAny<ReceiveMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + if (receiveCount++ == 0) + { + return CreateReceiveMessageResponse(TestRawLayoutId); + } + + messageProcessed.Release(); + return new ReceiveMessageResponse { Messages = [] }; + }); + + _mockRawLayoutRepository + .Setup(r => r.GetAsync(TestRawLayoutId, It.IsAny<CancellationToken>())) + .ReturnsAsync(rawLayout); + _mockCompilerClient + .Setup(c => c.CompileAsync(It.IsAny<RawLayout>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(compiledLayout); + _mockValidationClient + .Setup(v => v.ValidateAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(invalidResult); + _mockRawLayoutStatusWriter + .Setup(w => w.UpdateValidationResultAsync(TestRawLayoutId, It.IsAny<ValidationResult>(), It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + _mockSqs + .Setup(s => s.DeleteMessageAsync(It.IsAny<DeleteMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new DeleteMessageResponse()); + + // Act + LayoutProcessingOrchestrator orchestrator = CreateOrchestrator(); + CancellationTokenSource cts = new(); + await orchestrator.StartAsync(cts.Token); + + bool processed = await messageProcessed.WaitAsync(TimeSpan.FromSeconds(10)); + processed.Should().BeTrue(); + + await cts.CancelAsync(); + await orchestrator.StopAsync(CancellationToken.None); + + // Assert + _mockRawLayoutRepository.Verify(r => r.GetAsync(TestRawLayoutId, It.IsAny<CancellationToken>()), Times.Once); + _mockCompilerClient.Verify(c => c.CompileAsync(It.IsAny<RawLayout>(), It.IsAny<CancellationToken>()), Times.Once); + _mockValidationClient.Verify(v => v.ValidateAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), Times.Once); + _mockRawLayoutStatusWriter.Verify( + w => w.UpdateValidationResultAsync(TestRawLayoutId, It.IsAny<ValidationResult>(), It.IsAny<CancellationToken>()), + Times.Once); + _mockCompiledLayoutRepository.Verify( + r => r.SaveAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), + Times.Never); + _mockNotificationPublisher.Verify( + p => p.PublishLayoutReadyAsync(It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()), + Times.Never); + _mockSqs.Verify( + s => s.DeleteMessageAsync(It.IsAny<DeleteMessageRequest>(), It.IsAny<CancellationToken>()), + Times.Once); + + // Verify validation failure logs were emitted. + // Containment assertions are used rather than MockLogger.VerifyMessages because this is a + // BackgroundService test where polling infrastructure messages appear at non-deterministic + // positions — see the success-path test for a detailed explanation. + _mockLogger.Messages.Should().Contain(m => m.StartsWith($"Information[1708]: SQS message received; rawLayoutId={TestRawLayoutId}"), "message identity should be established"); + _mockLogger.Messages.Should().Contain(m => m.StartsWith($"Warning[1711]: Layout validation failed; rawLayoutId={TestRawLayoutId} issueCount=1"), "validation failure should be logged as warning"); + _mockLogger.Messages.Should().Contain(m => m.StartsWith($"Information[1719]: Validation result written back to raw layout; rawLayoutId={TestRawLayoutId}"), "write-back should be confirmed"); + } + + // ─── SQS retry behavior ──────────────────────────────────────────────────────── + + [TestMethod] + public async Task LayoutProcessingOrchestrator_ProcessMessageAsync_ProcessingError_DoesNotDeleteMessage() + { + // Arrange — compile throws; message should NOT be deleted so SQS retries it + RawLayout rawLayout = CreateTestRawLayout(); + InvalidOperationException compileException = new("Compile failed"); + + SemaphoreSlim messageProcessed = new(0, 1); + int receiveCount = 0; + + _mockSqs + .Setup(s => s.ReceiveMessageAsync(It.IsAny<ReceiveMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + if (receiveCount++ == 0) + { + return CreateReceiveMessageResponse(TestRawLayoutId); + } + + messageProcessed.Release(); + return new ReceiveMessageResponse { Messages = [] }; + }); + + _mockRawLayoutRepository + .Setup(r => r.GetAsync(TestRawLayoutId, It.IsAny<CancellationToken>())) + .ReturnsAsync(rawLayout); + _mockCompilerClient + .Setup(c => c.CompileAsync(It.IsAny<RawLayout>(), It.IsAny<CancellationToken>())) + .ThrowsAsync(compileException); + + // Act + LayoutProcessingOrchestrator orchestrator = CreateOrchestrator(); + CancellationTokenSource cts = new(); + await orchestrator.StartAsync(cts.Token); + + bool processed = await messageProcessed.WaitAsync(TimeSpan.FromSeconds(10)); + processed.Should().BeTrue(); + + await cts.CancelAsync(); + await orchestrator.StopAsync(CancellationToken.None); + + // Assert — message is NOT deleted on error; SQS will retry + _mockRawLayoutRepository.Verify(r => r.GetAsync(TestRawLayoutId, It.IsAny<CancellationToken>()), Times.Once); + _mockCompilerClient.Verify(c => c.CompileAsync(It.IsAny<RawLayout>(), It.IsAny<CancellationToken>()), Times.Once); + _mockSqs.Verify( + s => s.DeleteMessageAsync(It.IsAny<DeleteMessageRequest>(), It.IsAny<CancellationToken>()), + Times.Never); + _mockValidationClient.Verify(v => v.ValidateAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), Times.Never); + _mockCompiledLayoutRepository.Verify( + r => r.SaveAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), + Times.Never); + _mockNotificationPublisher.Verify( + p => p.PublishLayoutReadyAsync(It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()), + Times.Never); + _mockRawLayoutStatusWriter.Verify( + w => w.UpdateValidationResultAsync(It.IsAny<Guid>(), It.IsAny<ValidationResult>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + [TestMethod] + public async Task LayoutProcessingOrchestrator_ProcessMessageAsync_RawLayoutNotFound_DeletesMessage() + { + // Arrange — raw layout not found; message deleted without further pipeline steps + SemaphoreSlim messageProcessed = new(0, 1); + int receiveCount = 0; + + _mockSqs + .Setup(s => s.ReceiveMessageAsync(It.IsAny<ReceiveMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + if (receiveCount++ == 0) + { + return CreateReceiveMessageResponse(TestRawLayoutId); + } + + messageProcessed.Release(); + return new ReceiveMessageResponse { Messages = [] }; + }); + + _mockRawLayoutRepository + .Setup(r => r.GetAsync(TestRawLayoutId, It.IsAny<CancellationToken>())) + .ReturnsAsync((RawLayout?)null); + + _mockSqs + .Setup(s => s.DeleteMessageAsync(It.IsAny<DeleteMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new DeleteMessageResponse()); + + // Act + LayoutProcessingOrchestrator orchestrator = CreateOrchestrator(); + CancellationTokenSource cts = new(); + await orchestrator.StartAsync(cts.Token); + + bool processed = await messageProcessed.WaitAsync(TimeSpan.FromSeconds(10)); + processed.Should().BeTrue(); + + await cts.CancelAsync(); + await orchestrator.StopAsync(CancellationToken.None); + + // Assert + _mockRawLayoutRepository.Verify(r => r.GetAsync(TestRawLayoutId, It.IsAny<CancellationToken>()), Times.Once); + _mockSqs.Verify( + s => s.DeleteMessageAsync(It.IsAny<DeleteMessageRequest>(), It.IsAny<CancellationToken>()), + Times.Once); + _mockCompilerClient.Verify( + c => c.CompileAsync(It.IsAny<RawLayout>(), It.IsAny<CancellationToken>()), + Times.Never); + _mockValidationClient.Verify(v => v.ValidateAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), Times.Never); + _mockCompiledLayoutRepository.Verify(r => r.SaveAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), Times.Never); + _mockNotificationPublisher.Verify(p => p.PublishLayoutReadyAsync(It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Never); + _mockRawLayoutStatusWriter.Verify( + w => w.UpdateValidationResultAsync(It.IsAny<Guid>(), It.IsAny<ValidationResult>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + // ─── Unrecognized message body ───────────────────────────────────────────────── + + [TestMethod] + public async Task LayoutProcessingOrchestrator_ProcessMessageAsync_UnrecognizedMessageBody_DeletesMessageWithoutPipelineSteps() + { + // Arrange — message body is not a valid GUID + const string InvalidBody = "not-a-guid"; + string receiptHandle = $"receipt-{Guid.NewGuid():N}"; + + SemaphoreSlim messageProcessed = new(0, 1); + int receiveCount = 0; + + _mockSqs + .Setup(s => s.ReceiveMessageAsync(It.IsAny<ReceiveMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + if (receiveCount++ == 0) + { + return new ReceiveMessageResponse + { + Messages = + [ + new Message + { + MessageId = Guid.NewGuid().ToString(), + ReceiptHandle = receiptHandle, + Body = InvalidBody, + Attributes = new Dictionary<string, string> + { + ["ApproximateReceiveCount"] = "1" + } + } + ] + }; + } + + messageProcessed.Release(); + return new ReceiveMessageResponse { Messages = [] }; + }); + + _mockSqs + .Setup(s => s.DeleteMessageAsync(It.IsAny<DeleteMessageRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new DeleteMessageResponse()); + + // Act + LayoutProcessingOrchestrator orchestrator = CreateOrchestrator(); + CancellationTokenSource cts = new(); + await orchestrator.StartAsync(cts.Token); + + bool processed = await messageProcessed.WaitAsync(TimeSpan.FromSeconds(10)); + processed.Should().BeTrue(because: "unrecognized message should be deleted within 10 seconds"); + + await cts.CancelAsync(); + await orchestrator.StopAsync(CancellationToken.None); + + // Assert — message is deleted and no pipeline steps are invoked + _mockSqs.Verify( + s => s.DeleteMessageAsync( + It.Is<DeleteMessageRequest>(r => r.ReceiptHandle == receiptHandle), + It.IsAny<CancellationToken>()), + Times.Once); + _mockRawLayoutRepository.Verify( + r => r.GetAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()), + Times.Never); + _mockCompilerClient.Verify( + c => c.CompileAsync(It.IsAny<RawLayout>(), It.IsAny<CancellationToken>()), + Times.Never); + _mockValidationClient.Verify( + v => v.ValidateAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), + Times.Never); + _mockCompiledLayoutRepository.Verify( + r => r.SaveAsync(It.IsAny<CompiledLayout>(), It.IsAny<CancellationToken>()), + Times.Never); + _mockNotificationPublisher.Verify( + p => p.PublishLayoutReadyAsync(It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()), + Times.Never); + + // Verify the unrecognized message warning was logged. + // Containment assertions are used rather than MockLogger.VerifyMessages because this is a + // BackgroundService test where polling infrastructure messages appear at non-deterministic + // positions — see the success-path test for a detailed explanation. + _mockLogger.Messages.Should().Contain( + m => m.StartsWith($"Warning[1720]: SQS message unrecognized and deleted; receiptHandle={receiptHandle}"), + "unrecognized message body should be logged as a warning"); + } + + // ─── Helpers ────────────────────────────────────────────────────────────────── + + private LayoutProcessingOrchestrator CreateOrchestrator() + { + SqsSettings settings = new() + { + QueueUrl = TestQueueUrl, + MaxNumberOfMessages = 10, + VisibilityTimeoutSeconds = 60, + WaitTimeSeconds = 0, // No long-poll in tests + EmptyQueueDelayMs = 0 // No delay in tests + }; + + return new LayoutProcessingOrchestrator( + _mockSqs.Object, + Options.Create(settings), + _mockRawLayoutRepository.Object, + _mockRawLayoutStatusWriter.Object, + _mockCompilerClient.Object, + _mockValidationClient.Object, + _mockCompiledLayoutRepository.Object, + _mockNotificationPublisher.Object, + _mockLogger); + } + + private static ReceiveMessageResponse CreateReceiveMessageResponse(Guid rawLayoutId) + { + return new ReceiveMessageResponse + { + Messages = + [ + new Message + { + MessageId = Guid.NewGuid().ToString(), + ReceiptHandle = $"receipt-{Guid.NewGuid():N}", + Body = rawLayoutId.ToString(), + Attributes = new Dictionary<string, string> + { + ["ApproximateReceiveCount"] = "1" + } + } + ] + }; + } + + private static RawLayout CreateTestRawLayout() + { + return new RawLayout( + Id: TestRawLayoutId, + UserId: TestUserId, + Name: "Test Layout", + Elements: new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Play", + Label: "Play", + Glyph: null, + SpeakPhrase: "Play", + Reverse: "Pause", + CssId: "PLAY", + GridRow: 1, + GridColumn: 1) + }, + Version: 1, + CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow, + ValidationResult: null + ); + } + + private static CompiledLayout CreateTestCompiledLayout(Guid rawLayoutId) + { + return new CompiledLayout( + Id: Guid.NewGuid(), + RawLayoutId: rawLayoutId, + UserId: TestUserId, + IsActive: false, + Version: 1, + Elements: new[] + { + new CommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Play", + Label: "Play", + Glyph: null, + SpeakPhrase: "Play", + Reverse: "Pause", + CssId: "PLAY") + }, + CssDefinitions: string.Empty, + CompiledAt: DateTimeOffset.UtcNow + ); + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/AccessibilitySteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Application/AccessibilitySteps.cs similarity index 97% rename from test/AdaptiveRemote.EndToEndTests.Steps/AccessibilitySteps.cs rename to test/AdaptiveRemote.EndToEndTests.Steps/Application/AccessibilitySteps.cs index 07d1a652..731fb65e 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/AccessibilitySteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Application/AccessibilitySteps.cs @@ -3,7 +3,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll; -namespace AdaptiveRemote.EndToEndTests.Steps; +namespace AdaptiveRemote.EndToEndTests.Steps.Application; [Binding] public class AccessibilitySteps : StepsBase diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/AdptiveRemoteHostSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Application/AdptiveRemoteHostSteps.cs similarity index 96% rename from test/AdaptiveRemote.EndToEndTests.Steps/AdptiveRemoteHostSteps.cs rename to test/AdaptiveRemote.EndToEndTests.Steps/Application/AdptiveRemoteHostSteps.cs index 0535cab5..e7706f6b 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/AdptiveRemoteHostSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Application/AdptiveRemoteHostSteps.cs @@ -2,7 +2,7 @@ using FluentAssertions; using Reqnroll; -namespace AdaptiveRemote.EndToEndTests.Steps; +namespace AdaptiveRemote.EndToEndTests.Steps.Application; [Binding] public class AdaptiveRemoteHostSteps : StepsBase diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/DebugSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Application/DebugSteps.cs similarity index 91% rename from test/AdaptiveRemote.EndToEndTests.Steps/DebugSteps.cs rename to test/AdaptiveRemote.EndToEndTests.Steps/Application/DebugSteps.cs index 8adbac7c..7799687e 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/DebugSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Application/DebugSteps.cs @@ -2,7 +2,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll; -namespace AdaptiveRemote.EndToEndTests.Steps; +namespace AdaptiveRemote.EndToEndTests.Steps.Application; [Binding] public class DebugSteps : StepsBase diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/ISpeechTestServiceExtensions.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Application/ISpeechTestServiceExtensions.cs similarity index 98% rename from test/AdaptiveRemote.EndToEndTests.Steps/ISpeechTestServiceExtensions.cs rename to test/AdaptiveRemote.EndToEndTests.Steps/Application/ISpeechTestServiceExtensions.cs index a8957f29..dd1f84f4 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/ISpeechTestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Application/ISpeechTestServiceExtensions.cs @@ -1,8 +1,9 @@ using AdaptiveRemote.EndtoEndTests; using AdaptiveRemote.Services.Testing; +using AdaptiveRemote.TestUtilities; using FluentAssertions; -namespace AdaptiveRemote.EndToEndTests.Steps; +namespace AdaptiveRemote.EndToEndTests.Steps.Application; internal static class ISpeechTestServiceExtensions { diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/SimulatedBroadlinkSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Application/SimulatedBroadlinkSteps.cs similarity index 98% rename from test/AdaptiveRemote.EndToEndTests.Steps/SimulatedBroadlinkSteps.cs rename to test/AdaptiveRemote.EndToEndTests.Steps/Application/SimulatedBroadlinkSteps.cs index f1c11e84..db9a8356 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/SimulatedBroadlinkSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Application/SimulatedBroadlinkSteps.cs @@ -1,11 +1,12 @@ using AdaptiveRemote.EndtoEndTests; using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; +using AdaptiveRemote.TestUtilities; using FluentAssertions; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll; -namespace AdaptiveRemote.EndToEndTests.Steps; +namespace AdaptiveRemote.EndToEndTests.Steps.Application; [Binding] public class SimulatedBroadlinkSteps : StepsBase diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/SimulatedTiVoSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Application/SimulatedTiVoSteps.cs similarity index 94% rename from test/AdaptiveRemote.EndToEndTests.Steps/SimulatedTiVoSteps.cs rename to test/AdaptiveRemote.EndToEndTests.Steps/Application/SimulatedTiVoSteps.cs index c73b4e03..0c60a37c 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/SimulatedTiVoSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Application/SimulatedTiVoSteps.cs @@ -1,10 +1,10 @@ -using AdaptiveRemote.EndtoEndTests; using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; +using AdaptiveRemote.TestUtilities; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll; -namespace AdaptiveRemote.EndToEndTests.Steps; +namespace AdaptiveRemote.EndToEndTests.Steps.Application; [Binding] public class SimulatedTiVoSteps : StepsBase diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/SpeechSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Application/SpeechSteps.cs similarity index 94% rename from test/AdaptiveRemote.EndToEndTests.Steps/SpeechSteps.cs rename to test/AdaptiveRemote.EndToEndTests.Steps/Application/SpeechSteps.cs index 9dcaa893..27aa3339 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/SpeechSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Application/SpeechSteps.cs @@ -1,7 +1,7 @@ using AdaptiveRemote.EndtoEndTests; using Reqnroll; -namespace AdaptiveRemote.EndToEndTests.Steps; +namespace AdaptiveRemote.EndToEndTests.Steps.Application; [Binding] public class SpeechSteps : StepsBase diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Application/UISteps.cs similarity index 97% rename from test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs rename to test/AdaptiveRemote.EndToEndTests.Steps/Application/UISteps.cs index f94acc3b..06440a6e 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Application/UISteps.cs @@ -2,7 +2,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll; -namespace AdaptiveRemote.EndToEndTests.Steps; +namespace AdaptiveRemote.EndToEndTests.Steps.Application; [Binding] public class UISteps : StepsBase diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/Backend/AuthenticationSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/AuthenticationSteps.cs new file mode 100644 index 00000000..f96b6350 --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/AuthenticationSteps.cs @@ -0,0 +1,30 @@ +using AdaptiveRemote.EndToEndTests.TestServices; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps.Backend; + +[Binding] +public class AuthenticationSteps : StepsBase +{ + // Use a unique user ID per fixture so each scenario operates on isolated data + // even when DynamoDB is shared across test scenarios via the shared LocalStack. + private readonly string _testUserId = $"test-user-{Guid.NewGuid():N}"; + + [Given("the client has a valid Authorization token")] + public void GivenClientHasValidAuthenticationToken() + { + TestClient.AuthorizationToken = Environment.JwtAuthority.CreateToken(_testUserId); + } + + [Given("the client has no Authorization token")] + public void GivenClientHasNoAuthorizationToken() + { + TestClient.AuthorizationToken = string.Empty; + } + + [Given("the client has an expired Authorization token")] + public void GivenClientHasExpiredAuthorizationToken() + { + TestClient.AuthorizationToken = Environment.JwtAuthority.CreateExpiredToken(_testUserId); + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/Backend/CompiledLayoutSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/CompiledLayoutSteps.cs new file mode 100644 index 00000000..89f94d78 --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/CompiledLayoutSteps.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization.Metadata; +using AdaptiveRemote.Contracts; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps.Backend; + +[Binding] +public class CompiledLayoutSteps : StepsBase +{ + [StepArgumentTransformation(nameof(CompiledLayout))] + public static JsonTypeInfo CompiledLayoutJsonTypeInfo() => LayoutContractsJsonContext.Default.CompiledLayout; + + [StepArgumentTransformation("(TiVo|IR|Lifecycle)")] + public static CommandType StringToCommandType(string commandType) + => Enum.Parse<CommandType>(commandType); + + [Then(@"the CompiledLayout in the response body has a(n) {CommandType} command named {string}")] + public void ThenTheCompiledLayoutInTheResponseBodyHasACommandOfTypeWithName(CommandType expectedType, string expectedName) + { + CompiledLayout? layout = TestClient.LastResponseObject as CompiledLayout; + Assert.IsNotNull(layout, "Last response was not parsed as a CompiledLayout"); + + IEnumerable<CommandDefinitionDto> commands = EnumerateAllCommands(layout.Elements); + + Assert.IsTrue(commands.Any(c => c.Type == expectedType && c.Name == expectedName), + $"Expected to find a command of type {expectedType} with name '{expectedName}' in the CompiledLayout, but it was not found. Commands found: {string.Join(", ", commands.Select(c => $"{c.Type}:{c.Name}"))}"); + } + + private static IEnumerable<CommandDefinitionDto> EnumerateAllCommands(IEnumerable<LayoutElementDto> elements) + { + Stack<IEnumerator<LayoutElementDto>> stack = new(); + stack.Push(elements.GetEnumerator()); + + while (stack.Count > 0) + { + IEnumerator<LayoutElementDto> enumerator = stack.Pop(); + while (enumerator.MoveNext()) + { + LayoutElementDto current = enumerator.Current; + if (current is CommandDefinitionDto command) + { + yield return command; + } + else if (current is LayoutGroupDefinitionDto container) + { + stack.Push(enumerator); + enumerator = container.Children.GetEnumerator(); + } + } + } + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/Backend/HealthResponseSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/HealthResponseSteps.cs new file mode 100644 index 00000000..de64f1d5 --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/HealthResponseSteps.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization.Metadata; +using AdaptiveRemote.Contracts; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps.Backend; + +[Binding] +internal static class HealthResponseSteps +{ + [StepArgumentTransformation(nameof(HealthResponse))] + public static JsonTypeInfo HealthResponseToJsonTypeInfo() => LayoutContractsJsonContext.Default.HealthResponse; +} diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/Backend/HttpRequestSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/HttpRequestSteps.cs new file mode 100644 index 00000000..8454a2ff --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/HttpRequestSteps.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Text.Json; +using AdaptiveRemote.Contracts; +using AdaptiveRemote.EndToEndTests.TestServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps.Backend; + +[Binding] +public class HttpRequestSteps : StepsBase +{ + private const string HttpMethodFilter = "(GET|POST|PUT|DELETE|PATCH)"; + private Guid? _existingRawLayoutId; + + [StepArgumentTransformation(HttpMethodFilter)] + public static HttpMethod StringToHttpMethod(string method) + => method switch + { + "GET" => HttpMethod.Get, + "POST" => HttpMethod.Post, + "PUT" => HttpMethod.Put, + "DELETE" => HttpMethod.Delete, + "PATCH" => HttpMethod.Patch, + _ => throw new ArgumentException($"Unsupported HTTP method: {method}") + }; + + [StepArgumentTransformation(@"/layouts/raw/\{id\}")] + private Uri TransformRawLayoutId() + => new Uri($"/layouts/raw/{_existingRawLayoutId}", UriKind.Relative); + + [Given("{Uri} has a raw layout with the name {string}")] + public void GivenARawLayoutExistsWithTheName(Uri endpointUri, string layoutName) + { + WhenANamedLayoutIsCreatedVia(layoutName, endpointUri); + } + + [When(@"the client calls " + HttpMethodFilter + @" (/\S+) on the (\w+) endpoint")] + public void WhenTheClientCallsEndpoint(HttpMethod method, Uri url, Uri endpointUrl) + { + WhenTheClientCallsEndpoint(method, url, endpointUrl, null); + } + + [When(@"the client calls " + HttpMethodFilter + @" (/\S+) on the (\w+) endpoint with")] + public void WhenTheClientCallsEndpoint(HttpMethod method, Uri url, Uri endpointUrl, string? body) + { + //url = ProcessSpecialUris(url); + + TestClient.SendRequest(method, new Uri(endpointUrl, url), body); + } + + [When(@"a layout named {string} is created via {Uri}")] + public void WhenANamedLayoutIsCreatedVia(string layoutName, Uri endpointUri) + { + RawLayout testLayout = new( + Id: Guid.Empty, + UserId: "test-user", + Name: layoutName, + Elements: new List<RawLayoutElementDto> + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Up", + Label: "Up", + Glyph: "↑", + SpeakPhrase: "up", + Reverse: "Down", + CssId: "up-btn", + GridRow: 0, + GridColumn: 1 + ) + }, + Version: 1, + CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow, + ValidationResult: null + ); + + string requestBody = JsonSerializer.Serialize(testLayout, LayoutContractsJsonContext.Default.RawLayout); + + WhenThisLayoutIsCreatedVia(endpointUri, requestBody); + } + + [When(@"^this layout is created via (RawLayoutService):")] + public void WhenThisLayoutIsCreatedVia(Uri serviceUri, string body) + { + WhenTheClientCallsEndpoint(HttpMethod.Post, new("/layouts/raw", UriKind.Relative), serviceUri, body); + Assert.AreEqual(HttpStatusCode.Created, TestClient.LastResponse!.StatusCode, "Layout creation returned an unexpected status code."); + + TestClient.ParseResponseAs(LayoutContractsJsonContext.Default.RawLayout); + _existingRawLayoutId = ((RawLayout)TestClient.LastResponseObject).Id; + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/Backend/HttpResponseSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/HttpResponseSteps.cs new file mode 100644 index 00000000..e63385cc --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/HttpResponseSteps.cs @@ -0,0 +1,95 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using AdaptiveRemote.EndToEndTests.TestServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps.Backend; + +[Binding] +public class HttpResponseSteps : StepsBase +{ + [StepArgumentTransformation("200 OK")] public static HttpStatusCode StringToOk() => HttpStatusCode.OK; + [StepArgumentTransformation("401 Unauthorized")] public static HttpStatusCode StringToUnauthorized() => HttpStatusCode.Unauthorized; + [StepArgumentTransformation("201 Created")] public static HttpStatusCode StringToCreated() => HttpStatusCode.Created; + [StepArgumentTransformation("204 No Content")] public static HttpStatusCode StringToNoContent() => HttpStatusCode.NoContent; + [StepArgumentTransformation("404 Not Found")] public static HttpStatusCode StringToNotFound() => HttpStatusCode.NotFound; + [StepArgumentTransformation("400 Bad Request")] public static HttpStatusCode StringToBadRequest() => HttpStatusCode.BadRequest; + [StepArgumentTransformation("500 Internal Server Error")] public static HttpStatusCode StringToInternalServerError() => HttpStatusCode.InternalServerError; + + [Then(@"the response is {HttpStatusCode}")] + public void ThenTheResponseIs(HttpStatusCode expectedStatusCode) + { + Assert.AreEqual(expectedStatusCode, TestClient.LastResponse.StatusCode, "Status code from the latest response. Response body:\n{0}", TestClient.LastResponseBody); + } + + [Then(@"the response body is {string}")] + public void ThenTheResponseBodyIs(string expectedBody) + { + Assert.AreEqual(expectedBody, TestClient.LastResponseBody, "Latest response body"); + } + + [Then(@"the response body contains {string}")] + public void ThenTheResponseBodyContains(string expectedContent) + { + StringAssert.Contains(TestClient.LastResponseBody, expectedContent, "Latest response body"); + } + + [Then(@"the response body does not contain {string}")] + public void ThenTheResponseBodyDoesNotContain(string unexpectedContent) + { + StringAssert.DoesNotMatch(TestClient.LastResponseBody!, new(unexpectedContent), "Latest response body"); + } + + [Then(@"the response body is valid JSON")] + public void ThenTheResponseBodyIsValidJson() + { + try + { + JsonDocument.Parse(TestClient.LastResponseBody); + } + catch (JsonException ex) + { + Assert.Fail($"Response body is not valid JSON. Parsing error: {ex.Message}\nResponse body:\n{TestClient.LastResponseBody}"); + } + } + + [Then(@"the response body represents a {JsonTypeInfo}")] + public void ThenTheResponseBodyRepresents(JsonTypeInfo type) + { + try + { + TestClient.ParseResponseAs(type); + } + catch (JsonException ex) + { + Assert.Fail($"Response body could not be deserialized into {type.Type.Name}. Parsing error: {ex.Message}\nResponse body:\n{TestClient.LastResponseBody}"); + } + } + + [Then(@"the {JsonTypeInfo} in the response body has a {string} property")] + public void ThenTheDeserializedResponseHasAPropertyWithValue(JsonTypeInfo typeInfo, string propertyName) + { + Assert.IsInstanceOfType(TestClient.LastResponseObject, typeInfo.Type, $"Expected the deserialized object to be of type {typeInfo.Type.Name}."); + + JsonPropertyInfo? property = typeInfo.Properties.FirstOrDefault(x => x.Name == propertyName); + Assert.IsNotNull(property, "{0} does not have a property named '{1}'. Found properties: {2}", typeInfo.Type.Name, propertyName, string.Join(", ", typeInfo.Properties.Select(p => p.Name))); + } + + [Then(@"the {JsonTypeInfo} in the response body has {string}={string}")] + public void ThenTheDeserializedResponseHasAPropertyWithValue(JsonTypeInfo typeInfo, string propertyName, string expectedValue) + { + Assert.IsInstanceOfType(TestClient.LastResponseObject, typeInfo.Type, $"Expected the deserialized object to be of type {typeInfo.Type.Name}."); + + JsonPropertyInfo? property = typeInfo.Properties.FirstOrDefault(x => x.Name == propertyName); + Assert.IsNotNull(property, "{0} does not have a property named '{1}'. Found properties: {2}", typeInfo.Type.Name, propertyName, string.Join(", ", typeInfo.Properties.Select(p => p.Name))); + Assert.AreEqual(typeof(string), property.PropertyType, "Expected property '{0}' to be of type string.", propertyName); + + Assert.IsNotNull(property.Get, "Property '{0}' does not have a Get method.", propertyName); + object? value = property.Get(TestClient.LastResponseObject); + + Assert.IsNotNull(value, "Property '{0}' was null.", propertyName); + Assert.AreEqual(expectedValue, value.ToString(), "Expected property '{0}' to have value '{1}', but found '{2}'.", propertyName, expectedValue, value); + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/Backend/RawLayoutSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/RawLayoutSteps.cs new file mode 100644 index 00000000..0a999bae --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/RawLayoutSteps.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization.Metadata; +using AdaptiveRemote.Contracts; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps.Backend; + +[Binding] +public class RawLayoutSteps : StepsBase +{ + [StepArgumentTransformation(nameof(RawLayout))] + public static JsonTypeInfo RawLayoutToJsonTypeInfo() => LayoutContractsJsonContext.Default.RawLayout; + + [Then(@"the RawLayout in the response body has a valid Id property")] + public void ThenTheRawLayoutInTheResponseBodyHasAValidIdProperty() + { + RawLayout? layout = TestClient.LastResponseObject as RawLayout; + Assert.IsNotNull(layout, "Last response was not parsed as a RawLayout"); + + Assert.IsFalse(layout.Id == Guid.Empty, "Expected RawLayout to have a non-empty Id property."); + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/Backend/ServiceSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/ServiceSteps.cs new file mode 100644 index 00000000..ddad7def --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Backend/ServiceSteps.cs @@ -0,0 +1,31 @@ +using AdaptiveRemote.EndToEndTests.TestServices.Backend; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps.Backend; + +[Binding] +public class ServiceSteps : StepsBase +{ + private const string ServiceRegex = "(RawLayoutService|CompiledLayoutService|LayoutProcessingService)"; + + [StepArgumentTransformation(ServiceRegex)] + public Uri ServiceNameToEndpointUri(string serviceName) + => new(ServiceNameToFixture(serviceName).ServiceUrl); + + [StepArgumentTransformation(ServiceRegex)] + public ServiceFixture ServiceNameToFixture(string serviceName) + => serviceName switch + { + "RawLayoutService" => Environment.RawLayoutService, + "CompiledLayoutService" => Environment.CompiledLayoutService, + "LayoutProcessingService" => Environment.LayoutProcessingService, + _ => throw new ArgumentException($"Unknown service name: {serviceName}", nameof(serviceName)) + }; + + [Given(@"^" + ServiceRegex + " is running")] + public void GivenCompiledLayoutServiceIsRunning(string serviceName) + { + // Accessing the property ensures the service is started. + _ = ServiceNameToFixture(serviceName); + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs index 97a118d9..25b58f3a 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs @@ -53,22 +53,30 @@ public static void OnBeforeScenario_ClearBroadlinkPackets(IObjectContainer conta [AfterScenario] public static void OnAfterScenario_AttachLogsToTestResult(TestContext testContext) { - string? logLocation = _startedEnvironment?.HostLogs; - - if (logLocation is null) + (string service, string? logLocation)[] logsToAttach = { - testContext.WriteLine("No log location had been set for the host."); - return; - } + ("Host", _startedEnvironment?.HostLogs), + ("RawLayoutService", _startedEnvironment?.RawLayoutServiceLogs), + ("CompiledLayoutService", _startedEnvironment?.CompiledLayoutServiceLogs), + ("LayoutProcessingService", _startedEnvironment?.LayoutProcessingServiceLogs) + }; - if (File.Exists(logLocation)) + foreach ((string service, string? logLocation) in logsToAttach) { - testContext.AddResultFile(logLocation); - testContext.WriteLine("Log file found and attached"); - } - else - { - testContext.WriteLine("Log file not found at expected location: " + logLocation); + if (logLocation is null) + { + continue; + } + + if (File.Exists(logLocation)) + { + testContext.AddResultFile(logLocation); + testContext.WriteLine("Log file for {0} found and attached", service); + } + else + { + testContext.WriteLine("Log file for {0} not found at expected location: {1}", service, logLocation); + } } } diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs index c3eaab79..b0c43cd6 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs @@ -1,4 +1,4 @@ -using AdaptiveRemote.EndtoEndTests; +using AdaptiveRemote.TestUtilities; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll; @@ -8,92 +8,271 @@ namespace AdaptiveRemote.EndToEndTests.Steps; [Binding] public class LogVerificationSteps : StepsBase { + private const string HostName = "Host"; + private const string RawLayoutServiceName = "RawLayoutService"; + private const string CompiledLayoutServiceName = "CompiledLayoutService"; + private const string LayoutProcessingServiceName = "LayoutProcessingService"; + private const string ServiceFilter = "(" + RawLayoutServiceName + "|" + CompiledLayoutServiceName + "|" + LayoutProcessingServiceName + ")"; + private static readonly Dictionary<string, int> _lastLineRead = new(); [Then("I should not see any warning or error messages in the logs")] public void ThenIShouldNotSeeAnyWarningsOrErrorsInTheLogFile() { - IEnumerable<string> warningAndErrorLines = FilterLogLines(IsWarningOrError); + ThenIShouldNotSeeAnyWarningsOrErrorsInTheServiceLogs(HostName); + } + + [Then("^I should not see any warning or error messages in the " + ServiceFilter + " logs")] + public void ThenIShouldNotSeeAnyWarningsOrErrorsInTheServiceLogs(string serviceName) + { + string logFilePath = GetLogFileFor(serviceName); + LogLine[] unreadLines = ReadUnreadLogLines(logFilePath); + string[] warningAndErrorLines = unreadLines + .Where(line => IsWarningOrError(line.Text)) + .Select(line => line.Text) + .ToArray(); + MarkLinesAsRead(logFilePath, unreadLines); Assert.IsFalse( - warningAndErrorLines.Any(), - "Host log contains warnings or errors:\n{0}", + warningAndErrorLines.Length > 0, + "{0} log contains warnings or errors:\n{1}", + serviceName, string.Join("\n", warningAndErrorLines)); } [Then("I should not see any error messages in the logs")] public void ThenIShouldNotSeeAnyErrorsInTheLogFile() { - IEnumerable<string> errorLines = FilterLogLines(IsError); + ThenIShouldNotSeeAnyErrorsInTheServiceLogs(HostName); + } + + [Then("^I should not see any error messages in the " + ServiceFilter + " logs")] + public void ThenIShouldNotSeeAnyErrorsInTheServiceLogs(string serviceName) + { + string logFilePath = GetLogFileFor(serviceName); + LogLine[] unreadLines = ReadUnreadLogLines(logFilePath); + string[] errorLines = unreadLines + .Where(line => IsError(line.Text)) + .Select(line => line.Text) + .ToArray(); + MarkLinesAsRead(logFilePath, unreadLines); Assert.IsFalse( - errorLines.Any(), - "Host log contains errors:\n{0}", + errorLines.Length > 0, + "{0} log contains errors:\n{1}", + serviceName, string.Join("\n", errorLines)); } [Then("I should see an error message in the logs:")] public void ThenIShouldSeeAnErrorInTheLogs(string expectedErrorMessage) { - IEnumerable<string>? errorLines = null; + ThenIShouldSeeAnErrorInTheServiceLogs(HostName, expectedErrorMessage); + } + + [Then("^I should see an error message in the " + ServiceFilter + " logs:")] + public void ThenIShouldSeeAnErrorInTheServiceLogs(string serviceName, string expectedErrorMessage) + { + string logFilePath = GetLogFileFor(serviceName); + string[]? errorLines = null; + LogLine? matchingError = null; WaitHelpers.ExecuteWithRetries(() => { - errorLines = FilterLogLines(IsError); - return errorLines.Any(line => line.Contains(expectedErrorMessage, StringComparison.Ordinal)); + LogLine[] unreadLines = ReadUnreadLogLines(logFilePath); + matchingError = unreadLines + .Where(line => IsError(line.Text)) + .FirstOrDefault(line => line.Text.Contains(expectedErrorMessage, StringComparison.Ordinal)); + + if (matchingError is null) + { + return false; + } + + errorLines = unreadLines + .Where(line => line.Number <= matchingError.Number && IsError(line.Text)) + .Select(line => line.Text) + .ToArray(); + MarkLinesAsRead(logFilePath, matchingError.Number); + return true; }); - Assert.IsNotNull(errorLines, "Failed to read host log lines."); - Assert.IsTrue(errorLines.Any(), "Host log does not contain any error messages."); - Assert.AreEqual(1, errorLines.Count(), - "Host log contains unexpected errors:\n{0}", + Assert.IsNotNull(errorLines, "Failed to read {0} log lines.", serviceName); + Assert.IsTrue(errorLines.Length > 0, "{0} log does not contain any error messages.", serviceName); + Assert.AreEqual(1, errorLines.Length, + "{0} log contains unexpected errors:\n{1}", + serviceName, string.Join("\n", errorLines)); StringAssert.Contains(errorLines.First(), expectedErrorMessage, - "Host log error message does not match the expected text"); + "{0} log error message does not match the expected text", serviceName); + } + + [Then("I should see a warning message in the logs:")] + public void ThenIShouldSeeAWarningInTheLogs(string expectedWarningMessage) + { + ThenIShouldSeeAWarningInTheServiceLogs(HostName, expectedWarningMessage); } - private IEnumerable<string> FilterLogLines(Func<string, bool> lineFilter) + [Then("^I should see a warning message in the " + ServiceFilter + " logs:")] + public void ThenIShouldSeeAWarningInTheServiceLogs(string serviceName, string expectedWarningMessage) { - Assert.IsNotNull(Environment.HostLogs, "Host log path was not set."); + string logFilePath = GetLogFileFor(serviceName); + string[]? warningAndErrorLines = null; + LogLine? matchingWarning = null; - if (!File.Exists(Environment.HostLogs)) + WaitHelpers.ExecuteWithRetries(() => { - Logger.LogWarning("Host log file does not exist at expected location: {LogPath}", Environment.HostLogs); - } + LogLine[] unreadLines = ReadUnreadLogLines(logFilePath); + matchingWarning = unreadLines + .Where(line => IsWarningOrError(line.Text)) + .FirstOrDefault(line => line.Text.Contains(expectedWarningMessage, StringComparison.Ordinal)); + + if (matchingWarning is null) + { + return false; + } + + warningAndErrorLines = unreadLines + .Where(line => line.Number <= matchingWarning.Number && IsWarningOrError(line.Text)) + .Select(line => line.Text) + .ToArray(); + MarkLinesAsRead(logFilePath, matchingWarning.Number); + return true; + }); + + Assert.IsNotNull(warningAndErrorLines, "Failed to read {0} log lines.", serviceName); + Assert.IsTrue(warningAndErrorLines.Length > 0, "{0} log does not contain any error messages.", serviceName); + Assert.AreEqual(1, warningAndErrorLines.Length, + "{0} log contains unexpected errors:\n{1}", + serviceName, + string.Join("\n", warningAndErrorLines)); + StringAssert.Contains(warningAndErrorLines.First(), expectedWarningMessage, + "{0} log warning message does not match the expected text", serviceName); + } + + [Then("^I should see a message that contains \"(.*)\" in the logs")] + public void ThenIShouldSeeAMessageThatContainsSomethingInTheLogs(string expectedMessagePart) + { + ThenIShouldSeeAMessageThatContainsSomethingInTheServiceLogs(expectedMessagePart, HostName); + } - string logContent; - using (Stream logStream = File.Open(Environment.HostLogs, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + [Then("^I should see a message that contains \"(.*)\" in the " + ServiceFilter + " logs")] + public void ThenIShouldSeeAMessageThatContainsSomethingInTheServiceLogs(string expectedMessagePart, string serviceName) + { + string logFilePath = GetLogFileFor(serviceName); + + bool result = WaitHelpers.ExecuteWithRetries(() => { - logContent = new StreamReader(logStream).ReadToEnd(); - } + LogLine[] unreadLines = ReadUnreadLogLines(logFilePath); + LogLine? matchingLine = unreadLines + .FirstOrDefault(line => line.Text.Contains(expectedMessagePart, StringComparison.Ordinal)); - string[] logLines = logContent.Split(System.Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + if (matchingLine is not null) + { + LogLine? warningOrErrorBeforeMatch = unreadLines + .FirstOrDefault(line => line.Number <= matchingLine.Number && IsWarningOrError(line.Text)); - return FilterLines(logLines, lineFilter); + if (warningOrErrorBeforeMatch is not null) + { + Assert.Fail("Found an error or warning in the {0} log while looking for a message containing '{1}':\n{2}", + serviceName, + expectedMessagePart, + warningOrErrorBeforeMatch.Text); + } + + MarkLinesAsRead(logFilePath, matchingLine.Number); + return true; + } + + LogLine? warningOrErrorLine = unreadLines.FirstOrDefault(line => IsWarningOrError(line.Text)); + if (warningOrErrorLine is not null) + { + Assert.Fail("Found an error or warning in the {0} log while looking for a message containing '{1}':\n{2}", + serviceName, + expectedMessagePart, + warningOrErrorLine.Text); + } + + return false; + }); + + Assert.IsTrue(result, "Did not find a message in the {0} log containing '{1}'", serviceName, expectedMessagePart); + } + + private string GetLogFileFor(string serviceName) + { + string? logPath = serviceName switch + { + HostName => Environment.HostLogs, + RawLayoutServiceName => Environment.RawLayoutServiceLogs, + CompiledLayoutServiceName => Environment.CompiledLayoutServiceLogs, + LayoutProcessingServiceName => Environment.LayoutProcessingServiceLogs, + _ => throw new ArgumentException($"Unexpected service name: {serviceName}", nameof(serviceName)) + }; + + Assert.IsNotNull(logPath, $"{serviceName} log path was not set."); + if (!File.Exists(logPath)) + { + Logger.LogWarning("{ServiceName} log file does not exist at expected location: {LogPath}", serviceName, logPath); + } + + return logPath; } - private IEnumerable<string> FilterLines(string[] logLines, Func<string, bool> lineFilter) + private static LogLine[] ReadUnreadLogLines(string logFilePath) { - Assert.IsNotNull(Environment.HostLogs, "Host log path was not set."); + int currentLine = 0; + List<LogLine> unreadLines = []; + + _lastLineRead.TryGetValue(logFilePath, out int lastLineRead); - IEnumerable<string> filteredLines = logLines; - if (_lastLineRead.TryGetValue(Environment.HostLogs, out int lastLine)) + using (Stream logStream = File.Open(logFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (StreamReader logReader = new(logStream)) { - filteredLines = logLines.Skip(lastLine); + string? logLine; + while ((logLine = logReader.ReadLine()) is not null) + { + currentLine++; + if (currentLine > lastLineRead) + { + unreadLines.Add(new(currentLine, logLine)); + } + } } - _lastLineRead[Environment.HostLogs] = logLines.Length; - return filteredLines.Where(lineFilter); + return unreadLines.ToArray(); + } + + private static void MarkLinesAsRead(string logFilePath, IEnumerable<LogLine> lines) + { + LogLine? lastLine = lines.LastOrDefault(); + if (lastLine is not null) + { + MarkLinesAsRead(logFilePath, lastLine.Number); + } + } + + private static void MarkLinesAsRead(string logFilePath, int lastLineRead) + { + _lastLineRead[logFilePath] = lastLineRead; } private static bool IsError(string line) { - return line.Contains("] Error [", StringComparison.Ordinal); + return line.Contains("] Error [", StringComparison.Ordinal) + || line.Contains("] [Error] [", StringComparison.Ordinal); + } + + private static bool IsWarning(string line) + { + return line.Contains("] Warning [", StringComparison.Ordinal) + || line.Contains("] [Warning] [", StringComparison.Ordinal); } private static bool IsWarningOrError(string line) { - return line.Contains("] Error [", StringComparison.Ordinal) - || line.Contains("] Warning [", StringComparison.Ordinal); + return IsError(line) || IsWarning(line); } + + private sealed record LogLine(int Number, string Text); } diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs b/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs index 4103c836..083951d9 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs @@ -1,5 +1,6 @@ using AdaptiveRemote.EndtoEndTests.Host; using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; +using AdaptiveRemote.EndToEndTests.TestServices; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll.BoDi; @@ -12,6 +13,7 @@ public abstract class StepsBase : IContainerDependentObject private IObjectContainer? _container; private ISimulatedEnvironment? _simulatedEnvironment; private ILogger? _logger; + private TestClient? _testClient; public void SetObjectContainer(IObjectContainer container) => _container = container; @@ -23,6 +25,8 @@ public abstract class StepsBase : IContainerDependentObject public ILogger Logger => _logger ??= Host.CreateLogger(GetType().Name); + public TestClient TestClient => _testClient ??= GetContainerObject<TestClient>(); + private ObjectType GetContainerObject<ObjectType>() where ObjectType : notnull { diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj b/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj index a5241338..a3b251df 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj @@ -8,16 +8,20 @@ </PropertyGroup> <ItemGroup> + <PackageReference Include="AWSSDK.DynamoDBv2" /> + <PackageReference Include="AWSSDK.SQS" /> <PackageReference Include="Deque.AxeCore.Commons" /> <PackageReference Include="Deque.AxeCore.Playwright" /> <PackageReference Include="FluentAssertions" /> + <PackageReference Include="Microsoft.Playwright" /> <PackageReference Include="MSTest.TestFramework" /> <PackageReference Include="StreamJsonRpc" /> - <PackageReference Include="Microsoft.Playwright" /> + <PackageReference Include="System.IdentityModel.Tokens.Jwt" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\..\src\AdaptiveRemote.App\AdaptiveRemote.App.csproj" /> + <ProjectReference Include="..\AdaptiveRemote.TestUtilities\AdaptiveRemote.TestUtilities.csproj" /> </ItemGroup> </Project> diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/LocalStackFixture.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/LocalStackFixture.cs new file mode 100644 index 00000000..863a7151 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/LocalStackFixture.cs @@ -0,0 +1,365 @@ +using System.Diagnostics; +using AdaptiveRemote.TestUtilities; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.EndToEndTests.TestServices.Backend; + +/// <summary> +/// Manages a LocalStack Docker container for integration testing. +/// Provides a local DynamoDB instance that services can connect to. +/// </summary> +public class LocalStackFixture : IDisposable +{ + private Process? _dockerProcess; + private bool _isStarted; + private bool _ownsContainer; // Track if we created the container + private readonly ILogger<LocalStackFixture> _logger; + + public LocalStackFixture(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger<LocalStackFixture>(); + } + + public string ServiceUrl { get; } = "http://localhost:4566"; + + public string Region { get; } = "us-east-1"; + + /// <summary> + /// Starts LocalStack in a Docker container and waits for it to be ready. + /// </summary> + public void Start() + { + if (_isStarted) + { + return; + } + + // Check if LocalStack container is already running + Process checkProcess = new() + { + StartInfo = new ProcessStartInfo + { + FileName = "docker", + Arguments = "ps --filter name=localstack-test --format {{.Names}}", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + + checkProcess.Start(); + string existingContainer = WaitHelpers.WaitForAsyncTask(checkProcess.StandardOutput.ReadToEndAsync); + WaitHelpers.WaitForAsyncTask(checkProcess.WaitForExitAsync); + + if (!string.IsNullOrWhiteSpace(existingContainer)) + { + _logger.LogInformation("Found an existing localstack-test container. Verifying if it can be reused..."); + + // Container already running — verify that SQS is enabled before reusing it. + // An older container may have been started with SERVICES=dynamodb only. + WaitForLocalStackReady(); + if (IsSqsEnabled()) + { + _isStarted = true; + _ownsContainer = false; + return; + } + + _logger.LogInformation("Found an existing localstack-test container, but SQS is not enabled. Stopping the stale container..."); + + // SQS not available — stop the stale container so we can start a fresh one + // with the correct SERVICES configuration. + Process stopOldProcess = new() + { + StartInfo = new ProcessStartInfo + { + FileName = "docker", + Arguments = "stop localstack-test", + UseShellExecute = false, + CreateNoWindow = true + } + }; + stopOldProcess.Start(); + WaitHelpers.WaitForAsyncTask(stopOldProcess.WaitForExitAsync); + stopOldProcess.Dispose(); + } + + // Start LocalStack container + _logger.LogInformation("Starting localstack-test container..."); + + ProcessStartInfo startInfo = new() + { + FileName = "docker", + Arguments = "run --rm -d " + + "--name localstack-test " + + "-p 4566:4566 " + + "-e SERVICES=dynamodb,sqs " + + "localstack/localstack:3.0", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + _dockerProcess = new Process { StartInfo = startInfo }; + _dockerProcess.Start(); + + string containerId = WaitHelpers.WaitForAsyncTask(_dockerProcess.StandardOutput.ReadToEndAsync); + WaitHelpers.WaitForAsyncTask(_dockerProcess.WaitForExitAsync); + + if (_dockerProcess.ExitCode != 0) + { + string error = WaitHelpers.WaitForAsyncTask(_dockerProcess.StandardError.ReadToEndAsync); + _logger.LogError("Failed to start localstack-test container. Exit code: {ExitCode}. Error: {Error}", _dockerProcess.ExitCode, error); + throw new InvalidOperationException($"Failed to start LocalStack: {error}"); + } + + // Wait for LocalStack to be ready + WaitForLocalStackReady(); + + _isStarted = true; + _ownsContainer = true; // We created this container + + _logger.LogInformation("LocalStack is ready and running in container {ContainerId}", containerId.Trim()); + } + + /// <summary> + /// Creates a DynamoDB table in LocalStack for testing. + /// </summary> + public void CreateTable(string tableName) + { + if (!_isStarted) + { + throw new InvalidOperationException("LocalStack must be started before creating tables"); + } + + _logger.LogInformation("Creating DynamoDB table '{TableName}' in LocalStack...", tableName); + + // Use dummy credentials for LocalStack + Amazon.Runtime.BasicAWSCredentials credentials = new("test", "test"); + + AmazonDynamoDBConfig config = new() + { + ServiceURL = ServiceUrl, + // Don't set RegionEndpoint when using ServiceURL - it overrides the custom endpoint + AuthenticationRegion = Region + }; + + using AmazonDynamoDBClient client = new(credentials, config); + + // Check if table already exists + try + { + WaitHelpers.WaitForAsyncTask(ct => client.DescribeTableAsync(tableName, ct), timeoutInSeconds: 10); + // Table exists, no need to create + return; + } + catch (AggregateException ex) when (ex.InnerException is Amazon.DynamoDBv2.Model.ResourceNotFoundException) + { + // Table doesn't exist, proceed to create + } + + CreateTableRequest request = new() + { + TableName = tableName, + KeySchema = new List<KeySchemaElement> + { + new KeySchemaElement { AttributeName = "UserId", KeyType = KeyType.HASH }, + new KeySchemaElement { AttributeName = "Id", KeyType = KeyType.RANGE } + }, + AttributeDefinitions = new List<AttributeDefinition> + { + new AttributeDefinition { AttributeName = "UserId", AttributeType = ScalarAttributeType.S }, + new AttributeDefinition { AttributeName = "Id", AttributeType = ScalarAttributeType.S } + }, + BillingMode = BillingMode.PAY_PER_REQUEST + }; + + WaitHelpers.WaitForAsyncTask(ct => client.CreateTableAsync(request, ct), timeoutInSeconds: 10); + + _logger.LogInformation("CreateTable request for '{TableName}' sent. Waiting for table to become active...", tableName); + + // Wait for table to be active + bool isActive = WaitHelpers.ExecuteWithRetries(() => + { + try + { + DescribeTableResponse response = WaitHelpers.WaitForAsyncTask(ct => client.DescribeTableAsync(tableName, ct)); + return response.Table.TableStatus == TableStatus.ACTIVE; + } + catch (AggregateException ex) when (ex.InnerException is Amazon.DynamoDBv2.Model.ResourceNotFoundException + || ex.InnerException is OperationCanceledException) + { + return false; + } + }, timeoutInSeconds: 15); + + if (!isActive) + { + _logger.LogError("Table {TableName} did not become active within the expected time.", tableName); + throw new InvalidOperationException($"Table {tableName} did not become active within 15 seconds"); + } + + _logger.LogInformation("DynamoDB table '{TableName}' is created and active.", tableName); + } + + /// <summary> + /// Creates an SQS queue in LocalStack for testing. Idempotent: returns existing queue URL if already present. + /// </summary> + public string CreateSqsQueue(string queueName) + { + if (!_isStarted) + { + throw new InvalidOperationException("LocalStack must be started before creating queues"); + } + + Amazon.Runtime.BasicAWSCredentials credentials = new("test", "test"); + + AmazonSQSConfig config = new() + { + ServiceURL = ServiceUrl, + AuthenticationRegion = Region + }; + + using AmazonSQSClient client = new(credentials, config); + + try + { + _logger.LogInformation("Checking if SQS queue '{QueueName}' already exists in LocalStack...", queueName); + GetQueueUrlResponse existingQueue = WaitHelpers.WaitForAsyncTask(ct => client.GetQueueUrlAsync(queueName, ct)); + + _logger.LogInformation("SQS queue '{QueueName}' already exists with URL: {QueueUrl}", queueName, existingQueue.QueueUrl); + return existingQueue.QueueUrl; + } + catch (AggregateException ex) when (ex.InnerException is QueueDoesNotExistException) + { + // Queue doesn't exist, proceed to create + } + + _logger.LogInformation("Creating SQS queue '{QueueName}' in LocalStack...", queueName); + + CreateQueueResponse response = WaitHelpers.WaitForAsyncTask(ct => client.CreateQueueAsync(new CreateQueueRequest + { + QueueName = queueName + }, ct), timeoutInSeconds: 15); + + _logger.LogInformation("SQS queue '{QueueName}' created with URL: {QueueUrl}", queueName, response.QueueUrl); + + return response.QueueUrl; + } + + /// <summary> + /// Returns the SQS queue URL for the given queue name in LocalStack format. + /// </summary> + public string GetSqsQueueUrl(string queueName) + => $"http://sqs.{Region}.localhost.localstack.cloud:4566/000000000000/{queueName}"; + + /// <summary> + /// Returns true if SQS is enabled in the running LocalStack instance. + /// </summary> + private bool IsSqsEnabled() + { + try + { + using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(5) }; + HttpResponseMessage response = WaitHelpers.WaitForAsyncTask(ct => client.GetAsync($"{ServiceUrl}/_localstack/health", ct)); + if (!response.IsSuccessStatusCode) + { + return false; + } + + string body = WaitHelpers.WaitForAsyncTask(response.Content.ReadAsStringAsync); + using System.Text.Json.JsonDocument json = System.Text.Json.JsonDocument.Parse(body); + + // Top-level "status": "running" means all services are implicitly available + if (json.RootElement.TryGetProperty("status", out System.Text.Json.JsonElement statusEl)) + { + string status = statusEl.GetString() ?? string.Empty; + if (string.Equals(status, "running", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + if (json.RootElement.TryGetProperty("services", out System.Text.Json.JsonElement servicesEl)) + { + if (servicesEl.TryGetProperty("sqs", out System.Text.Json.JsonElement sqsEl)) + { + string sqsStatus = sqsEl.GetString() ?? string.Empty; + return string.Equals(sqsStatus, "available", StringComparison.OrdinalIgnoreCase) + || string.Equals(sqsStatus, "running", StringComparison.OrdinalIgnoreCase); + } + } + + return false; + } + catch + { + return false; + } + } + + private void WaitForLocalStackReady() + { + // Poll LocalStack health endpoint + using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(2) }; + + bool isReady = WaitHelpers.ExecuteWithRetries(() => + { + try + { + HttpResponseMessage response = WaitHelpers.WaitForAsyncTask(ct => client.GetAsync($"{ServiceUrl}/_localstack/health", ct)); + if (response.IsSuccessStatusCode) + { + // Give it a bit more time to fully initialize DynamoDB + Thread.Sleep(2000); + return true; + } + } + catch + { + // Ignore exceptions during startup + } + return false; + }, timeoutInSeconds: 60); + + if (!isReady) + { + _logger.LogError("LocalStack did not become ready within the expected time."); + throw new InvalidOperationException("LocalStack did not become ready within 60 seconds"); + } + } + + public void Dispose() + { + // Only stop the container if we created it + if (_ownsContainer && _dockerProcess != null) + { + _logger.LogInformation("Stopping localstack-test container..."); + + // Stop and remove the container + Process stopProcess = new() + { + StartInfo = new ProcessStartInfo + { + FileName = "docker", + Arguments = "stop localstack-test", + UseShellExecute = false, + CreateNoWindow = true + } + }; + + stopProcess.Start(); + stopProcess.WaitForExit(10000); + stopProcess.Dispose(); + + _dockerProcess.Dispose(); + } + + GC.SuppressFinalize(this); + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/ServiceFixture.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/ServiceFixture.cs new file mode 100644 index 00000000..359575a0 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/ServiceFixture.cs @@ -0,0 +1,196 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; +using AdaptiveRemote.TestUtilities; +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.EndToEndTests.TestServices.Backend; +/// <summary> +/// Manages the lifecycle of backend services for API integration tests. +/// Starts the service process and captures structured log output. +/// </summary> +public class ServiceFixture : IDisposable +{ + private Process? _serviceProcess; + private readonly string _serviceName; + private readonly ISimulatedEnvironment _environment; + private readonly IReadOnlyDictionary<string, string>? _environmentVariables; + private readonly ILogger _logger; + + public string? LogFilePath { get; } + + public string ServiceUrl { get; } + + public ServiceFixture(string serviceName, ISimulatedEnvironment environment, Dictionary<string, string>? environmentVariables = null) + { + _environmentVariables = environmentVariables; + ServiceUrl = $"http://localhost:{GetFreePort()}"; + _serviceName = serviceName; + _environment = environment; + + LogFilePath = _environment.LogFolder is null + ? null + : Path.Combine(_environment.LogFolder, $"{serviceName}_{DateTime.Now:yyyyMMdd_HHmmss}.log)"); + + _logger = environment.LoggerFactory.CreateLogger(serviceName + "Fixture"); + } + + public void StartService() + { + if (_serviceProcess != null) + { + return; // Already started + } + + _logger.LogInformation("Initializing {ServiceName} fixture", _serviceName); + + // Find the repository root by looking for the .git directory + string currentDir = Directory.GetCurrentDirectory(); + string? repoRoot = currentDir; + while (repoRoot != null && !Directory.Exists(Path.Combine(repoRoot, ".git"))) + { + repoRoot = Directory.GetParent(repoRoot)?.FullName; + } + + if (repoRoot == null) + { + _logger.LogError("Could not find repository root (no .git directory found)"); + throw new InvalidOperationException("Could not find repository root (no .git directory found)"); + } + + string projectPath = Path.Combine( + repoRoot, + "src", _serviceName, + $"{_serviceName}.csproj"); + + if (!File.Exists(projectPath)) + { + _logger.LogError("Project file not found at: {ProjectPath}", projectPath); + throw new InvalidOperationException($"Project file not found at: {projectPath}"); + } + + _logger.LogInformation("Found project file for {ServiceName} at: {ProjectPath}", _serviceName, projectPath); + + ProcessStartInfo startInfo = new() + { + FileName = "dotnet", + // --no-launch-profile prevents launchSettings.json from overriding + // ASPNETCORE_URLS with its applicationUrl setting. + Arguments = $"run --project \"{projectPath}\" --no-build --no-launch-profile --logFile \"{LogFilePath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + Environment = + { + ["ASPNETCORE_ENVIRONMENT"] = "Development", + ["ASPNETCORE_URLS"] = ServiceUrl, + // Point the service at the local test JWT authority. + ["Cognito__Authority"] = _environment.JwtAuthority.Authority, + // Use the same local test authority host for LocalStack health checks. + ["LocalStack__BaseUrl"] = _environment.JwtAuthority.Authority, + + // Configure AWS resources for services that need LocalStack + ["AWS_ACCESS_KEY_ID"] = "test", + ["AWS_SECRET_ACCESS_KEY"] = "test", + + // ASP.NET Core Data Protection logs a development-only warning about + // unencrypted key persistence that is unrelated to the service behavior + // these API tests are exercising. Treat it as non-actionable noise so + // log-cleanliness assertions remain focused on service regressions. + ["Logging__LogLevel__Microsoft.AspNetCore.DataProtection"] = "Error", + + // Disable the SQS polling background service so health-check-only tests do not + // trigger the orchestration pipeline and log errors against unconfigured upstreams. + ["Orchestrator__Enabled"] = "false", + } + }; + + if (_serviceName == "AdaptiveRemote.Backend.RawLayoutService") + { + // Configure DynamoDB for RawLayoutService + startInfo.Environment["DynamoDB__ServiceUrl"] = _environment.LocalStack.ServiceUrl; + startInfo.Environment["DynamoDB__Region"] = _environment.LocalStack.Region; + startInfo.Environment["DynamoDB__TableName"] = "RawLayouts"; + } + + if (_serviceName == "AdaptiveRemote.Backend.LayoutProcessingService") + { + // Configure SQS for LayoutProcessingService + startInfo.Environment["Sqs__ServiceUrl"] = _environment.LocalStack.ServiceUrl; + startInfo.Environment["Sqs__QueueUrl"] = _environment.LocalStack.GetSqsQueueUrl("LayoutProcessingQueue"); + startInfo.Environment["Sqs__Region"] = _environment.LocalStack.Region; + } + + if (_environmentVariables is not null) + { + foreach (KeyValuePair<string, string> envVar in _environmentVariables) + { + startInfo.Environment.Add(envVar.Key, envVar.Value); + } + } + + _serviceProcess = new Process { StartInfo = startInfo }; + _serviceProcess.Start(); + + // Poll /health with a temporary unauthenticated client (/health is open). + // Use a short per-request timeout so a slow/stuck response doesn't block the loop. + using HttpClient healthClient = new() + { + BaseAddress = new Uri(ServiceUrl), + Timeout = TimeSpan.FromSeconds(5), + }; + + int i = 0; + bool isReady = WaitHelpers.ExecuteWithRetries(() => + { + try + { + HttpResponseMessage response = WaitHelpers.WaitForAsyncTask(ct => healthClient.GetAsync("/health", ct)); + if (response.IsSuccessStatusCode) + { + return true; + } + + _logger.LogWarning("Health check attempt {Attempt} failed with HTTP {StatusCode} from {ServiceUrl}/health", ++i, (int)response.StatusCode, ServiceUrl); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Health check attempt {Attempt} failed polling {ServiceUrl}/health", ++i, ServiceUrl); + } + + return false; + }); + + if (!isReady) + { + _logger.LogError("Service failed to start within 30 seconds (polling {ServiceUrl}/health).", ServiceUrl); + throw new InvalidOperationException($"Service failed to start within 30 seconds (polling {ServiceUrl}/health)."); + } + + _logger.LogInformation("{ServiceName} is ready and responding to health checks at {ServiceUrl}/health", _serviceName, ServiceUrl); + } + + public void Dispose() + { + if (_serviceProcess != null && !_serviceProcess.HasExited) + { + _serviceProcess.Kill(entireProcessTree: true); + _serviceProcess.WaitForExit(5000); + _serviceProcess.Dispose(); + } + + // LocalStack is shared across all scenarios; do not dispose it here. + GC.SuppressFinalize(this); + } + + private static int GetFreePort() + { + using TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/TestJwtAuthority.cs similarity index 98% rename from test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs rename to test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/TestJwtAuthority.cs index dcba9a7c..a5ba79a4 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/TestJwtAuthority.cs @@ -5,7 +5,7 @@ using System.Text.Json; using Microsoft.IdentityModel.Tokens; -namespace AdaptiveRemote.Backend.ApiTests.Support; +namespace AdaptiveRemote.EndToEndTests.TestServices.Backend; /// <summary> /// A minimal local OIDC/JWKS authority used by API integration tests to issue and @@ -60,7 +60,7 @@ public string CreateToken(string sub) /// <summary> /// Creates a signed JWT that is already expired (issued/expiry in the past). /// </summary> - public string CreateExpiredToken(string sub = "test-user") + public string CreateExpiredToken(string sub) => CreateTokenCore(sub, expired: true); private string CreateTokenCore(string sub, bool expired) diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/BlazorWebViewUITestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/BlazorWebViewUITestService.cs index 18197850..7b4cb54d 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/BlazorWebViewUITestService.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/BlazorWebViewUITestService.cs @@ -1,4 +1,5 @@ using AdaptiveRemote.Services.Testing; +using AdaptiveRemote.TestUtilities; using Microsoft.Extensions.Logging; using Microsoft.Playwright; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.Builder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.Builder.cs index 99409c9f..66782ec0 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.Builder.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.Builder.cs @@ -3,6 +3,7 @@ using System.Text; using AdaptiveRemote.EndtoEndTests.Logging; using AdaptiveRemote.Services.Testing; +using AdaptiveRemote.TestUtilities; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using StreamJsonRpc; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.cs index 6033dc49..dd226e9f 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.cs @@ -2,6 +2,7 @@ using System.Net.Sockets; using System.Text; using AdaptiveRemote.Services.Testing; +using AdaptiveRemote.TestUtilities; using Microsoft.Extensions.Logging; using StreamJsonRpc; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs index 4a216938..548599c5 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs @@ -1,5 +1,7 @@ using AdaptiveRemote.EndtoEndTests.Host; using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; +using AdaptiveRemote.EndToEndTests.TestServices.Backend; +using Microsoft.Extensions.Logging; namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; @@ -18,6 +20,16 @@ public interface ISimulatedEnvironment : IDisposable /// </summary> ISimulatedBroadlinkDevice Broadlink { get; } + TestJwtAuthority JwtAuthority { get; } + + ServiceFixture RawLayoutService { get; } + + ServiceFixture CompiledLayoutService { get; } + + ServiceFixture LayoutProcessingService { get; } + + LocalStackFixture LocalStack { get; } + void EnsureHostStarted(); void StartHost(); @@ -28,10 +40,20 @@ public interface ISimulatedEnvironment : IDisposable string? HostLogs { get; } + string? RawLayoutServiceLogs { get; } + + string? CompiledLayoutServiceLogs { get; } + + string? LayoutProcessingServiceLogs { get; } + /// <summary> /// Gets the test-time IR payloads that are programmed into the settings file. /// Keys are command names (e.g. "Power"); values are the raw IR bytes. /// Commands not present in this dictionary are not programmed and should be disabled. /// </summary> IReadOnlyDictionary<string, byte[]> TestIrPayloads { get; } + + string? LogFolder { get; } + + ILoggerFactory LoggerFactory { get; } } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs index 68d170a4..4784e3dd 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs @@ -1,6 +1,9 @@ using AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; +using AdaptiveRemote.EndToEndTests.TestServices.Backend; using AdaptiveRemote.Services.Conversation; +using AdaptiveRemote.TestUtilities; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace AdaptiveRemote.EndtoEndTests.Host; @@ -32,11 +35,12 @@ public sealed class SimulatedEnvironment : ISimulatedEnvironment // Settings file path is determined lazily from the TestResults directory when SetLogLocation is first called. private string? _testSettingsPath; - public SimulatedEnvironment(SimulatedTiVoDeviceBuilder tivoBuilder, SimulatedBroadlinkDeviceBuilder broadlinkBuilder, AdaptiveRemoteHost.Builder hostBuilder) + public SimulatedEnvironment(SimulatedTiVoDeviceBuilder tivoBuilder, SimulatedBroadlinkDeviceBuilder broadlinkBuilder, AdaptiveRemoteHost.Builder hostBuilder, ILoggerFactory loggerFactory) { _tivo = tivoBuilder.Start(); _broadlink = broadlinkBuilder.Start(); _hostBuilder = hostBuilder; + LoggerFactory = loggerFactory; List<string> args = [ @@ -57,6 +61,11 @@ public SimulatedEnvironment(SimulatedTiVoDeviceBuilder tivoBuilder, SimulatedBro // Always inject TestSpeechSynthesis so tests can verify spoken phrases without audio devices await testEndpoint.InjectTestServiceAsync<ISpeechSynthesis, TestSpeechSynthesis>(ct); }); + + _lazyCompiledLayoutService = new(StartCompiledLayoutService); + _lazyRawLayoutService = new(StartRawLayoutService); + _lazyLayoutProcessingService = new(StartLayoutProcessingService); + _lazyLocalStackFixture = new(StartLocalStack); } /// <inheritdoc/> @@ -65,6 +74,60 @@ public SimulatedEnvironment(SimulatedTiVoDeviceBuilder tivoBuilder, SimulatedBro /// <inheritdoc/> public ISimulatedBroadlinkDevice Broadlink => _broadlink; + private Lazy<ServiceFixture> _lazyRawLayoutService; + public ServiceFixture RawLayoutService => _lazyRawLayoutService.Value; + + private Lazy<ServiceFixture> _lazyCompiledLayoutService; + public ServiceFixture CompiledLayoutService => _lazyCompiledLayoutService.Value; + + private Lazy<ServiceFixture> _lazyLayoutProcessingService; + public ServiceFixture LayoutProcessingService => _lazyLayoutProcessingService.Value; + + private Lazy<LocalStackFixture> _lazyLocalStackFixture; + public LocalStackFixture LocalStack => _lazyLocalStackFixture.Value; + + public TestJwtAuthority JwtAuthority { get; } = new(); + + private ServiceFixture StartRawLayoutService() + { + ServiceFixture fixture = new ServiceFixture("AdaptiveRemote.Backend.RawLayoutService", this); + fixture.StartService(); + return fixture; + } + + private ServiceFixture StartCompiledLayoutService() + { + ServiceFixture fixture = new ServiceFixture("AdaptiveRemote.Backend.CompiledLayoutService", this); + fixture.StartService(); + return fixture; + } + + private ServiceFixture StartLayoutProcessingService() + { + ServiceFixture fixture = new ServiceFixture("AdaptiveRemote.Backend.LayoutProcessingService", this, new() + { + ["RawLayoutService__BaseUrl"] = RawLayoutService.ServiceUrl, + ["RawLayoutService__ServiceAccountToken"] = JwtAuthority.CreateToken("service-account-layout-processor"), + ["CompiledLayoutService__BaseUrl"] = CompiledLayoutService.ServiceUrl, + + // Enable the orchestrator for pipeline tests + ["Orchestrator__Enabled"] = "true", + }); + fixture.StartService(); + return fixture; + } + + private LocalStackFixture StartLocalStack() + { + LocalStackFixture fixture = new LocalStackFixture(LoggerFactory); + + fixture.Start(); + fixture.CreateSqsQueue("LayoutProcessingQueue"); + fixture.CreateTable("RawLayouts"); + + return fixture; + } + /// <inheritdoc/> public IReadOnlyDictionary<string, byte[]> TestIrPayloads => _testIrPayloads; @@ -80,6 +143,18 @@ public AdaptiveRemoteHost Host public string? HostLogs => _currentLogLocation; + public string? RawLayoutServiceLogs => _lazyRawLayoutService.IsValueCreated ? _lazyRawLayoutService.Value.LogFilePath : null; + + public string? CompiledLayoutServiceLogs => _lazyCompiledLayoutService.IsValueCreated ? _lazyCompiledLayoutService.Value.LogFilePath : null; + + public string? LayoutProcessingServiceLogs => _lazyLayoutProcessingService.IsValueCreated ? _lazyLayoutProcessingService.Value.LogFilePath : null; + + public string? LogFolder => _nextLogLocation is not null + ? Path.GetDirectoryName(_nextLogLocation) + : null; + + public ILoggerFactory LoggerFactory { get; } + /// <inheritdoc/> public void Dispose() { @@ -115,6 +190,54 @@ public void Dispose() // Ignore disposal errors } + try + { + if (_lazyCompiledLayoutService.IsValueCreated) + { + _lazyCompiledLayoutService.Value.Dispose(); + } + } + catch + { + // Ignore disposal errors + } + + try + { + if (_lazyRawLayoutService.IsValueCreated) + { + _lazyRawLayoutService.Value.Dispose(); + } + } + catch + { + // Ignore disposal errors + } + + try + { + if (_lazyLayoutProcessingService.IsValueCreated) + { + _lazyLayoutProcessingService.Value.Dispose(); + } + } + catch + { + // Ignore disposal errors + } + + try + { + if (_lazyLocalStackFixture.IsValueCreated) + { + _lazyLocalStackFixture.Value.Dispose(); + } + } + catch + { + // Ignore disposal errors + } + _disposed = true; } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs index 9d3e5a2f..6df7cebf 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs @@ -1,4 +1,5 @@ using AdaptiveRemote.Services.Testing; +using AdaptiveRemote.TestUtilities; using FluentAssertions; namespace AdaptiveRemote.EndtoEndTests; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestEndpointExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestEndpointExtensions.cs index 905a460e..75414072 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestEndpointExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestEndpointExtensions.cs @@ -1,4 +1,5 @@ using AdaptiveRemote.Services.Testing; +using AdaptiveRemote.TestUtilities; namespace AdaptiveRemote.EndtoEndTests; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs index e3565d4d..066862e0 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using AdaptiveRemote.Services.Testing; +using AdaptiveRemote.TestUtilities; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace AdaptiveRemote.EndtoEndTests; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/HostApplicationLoggerProvider.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/HostApplicationLoggerProvider.cs index bf25a3e7..c9a604db 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/HostApplicationLoggerProvider.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/HostApplicationLoggerProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using AdaptiveRemote.Services.Testing; +using AdaptiveRemote.TestUtilities; using Microsoft.Extensions.Logging; namespace AdaptiveRemote.EndtoEndTests.Logging; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestResultFileHelper.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestResultFileHelper.cs new file mode 100644 index 00000000..33c8f76d --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestResultFileHelper.cs @@ -0,0 +1,29 @@ +using System; +using System.IO; +using System.Threading; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AdaptiveRemote.EndtoEndTests.Logging; + +public static class TestResultFileHelper +{ + public static void AttachResultFileIfExists(string? filePath, TestContext? testContext) + { + if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath) && testContext != null) + { + // Retry a few times in case the file is still being written + for (int i = 0; i < 3; i++) + { + try + { + testContext.AddResultFile(filePath); + break; + } + catch (IOException) when (i < 2) + { + Thread.Sleep(100); + } + } + } + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDeviceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDeviceExtensions.cs index 9931f9c6..0d9834ec 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDeviceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/ISimulatedBroadlinkDeviceExtensions.cs @@ -1,3 +1,5 @@ +using AdaptiveRemote.TestUtilities; + namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; /// <summary> diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs index 51a6cc1c..1857e2e1 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedBroadlink/SimulatedBroadlinkDevice.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; +using AdaptiveRemote.TestUtilities; using Microsoft.Extensions.Logging; namespace AdaptiveRemote.EndtoEndTests.SimulatedBroadlink; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs index 8e8e5add..9ce16714 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Sockets; using System.Text; +using AdaptiveRemote.TestUtilities; using Microsoft.Extensions.Logging; namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/TestClient.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/TestClient.cs new file mode 100644 index 00000000..f69a1ae3 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/TestClient.cs @@ -0,0 +1,101 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using AdaptiveRemote.TestUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AdaptiveRemote.EndToEndTests.TestServices; + +public class TestClient +{ + private HttpClient _httpClient = new(); + + public string AuthorizationToken { get; set; } = string.Empty; + + private static int NextClientID = 1; + private readonly int _clientID = NextClientID++; + private readonly ILogger<TestClient> _log; + private int _requestCount = 0; + + private HttpResponseMessage? _lastResponseMessage; + private string? _lastResponseBody; + private object? _lastParsedObject = null; + + public TestClient(ILoggerFactory loggerFactory) + { + _log = loggerFactory.CreateLogger<TestClient>(); + } + + public HttpResponseMessage LastResponse => _lastResponseMessage + ?? throw new AssertFailedException("No request has been sent yet."); + public string LastResponseBody => _lastResponseBody + ?? throw new AssertFailedException("No request has been sent yet."); + public object LastResponseObject => _lastParsedObject + ?? throw new AssertFailedException("The response body has not been deserialized yet. Ensure that the step 'the response body represents a {JsonTypeInfo}' is called before this step."); + + public HttpResponseMessage? SendRequest(HttpMethod method, Uri url, string? body = null) + { + int requestNumber = ++_requestCount; + _log.LogInformation( + """ + Client {ClientID} sending request #{RequestNumber}: + {Method} {Url} + {Body} + """, + requestNumber, + _clientID, + method.Method, + url, + body); + + HttpRequestMessage request = new(method, url); + + if (!string.IsNullOrEmpty(body)) + { + request.Content = new StringContent(body); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + } + + if (!string.IsNullOrEmpty(AuthorizationToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AuthorizationToken); + } + + _lastParsedObject = null; + _lastResponseMessage = WaitHelpers.WaitForAsyncTask(ct => _httpClient.SendAsync(request, ct)); + _lastResponseBody = WaitHelpers.WaitForAsyncTask(LastResponse.Content.ReadAsStringAsync); + + _log.LogInformation( + """ + Client {ClientID} received response for request #{RequestNumber}: + {StatusCode} {ResponsePhrase} + {ResponseBody} + """, + _clientID, + requestNumber, + (int)LastResponse.StatusCode, + LastResponse.ReasonPhrase, + LastResponse.Content.ReadAsStringAsync().Result); + + return LastResponse; + } + + public void ParseResponseAs(JsonTypeInfo jsonTypeInfo) + { + Assert.IsNotNull(LastResponseBody, "No response body to parse. Make sure to call SendRequest first and that the response has a body."); + + try + { + _lastParsedObject = JsonSerializer.Deserialize(LastResponseBody, jsonTypeInfo); + Assert.IsNotNull(_lastParsedObject, "Deserialization returned null. Response body may be empty or not match the expected format."); + } + catch (JsonException ex) + { + Assert.Fail("Failed to parse the response body as JSON. {0}", ex.Message); + throw; + } + } + + public override string ToString() => $"Client {_clientID}"; +} diff --git a/test/AdaptiveRemote.TestUtilities/AdaptiveRemote.TestUtilities.csproj b/test/AdaptiveRemote.TestUtilities/AdaptiveRemote.TestUtilities.csproj index 2cff18de..72dd376a 100644 --- a/test/AdaptiveRemote.TestUtilities/AdaptiveRemote.TestUtilities.csproj +++ b/test/AdaptiveRemote.TestUtilities/AdaptiveRemote.TestUtilities.csproj @@ -12,7 +12,7 @@ <ItemGroup> <PackageReference Include="FluentAssertions" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Moq" /> <PackageReference Include="MSTest.TestAdapter" /> <PackageReference Include="MSTest.TestFramework" /> diff --git a/test/AdaptiveRemote.TestUtilities/HttpClientExtensions.cs b/test/AdaptiveRemote.TestUtilities/HttpClientExtensions.cs new file mode 100644 index 00000000..daadd1ce --- /dev/null +++ b/test/AdaptiveRemote.TestUtilities/HttpClientExtensions.cs @@ -0,0 +1,10 @@ +using AdaptiveRemote.TestUtilities; + +namespace AdaptiveRemote.TestUtilities; + +public static class HttpClientExtensions +{ + public static string ReadContentAsString(this HttpResponseMessage response) + => WaitHelpers.WaitForAsyncTask(response.Content.ReadAsStringAsync); + +} diff --git a/test/AdaptiveRemote.TestUtilities/MockLogger.cs b/test/AdaptiveRemote.TestUtilities/MockLogger.cs new file mode 100644 index 00000000..5f96b70a --- /dev/null +++ b/test/AdaptiveRemote.TestUtilities/MockLogger.cs @@ -0,0 +1,145 @@ +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.TestUtilities; + +public class MockLogger<LoggerType1, LoggerType2> : MockLogger<LoggerType1>, ILogger<LoggerType2> +{ +} + +public class MockLogger<LoggerType> : ILogger<LoggerType> +{ + private readonly List<string> _messages = new(); + private readonly object _lock = new(); + private Exception? _assertException = null; + + public IEnumerable<string> Messages => _messages; + public TestContext? OutputWriter { get; set; } + + public List<(string find, string replace)> ReplaceStrings = new(); + + IDisposable? ILogger.BeginScope<TState>(TState state) => throw new NotImplementedException(); + bool ILogger.IsEnabled(LogLevel logLevel) => true; + void ILogger.Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) + { + if (exception is AssertFailedException || + exception is AssertInconclusiveException || + exception is Moq.MockException) + { + _assertException = _assertException ?? exception; + return; + } + + string message = $"{logLevel}[{eventId.Id}]: {formatter(state, exception)}"; + foreach ((string find, string replace) in ReplaceStrings) + { + message = message.Replace(find, replace); + } + lock (_lock) + { + _messages.Add(message); + } + OutputWriter?.WriteLine(message); + } + + public void VerifyMessages(params string[] expected) + { + // Retry a few times, in case messages are still being logged on a background thread + for (int i = 0; i < 10; i++) + { + if (_assertException is not null) + { + throw _assertException; + } + + if (_messages.Count >= expected.Length) + { + break; + } + + Thread.Sleep(i * 5); + } + + IEnumerator<string> expectedIter = expected.AsEnumerable().GetEnumerator(); + List<string>.Enumerator actualIter = _messages.GetEnumerator(); + + int count = 0; + + while (expectedIter.MoveNext()) + { + if (!actualIter.MoveNext()) + { + int expectedCount = count; + List<string> missingMessages = GetRemaining(expectedIter, ref expectedCount); + Assert.AreEqual(expectedCount, count, "Wrong number of messages. Did not find:\n{0}", + string.Join("\n", missingMessages)); + } + + if (!actualIter.Current.StartsWith(expectedIter.Current)) + { + Assert.AreEqual($"\n{expectedIter.Current}", $"\n{actualIter.Current}", "MockLogger.Messages[{0}]", count); + } + + count++; + } + + if (actualIter.MoveNext()) + { + List<string> unexpectedMessages = GetRemaining(actualIter, ref count); + Assert.AreEqual(expected.Length, count, + "Wrong number of messages. Did not expect to find:\n{0}", + string.Join("\n", unexpectedMessages)); + } + } + + private static List<string> GetRemaining(IEnumerator<string> iter, ref int count) + { + List<string> remaining = new(); + + do + { + remaining.Add($"[{count}]: {iter.Current}"); + count++; + } while (iter.MoveNext()); + + return remaining; + } + + public Task WaitForMessageAsync(string expectedMessage) + => WaitForMessageAsync(expectedMessage, TimeSpan.FromSeconds(5)); + public async Task WaitForMessageAsync(string expectedMessage, TimeSpan timeout) + { + DateTime startTime = DateTime.Now; + + bool found = false; + while (!found) + { + if (_assertException is not null) + { + throw _assertException; + } + + List<string> messages; + lock (_lock) + { + messages = _messages.ToList(); // Make a copy + } + foreach (string message in messages) + { + if (message.StartsWith(expectedMessage)) + { + found = true; + break; + } + } + + await Task.Delay(100); + + Assert.IsTrue(DateTime.Now - startTime < timeout, "Timed out waiting for log message '{0}'", expectedMessage); + } + } + + public void ClearMessages() + { + _messages.Clear(); + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/WaitHelpers.cs b/test/AdaptiveRemote.TestUtilities/WaitHelpers.cs similarity index 98% rename from test/AdaptiveRemote.EndtoEndTests.TestServices/WaitHelpers.cs rename to test/AdaptiveRemote.TestUtilities/WaitHelpers.cs index 8363a4cd..0b60b337 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/WaitHelpers.cs +++ b/test/AdaptiveRemote.TestUtilities/WaitHelpers.cs @@ -1,4 +1,4 @@ -namespace AdaptiveRemote.EndtoEndTests; +namespace AdaptiveRemote.TestUtilities; public static class WaitHelpers { From 2f76b64c86636d2b24b69ef134372d083aebba30 Mon Sep 17 00:00:00 2001 From: Joe Davis <jodasoft@outlook.com> Date: Tue, 12 May 2026 20:12:48 -0700 Subject: [PATCH 11/11] Remove the filter from the API test run to make sure they all run --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 07b50a50..771423fb 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -90,7 +90,7 @@ jobs: run: docker pull localstack/localstack:3.0 - name: API Integration Tests - run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" ./test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj --filter "TestCategory=ApiIntegrationTest" -m:1 + run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" ./test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj -m:1 - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action@v2