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/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 16b90c77..771423fb 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 @@ -39,11 +40,11 @@ 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 - if: always() + if: always() && steps.build.outcome == 'success' - name: Upload E2E Test Logs uses: actions/upload-artifact@v6 @@ -60,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" ./test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj -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 a63a24a4..452270af 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}" @@ -46,6 +48,26 @@ 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 +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 +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 @@ -68,6 +90,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 @@ -188,6 +222,102 @@ 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 + {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 + {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 + {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 @@ -202,6 +332,14 @@ 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} + {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 05f7ac74..5889e767 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 @@ -47,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 12dcfc3e..ae5ea6fc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,33 +3,44 @@ <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> </PropertyGroup> <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" /> <!-- ASP.NET Core and Web Components --> <PackageVersion Include="Markdig" Version="0.40.0" /> + <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" /> <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.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" /> <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" /> <!-- 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" /> </ItemGroup> <ItemGroup Label="Test-Only Packages"> + <!-- JWT token creation for API integration tests --> + <PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" /> <!-- Test Frameworks --> <PackageVersion Include="MSTest" Version="3.1.1" /> <PackageVersion Include="MSTest.TestAdapter" Version="3.1.1" /> @@ -42,10 +53,6 @@ <PackageVersion Include="Deque.AxeCore.Commons" Version="4.11.2" /> <PackageVersion Include="Deque.AxeCore.Playwright" Version="4.11.2" /> <PackageVersion Include="FluentAssertions" Version="8.9.0" /> - <PackageVersion Include="coverlet.collector" Version="10.0.0" /> - <PackageVersion Include="Deque.AxeCore.Commons" Version="4.11.1" /> - <PackageVersion Include="Deque.AxeCore.Playwright" Version="4.11.1" /> - <PackageVersion Include="FluentAssertions" Version="8.9.0" /> <PackageVersion Include="Moq" Version="4.20.70" /> </ItemGroup> </Project> \ No newline at end of file diff --git a/backend.slnf b/backend.slnf new file mode 100644 index 00000000..57da75f3 --- /dev/null +++ b/backend.slnf @@ -0,0 +1,15 @@ +{ + "solution": { + "path": "AdaptiveRemote.sln", + "projects": [ + "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/client.slnf b/client.slnf new file mode 100644 index 00000000..760a249c --- /dev/null +++ b/client.slnf @@ -0,0 +1,20 @@ +{ + "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", + "test\\AdaptiveRemote.TestUtilities\\AdaptiveRemote.TestUtilities.csproj" + ] + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..257ee7a2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,99 @@ +version: '3.8' + +services: + localstack: + image: localstack/localstack:1.4.0 + ports: + - "4566:4566" + environment: + - 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-init:/etc/localstack/init/ready.d" + - "/var/run/docker.sock:/var/run/docker.sock" + networks: + - backend + + compiledlayoutservice: + build: + context: . + dockerfile: src/AdaptiveRemote.Backend.CompiledLayoutService/Dockerfile + ports: + - "8080:8080" + 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 + + 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 + # 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 + depends_on: + - localstack + 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 + +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/localstack-init/01-create-tables.sh b/localstack-init/01-create-tables.sh new file mode 100755 index 00000000..2e9ad8a0 --- /dev/null +++ b/localstack-init/01-create-tables.sh @@ -0,0 +1,43 @@ +#!/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" + +# 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/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 @@ <InternalsVisibleTo Include="AdaptiveRemote.EndtoEndTests.TestServices" /> </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj" /> + </ItemGroup> + <ItemGroup> <PackageReference Include="Azure.Identity" /> <PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" /> 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<BackendSettings>(configuration.GetSection(SettingsKeys.Backend)) + .AddSingleton<ICognitoTokenService, CognitoTokenService>(); +} 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 <see cref="Services.Broadlink.IRDataSettings"/> IR command payloads. /// </summary> public const string IRData = "irdata"; + + /// <summary> + /// Configuration section for <see cref="Services.Backend.BackendSettings"/> backend service settings. + /// </summary> + 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; + +/// <summary> +/// Configuration for the AdaptiveRemote backend services. +/// Maps to the "backend" section in appsettings.json. +/// </summary> +public class BackendSettings +{ + /// <summary> + /// Base URL of the backend API (e.g. https://api.adaptiveremote.example.com for + /// production, or http://localhost:8080 for local development). + /// </summary> + public string BaseUrl { get; set; } = string.Empty; + + /// <summary> + /// Cognito OAuth2 client credentials for the client application. + /// </summary> + public CognitoClientSettings Cognito { get; set; } = new(); +} + +/// <summary> +/// 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. +/// </summary> +public class CognitoClientSettings +{ + /// <summary> + /// The Cognito user pool authority URL, e.g. + /// https://cognito-idp.{region}.amazonaws.com/{userPoolId} + /// Used to discover the token endpoint via OIDC configuration. + /// </summary> + public string Authority { get; set; } = string.Empty; + + /// <summary> + /// The OAuth2 client ID registered in the Cognito user pool for the client application. + /// </summary> + public string ClientId { get; set; } = string.Empty; + + /// <summary> + /// The OAuth2 client secret. Store in user secrets or environment variables — + /// never commit to source control. + /// </summary> + public string ClientSecret { get; set; } = string.Empty; + + /// <summary> + /// OAuth2 scope(s) to request, space-separated (e.g. "adaptiveremote/layouts.read"). + /// Leave empty to omit the scope parameter. + /// </summary> + 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; + +/// <summary> +/// 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 <see cref="ExpiryBuffer"/> of expiring, +/// at which point a new token is acquired. +/// </summary> +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<BackendSettings> settings, + ILogger<CognitoTokenService> logger) + { + _settings = settings.Value; + _httpClient = new HttpClient(); + _log = new MessageLogger(logger); + } + + public async Task<string> 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<string> 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<KeyValuePair<string, string>> 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; + +/// <summary> +/// 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. +/// </summary> +public interface ICognitoTokenService +{ + /// <summary> + /// 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. + /// </summary> + Task<string> GetAccessTokenAsync(CancellationToken cancellationToken); +} 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 new file mode 100644 index 00000000..84f6068f --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.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.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> + <ProjectReference Include="..\AdaptiveRemote.Backend.Common\AdaptiveRemote.Backend.Common.csproj" /> + <ProjectReference Include="..\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj" /> + </ItemGroup> + +</Project> 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; + +/// <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.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..03eafd66 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using AdaptiveRemote.Backend.Common.Logging; +using AdaptiveRemote.Contracts; +using Microsoft.OpenApi; + +namespace AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; + +public static class HealthEndpoints +{ + public static void MapHealthEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/health", GetHealth) + .WithName(nameof(GetHealth)) + .Produces<HealthResponse>(StatusCodes.Status200OK); + } + + private static IResult GetHealth(ILogger<Program> logger) + { + using IDisposable scope = logger.StartRequestScope("GET", "/health"); + + string? version = Assembly.GetExecutingAssembly() + .GetCustomAttribute<AssemblyInformationalVersionAttribute>() + ?.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..9fd0e325 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs @@ -0,0 +1,68 @@ +using System.Security.Claims; +using AdaptiveRemote.Backend.Common.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<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( + ClaimsPrincipal user, + ILogger<Program> logger, + ICompiledLayoutRepository repository, + CancellationToken cancellationToken) + { + 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(); + } + + using IDisposable scope = logger.StartRequestScope("GET", "/layouts/compiled/active", userId); + + CompiledLayout? layout = await repository.GetActiveForUserAsync(userId, cancellationToken); + + if (layout == null) + { + return Results.NotFound(); + } + + logger.ReturningActiveLayout(layout.Id); + + return Results.Json( + layout, + LayoutContractsJsonContext.Default.CompiledLayout); + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs new file mode 100644 index 00000000..fc8db7a6 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -0,0 +1,202 @@ +using System.Text.Json; +using AdaptiveRemote.Backend.Common.Logging; +using AdaptiveRemote.Backend.CompiledLayoutService.Configuration; +using AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; +using AdaptiveRemote.Backend.CompiledLayoutService.Services; +using AdaptiveRemote.Contracts; +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); +} + +// Register services +builder.Services.AddSingleton<ICompiledLayoutRepository, HardcodedLayoutProvider>(); + +// 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("CompiledLayoutService"); + +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("CompiledLayoutService", 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", "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 new file mode 100644 index 00000000..6c3e8839 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json @@ -0,0 +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" + } + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs new file mode 100644 index 00000000..3524eb58 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs @@ -0,0 +1,114 @@ +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.CompiledLayoutService.Services; + +/// <summary> +/// 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. +/// </summary> +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<CompiledLayout?> GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default) + { + List<LayoutElementDto> elements = new List<LayoutElementDto> + { + new LayoutGroupDefinitionDto( + CssId: "DPAD", + Children: new List<LayoutElementDto> + { + 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<LayoutElementDto> + { + 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<LayoutElementDto> + { + 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<LayoutElementDto> + { + 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<LayoutElementDto> + { + 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<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.CompiledLayoutService/_doc_Auth.md b/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md new file mode 100644 index 00000000..ca4d63f2 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md @@ -0,0 +1,118 @@ +# 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.us-east-2.amazonaws.com/us-east-2_65NKvrlha` + +## 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-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 +`.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.us-east-2.amazonaws.com/us-east-2_65NKvrlha", + "clientId": "5g6eqq1v1o7lju703enelssl89", + "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. + +## 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`), +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 new file mode 100644 index 00000000..fc09110a --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "Cognito": { + "Authority": "", + "Audience": "" + }, + "LocalStack": { + "BaseUrl": "http://localhost:4566" + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json new file mode 100644 index 00000000..a013ae07 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cognito": { + "Authority": "", + "Audience": "" + } +} 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 new file mode 100644 index 00000000..fe0d6a04 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/AdaptiveRemote.Backend.RawLayoutService.csproj @@ -0,0 +1,24 @@ +<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" /> + <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.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/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/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..0206068f --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/HealthEndpoints.cs @@ -0,0 +1,33 @@ +using AdaptiveRemote.Backend.Common.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) + { + using IDisposable scope = logger.StartRequestScope("GET", "/health"); + + 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..e8f41f13 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Endpoints/LayoutEndpoints.cs @@ -0,0 +1,296 @@ +using System.Security.Claims; +using AdaptiveRemote.Backend.Common.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(); + + // 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( + ClaimsPrincipal user, + ILogger<Program> logger, + IRawLayoutRepository repository, + CancellationToken cancellationToken) + { + string? userId = user.FindFirst("sub")?.Value; + if (userId is null) + { + return Results.Unauthorized(); + } + + using IDisposable scope = logger.StartRequestScope("GET", "/layouts/raw", 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(); + } + + using IDisposable scope = logger.StartRequestScope("GET", $"/layouts/raw/{id}", userId); + + try + { + RawLayout? layout = await repository.GetAsync(id, cancellationToken); + + if (layout is null) + { + 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(); + } + + using IDisposable scope = logger.StartRequestScope("POST", "/layouts/raw", 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(); + } + + using IDisposable scope = logger.StartRequestScope("PUT", $"/layouts/raw/{id}", userId); + + 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(); + } + + using IDisposable scope = logger.StartRequestScope("DELETE", $"/layouts/raw/{id}", userId); + + 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"); + } + } + + 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/Program.cs b/src/AdaptiveRemote.Backend.RawLayoutService/Program.cs new file mode 100644 index 00000000..cd3e1fe7 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/Program.cs @@ -0,0 +1,329 @@ +using System.Text.Json; +using AdaptiveRemote.Backend.Common.Logging; +using AdaptiveRemote.Backend.RawLayoutService.Configuration; +using AdaptiveRemote.Backend.RawLayoutService.Endpoints; +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.DataProtection; +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); +} + +if (!builder.Environment.IsProduction()) +{ + builder.Services.AddDataProtection() + .UseEphemeralDataProtectionProvider(); +} + +// 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); + +// 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 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 +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(); + +// 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("RawLayoutService"); + +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("RawLayoutService", 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/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/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..8d6742c7 --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.Development.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "DynamoDB": { + "ServiceUrl": "http://localhost:4566", + "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 new file mode 100644 index 00000000..26818f5d --- /dev/null +++ b/src/AdaptiveRemote.Backend.RawLayoutService/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cognito": { + "Authority": "", + "Audience": "" + }, + "DynamoDB": { + "ServiceUrl": "", + "TableName": "RawLayouts", + "Region": "us-east-1" + }, + "Sqs": { + "ServiceUrl": "", + "QueueUrl": "", + "Region": "us-east-1" + } +} 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 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <RootNamespace>AdaptiveRemote.Contracts</RootNamespace> + </PropertyGroup> + +</Project> 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/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; + +/// <summary> +/// Standard health check response for all backend services. +/// </summary> +public record HealthResponse( + string ServiceName, + string Version, + string Status +); 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/ICompiledLayoutRepository.cs b/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs new file mode 100644 index 00000000..b1032c08 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs @@ -0,0 +1,33 @@ +namespace AdaptiveRemote.Contracts; + +/// <summary> +/// Repository interface for compiled layout storage and retrieval. +/// </summary> +public interface ICompiledLayoutRepository +{ + /// <summary> + /// 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/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/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/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 new file mode 100644 index 00000000..787c075a --- /dev/null +++ b/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs @@ -0,0 +1,16 @@ +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(HealthResponse))] +[JsonSerializable(typeof(IReadOnlyList<RawLayout>))] +[JsonSerializable(typeof(IReadOnlyList<CompiledLayout>))] +[JsonSerializable(typeof(IReadOnlyList<RawLayoutElementDto>))] +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<LayoutElementDto> 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<LayoutElementDto> 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<RawLayoutElementDto> 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<RawLayoutElementDto> 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<ValidationIssue> Issues); 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 @@ <EmbeddedResource Include="Services\Conversation\static_grammar.xml" /> </ItemGroup> + <ItemGroup> + <Content Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" /> + <Content Update="appsettings.Development.json" CopyToOutputDirectory="PreserveNewest" /> + </ItemGroup> + <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.Components.WebView.Wpf" /> <PackageReference Include="Microsoft.Extensions.Hosting" /> 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_BackendDevelopment.md b/src/_doc_BackendDevelopment.md new file mode 100644 index 00000000..d76d2785 --- /dev/null +++ b/src/_doc_BackendDevelopment.md @@ -0,0 +1,75 @@ +# Backend Development Guide + +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: + +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. + +## Lambda services + +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/src/_doc_Projects.md b/src/_doc_Projects.md index b786872f..77673b8f 100644 --- a/src/_doc_Projects.md +++ b/src/_doc_Projects.md @@ -33,6 +33,33 @@ 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`. + +## 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). + +### 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/src/_spec_LayoutCustomizationService.md b/src/_spec_LayoutCustomizationService.md new file mode 100644 index 00000000..283c2cfd --- /dev/null +++ b/src/_spec_LayoutCustomizationService.md @@ -0,0 +1,1006 @@ +# 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 `<ProjectName>.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<LayoutElementDto> 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<RawLayoutElementDto> 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<RawLayoutElementDto> 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<LayoutElementDto> 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<ValidationIssue> 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<RawLayout>))] +[JsonSerializable(typeof(IReadOnlyList<CompiledLayout>))] +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<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); +} + +// 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<CompiledLayout> CompileAsync(RawLayout raw, CancellationToken ct); + Task<PreviewLayout> CompilePreviewAsync(IReadOnlyList<RawLayoutElementDto> elements, CancellationToken ct); +} + +// LayoutValidationService — stateless; called by LayoutProcessingService +interface ILayoutValidationClient +{ + Task<ValidationResult> 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<CompiledLayout?> GetActiveForUserAsync(string userId, CancellationToken ct); + Task<IReadOnlyList<CompiledLayout>> ListByUserAsync(string userId, CancellationToken ct); + Task<CompiledLayout?> GetByIdAsync(Guid id, CancellationToken ct); + Task<CompiledLayout> 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<RawLayoutElementDto>; + 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 — 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 +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 + ``` +- [ ] 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 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. + +- [ ] `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<RawLayoutElementDto>`, 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 + ``` +- [ ] 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 — 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 + ``` +- [ ] 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 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. + +- [ ] `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 + ``` +- [ ] 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 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. + +- [ ] `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 + ``` +- [ ] 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) + +--- + +### [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. 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.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 new file mode 100644 index 00000000..0aa80567 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj @@ -0,0 +1,35 @@ +<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.ApiTests</RootNamespace> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="MSTest.TestAdapter" /> + <PackageReference Include="MSTest.TestFramework" /> + <PackageReference Include="Reqnroll" /> + <PackageReference Include="Reqnroll.MSTest" /> + <PackageReference Include="FluentAssertions" /> + </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 new file mode 100644 index 00000000..59015706 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature @@ -0,0 +1,29 @@ +Feature: CompiledLayoutService Authentication + +Scenario: Unauthenticated request is rejected + 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: Request with valid JWT is accepted + Given CompiledLayoutService is running + 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 + 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 + 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 new file mode 100644 index 00000000..23b40e60 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs @@ -0,0 +1,288 @@ +// ------------------------------------------------------------------------------ +// <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 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<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/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.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 6 + 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(); + } + + [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 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("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 12 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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(); + } + + [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 17 +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 18 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 19 + await testRunner.AndAsync("the client has an expired Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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(); + } + + [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 24 +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 25 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 26 + await testRunner.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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(); + } + } +} +#pragma warning restore +#endregion diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature new file mode 100644 index 00000000..ce2f53fc --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature @@ -0,0 +1,31 @@ +Feature: CompiledLayoutService Endpoints + + +Scenario: Get active compiled layout + Given CompiledLayoutService is running + 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 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 new file mode 100644 index 00000000..ec2aec67 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs @@ -0,0 +1,273 @@ +// ------------------------------------------------------------------------------ +// <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 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<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/CompiledLayoutEndpoints.feature.ndjson", 5); + } + + [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 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("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 6 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 7 + 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.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 9 + await testRunner.AndAsync("the response body is valid JSON", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + 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(); + } + } +} +#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..c5e8e2f9 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature @@ -0,0 +1,20 @@ +Feature: Health Endpoints + + +Scenario: Get service health status + Given CompiledLayoutService is running + 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 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 new file mode 100644 index 00000000..e19393c0 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs @@ -0,0 +1,218 @@ +// ------------------------------------------------------------------------------ +// <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 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<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/HealthEndpoints.feature.ndjson", 4); + } + + [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 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("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 6 + 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.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(); + } + } +} +#pragma warning restore +#endregion 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 new file mode 100644 index 00000000..04e1261a --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature @@ -0,0 +1,284 @@ +@ApiIntegrationTest +Feature: RawLayoutService Endpoints + +Scenario: List raw layouts when user has no layouts + Given RawLayoutService is running + 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 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 + 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 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 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 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 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 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 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 + 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 + 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 + 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 + 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 + 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 + 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 new file mode 100644 index 00000000..f65a7549 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/RawLayoutEndpoints.feature.cs @@ -0,0 +1,1025 @@ +// ------------------------------------------------------------------------------ +// <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", 19); + } + + [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.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 7 + 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.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(); + } + + [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 = "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 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("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 28 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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 54 + await testRunner.ThenAsync("the response is 201 Created", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 55 + await testRunner.AndAsync("the response body is valid JSON", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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(); + } + + [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 = "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 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.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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 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 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(); + } + + [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 = "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 89 +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 90 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 91 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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 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 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 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(); + } + + [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 = "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 131 +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 132 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 133 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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 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 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(); + } + + [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 = "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 162 +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 163 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 164 + await testRunner.AndAsync("the client has no Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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(); + } + + [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 = "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 169 +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 170 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#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 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(); + } + + [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 = "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 176 +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 177 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 178 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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(); + } + + [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 = "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 207 +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 208 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 209 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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(); + } + + [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 = "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 214 +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 215 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 216 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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(); + } + + [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 = "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 250 +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 251 + await testRunner.GivenAsync("RawLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 252 + await testRunner.AndAsync("the client has a valid Authorization token", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#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(); + } + } +} +#pragma warning restore +#endregion 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/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.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.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.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.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> 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.EndtoEndTests.TestServices/Backend/TestJwtAuthority.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/TestJwtAuthority.cs new file mode 100644 index 00000000..a5ba79a4 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Backend/TestJwtAuthority.cs @@ -0,0 +1,199 @@ +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.EndToEndTests.TestServices.Backend; + +/// <summary> +/// A minimal local OIDC/JWKS authority used by API integration tests to issue and +/// validate JWTs without a real Cognito user pool. +/// +/// 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 +/// is exercised end-to-end without external dependencies. +/// </summary> +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; + + /// <summary>The authority URL the service under test should be configured with.</summary> + 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(); + } + + /// <summary> + /// Creates a signed JWT with the given subject claim, valid for one hour. + /// </summary> + public string CreateToken(string sub) + => CreateTokenCore(sub, expired: false); + + /// <summary> + /// Creates a signed JWT that is already expired (issued/expiry in the past). + /// </summary> + public string CreateExpiredToken(string sub) + => 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(), + "/_localstack/health" => BuildLocalStackHealth(), + _ => 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 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); + 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(); + } +} 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 new file mode 100644 index 00000000..72dd376a --- /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.Extensions.Logging.Abstractions" /> + <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.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.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.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.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); 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 {