diff --git a/.agents/skills/steps/SKILL.md b/.agents/skills/steps/SKILL.md
new file mode 100644
index 0000000..f2d3323
--- /dev/null
+++ b/.agents/skills/steps/SKILL.md
@@ -0,0 +1,11 @@
+---
+name: steps
+description: Use this skill when implementing a number of steps from a steps markdown file.
+---
+
+- Skip over any steps that are already done (Indicated with a completed checkbox: `[x]`).
+- If steps are broken down into multiple phases, ask the user if they want you to work on all remaining phases, or just the next one.
+- Begin implementing the steps one by one.
+- After completing each step, mark its checkbox as done (`[x]`) in the steps file (if there is one).
+- After implementing code changes, build the solution and run relevant tests. Fix any failures before moving on.
+- Make commits at logical boundaries (e.g. after completing a step or a coherent group of related steps) so that a human reviewer can follow the progress in the git history. Don't batch all changes into a single commit at the end.
diff --git a/.opencode/skills/usecase/SKILL.md b/.agents/skills/usecase/SKILL.md
similarity index 80%
rename from .opencode/skills/usecase/SKILL.md
rename to .agents/skills/usecase/SKILL.md
index 173c04e..73afba7 100644
--- a/.opencode/skills/usecase/SKILL.md
+++ b/.agents/skills/usecase/SKILL.md
@@ -56,8 +56,4 @@ Find and read an existing, similar feature in the codebase as a reference implem
# 3. Implement steps
-- If there are multiple phases, ask the user if they want you to work on all remaining phases, or just the next one.
-- Begin implementing the steps one by one.
-- After completing each step, mark its checkbox as done (`[x]`) in the steps file.
-- After implementing code changes, build the solution and run relevant tests. Fix any failures before moving on.
-- Make commits at logical boundaries (e.g. after completing a step or a coherent group of related steps) so that a human reviewer can follow the progress in the git history. Don't batch all changes into a single commit at the end.
+Use the `steps` skill to implement the steps.
\ No newline at end of file
diff --git a/.github/workflows/api-build-and-test.yml b/.github/workflows/api-build-and-test.yml
index 8d5f238..bb78edb 100644
--- a/.github/workflows/api-build-and-test.yml
+++ b/.github/workflows/api-build-and-test.yml
@@ -8,16 +8,15 @@ jobs:
runs-on: ubuntu-latest
services:
- postgres:
- image: postgres:18
+ sqlserver:
+ image: mcr.microsoft.com/mssql/server:2025-latest
env:
- POSTGRES_USER: dbuser
- POSTGRES_PASSWORD: dbpassword
- POSTGRES_DB: procurehub_tests
+ ACCEPT_EULA: Y
+ MSSQL_SA_PASSWORD: 2#r4U@8GO1Kv@xNFi3k
ports:
- - 5432:5432
+ - 1433:1433
options: >-
- --health-cmd pg_isready
+ --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '2#r4U@8GO1Kv@xNFi3k' -C -Q 'SELECT 1' || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
@@ -37,10 +36,21 @@ jobs:
- name: Build
run: dotnet build ProcureHub.sln --no-restore --configuration Release
- - name: Test
- run: dotnet test ProcureHub.sln --no-build --configuration Release --verbosity normal --logger "trx;LogFileName=ProcureHubTests.trx" --results-directory ./TestResults
+ - name: Install Playwright browsers
+ run: pwsh ProcureHub.BlazorApp.E2ETests/bin/Release/net10.0/playwright.ps1 install --with-deps
+
+ - name: Test WebApi
+ run: dotnet test ProcureHub.WebApi.Tests/ProcureHub.WebApi.Tests.csproj --no-build --configuration Release --verbosity normal --logger "trx;LogFileName=ProcureHub.WebApi.Tests.trx" --results-directory ./TestResults
+ env:
+ ConnectionStrings__DefaultConnection: "Server=localhost;Database=procurehub_tests;User Id=sa;Password=2#r4U@8GO1Kv@xNFi3k;TrustServerCertificate=true"
+
+ - name: Test Blazor unit
+ run: dotnet test ProcureHub.BlazorApp.Tests/ProcureHub.BlazorApp.Tests.csproj --no-build --configuration Release --verbosity normal --logger "trx;LogFileName=ProcureHub.BlazorApp.Tests.trx" --results-directory ./TestResults
+
+ - name: Test Blazor E2E
+ run: dotnet test ProcureHub.BlazorApp.E2ETests/ProcureHub.BlazorApp.E2ETests.csproj --no-build --configuration Release --verbosity normal --logger "trx;LogFileName=ProcureHub.BlazorApp.E2ETests.trx" --results-directory ./TestResults
env:
- ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=procurehub_tests;Username=dbuser;Password=dbpassword"
+ ConnectionStrings__DefaultConnection: "Server=localhost;Database=procurehub_blazor_e2e_tests;User Id=sa;Password=2#r4U@8GO1Kv@xNFi3k;TrustServerCertificate=true"
- name: Test Report
uses: dorny/test-reporter@v2
@@ -48,4 +58,4 @@ jobs:
with:
name: Test Report
path: TestResults/*.trx
- reporter: dotnet-trx
\ No newline at end of file
+ reporter: dotnet-trx
diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml
index 2bbc41e..7983a32 100644
--- a/.github/workflows/api-ci.yml
+++ b/.github/workflows/api-ci.yml
@@ -22,4 +22,3 @@ on:
jobs:
build-and-test:
uses: ./.github/workflows/api-build-and-test.yml
-
diff --git a/.gitignore b/.gitignore
index f0a6c94..cc35628 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ todos.md
.notes/
.context/ignore
.bruno/
+.env
Prototype/
# Terraform
diff --git a/.plans/use-cases/01_view_and_search_purchase_requests.md b/.plans/use-cases/01_view_and_search_purchase_requests.md
new file mode 100644
index 0000000..cf5eb9b
--- /dev/null
+++ b/.plans/use-cases/01_view_and_search_purchase_requests.md
@@ -0,0 +1,67 @@
+# View My Requests & Search/Filter Requests
+
+## Use Cases
+
+- **View My Requests** — User sees all their submitted requests and their status
+- **Search/Filter Requests** — Find requests by status, department, or search text (date range and amount filters deferred)
+
+## Current State
+
+The `QueryPurchaseRequests` handler already supports:
+- Role-based visibility (admin sees all, approver sees dept + own, requester sees own)
+- Filter by `Status`
+- Search by `Title` / `RequestNumber`
+- Pagination
+
+The API endpoint `GET /purchase-requests` exposes `status`, `search`, `page`, `pageSize` query params.
+
+The Blazor `Index.razor` page at `/requests` is a stub with a TODO comment.
+
+## Changes Needed
+
+### 0. Fix DataSeeder — remove hardcoded emails in SeedPurchaseRequestsAsync
+
+`SeedPurchaseRequestsAsync()` hardcodes `requester@example.com` and `approver@example.com`. This breaks seeding in environments with different emails (e.g. E2E tests use `test-requester@example.com`).
+
+Fix: find the requester and approver users from the `SeedUsers` config section by checking if their email contains "requester" or "approver", respectively. This makes purchase request seeding work across all environments (dev, Blazor, E2E).
+
+### 1. Backend — Add department filter to QueryPurchaseRequests
+
+Add a `DepartmentId` (Guid?) parameter to `QueryPurchaseRequests.Request`. When provided, filter results to only that department. Update the API endpoint to accept `departmentId` query param.
+
+### 2. Backend — Add API test for department filter
+
+Add a test in `PurchaseRequestTests` that creates requests in two departments, queries with `departmentId` filter, and asserts only matching requests are returned.
+
+### 3. Blazor UI — Implement requests listing page
+
+Replace the `/requests` stub with a full page using `RadzenDataGrid` (server-side paging via `LoadData`). Pattern follows `Admin/Users/Index.razor`.
+
+Features:
+- Search bar (text search by title/request number)
+- Status dropdown filter (all statuses + "All")
+- Department dropdown filter (all departments user can see + "All")
+- Data grid columns: Request #, Title, Department, Status, Estimated Amount, Requester, Created
+- Status rendered as colored `RadzenBadge`
+- Click row to navigate to request detail (future — for now, no action)
+
+### 4. No new endpoint needed
+
+The existing `GET /purchase-requests` endpoint is sufficient. The Blazor app calls handlers directly anyway.
+
+## Architecture Notes
+
+- Blazor app calls `QueryPurchaseRequests.Handler` and `QueryDepartments.Handler` directly (no HTTP)
+- `ICurrentUserProvider` provides auth context for the query handler
+- Sequential handler calls (no parallel DbContext usage)
+
+## Tests
+
+### API test
+- `Can_filter_purchase_requests_by_department` in `PurchaseRequestTests`
+
+### Blazor E2E smoke test
+- `PurchaseRequestListTests` in `ProcureHub.BlazorApp.E2ETests/Features/`
+- Test: `Requester_can_see_own_request_in_grid`
+ - Login as requester, navigate to `/requests`, verify the grid loads and seeded purchase requests appear with correct data
+ - Purchase requests are pre-seeded by DataSeeder (after the fix in step 0), so no need to create data via UI
diff --git a/.plans/use-cases/01_view_and_search_purchase_requests_steps.md b/.plans/use-cases/01_view_and_search_purchase_requests_steps.md
new file mode 100644
index 0000000..3a1980a
--- /dev/null
+++ b/.plans/use-cases/01_view_and_search_purchase_requests_steps.md
@@ -0,0 +1,22 @@
+# View My Requests & Search/Filter — Implementation Steps
+
+## Phase 0: Fix DataSeeder
+
+- [x] 0.1 In `SeedPurchaseRequestsAsync`, replace hardcoded `FindByEmailAsync("requester@example.com")` / `FindByEmailAsync("approver@example.com")` with lookup from `SeedUsers` config section (find emails containing "requester" / "approver")
+- [x] 0.2 Build & run existing tests to verify no regression
+
+## Phase 1: Backend
+
+- [x] 1.1 Add `DepartmentId` (Guid?) to `QueryPurchaseRequests.Request`, add filter logic in `Handler`, wire through API endpoint
+- [x] 1.2 Add `Can_filter_purchase_requests_by_department` test in `PurchaseRequestTests`, add endpoint to `GetAllPurchaseRequestEndpoints`
+- [x] 1.3 Build & run tests, fix any failures
+
+## Phase 2: Blazor UI
+
+- [x] 2.1 Implement `/requests` page (`Index.razor`) with `RadzenDataGrid`, search bar, status dropdown, department dropdown, server-side paging via `LoadData`
+- [x] 2.2 Build, manually verify page loads (if app is running)
+
+## Phase 3: E2E Test
+
+- [x] 3.1 Add `PurchaseRequestListTests.cs` — `Requester_can_see_own_request_in_grid`, `Search_filters_requests_by_title`, `Clear_filters_shows_all_requests`
+- [x] 3.2 Run E2E tests, fix config merge issue via `GetTestSeedUsers()` filtering
diff --git a/ProcureHub.BlazorApp.E2ETests/AGENTS.md b/ProcureHub.BlazorApp.E2ETests/AGENTS.md
index 09f46b5..0da1216 100644
--- a/ProcureHub.BlazorApp.E2ETests/AGENTS.md
+++ b/ProcureHub.BlazorApp.E2ETests/AGENTS.md
@@ -36,9 +36,8 @@ Each test:
- Extend `BlazorPageTest` (defined in `Infrastructure/BlazorPageTest.cs`)
- Use `LoginAsAdminAsync()`, `LoginAsRequesterAsync()`, `LoginAsApproverAsync()` helpers
-- Use `Page.GotoBlazorServerPageAsync("/route")` instead of raw `GotoAsync` (waits for Blazor circuit)
-- Use `DefaultNavigationTimeoutMs` const for all `WaitForURLAsync` timeouts
- The app uses Radzen UI components — selectors may need Radzen-specific CSS classes (e.g. `.rz-sidebar`, `.rz-profile-menu`)
+- If it's particularly difficult to get a test to pass, consider using MCP tools like Chrome DevTools to inspect the dom structure or app behaviour in the real app.
## Test Users
@@ -52,11 +51,14 @@ Each test:
### Avoid hard-coded timeouts
-Do not use `await Page.WaitForTimeoutAsync(1000);` to wait for elements or page state. This approach is brittle (arbitrary wait times) and slows down tests unnecessarily. Instead, prefer deterministic waiting methods:
+IMPORTANT: DO NOT use `Page.WaitForTimeoutAsync` to wait for elements or page state. This approach is brittle (arbitrary wait times) and slows down tests unnecessarily. Playwright includes auto-retrying assertions (such as `ToBeVisibleAsync()`) that remove flakiness by waiting until the condition is met - prefer to use those. Examples:
- Use `await Expect(locator).ToBeVisibleAsync()` or `await Expect(locator).ToBeHiddenAsync()`
- Use `await Page.WaitForURLAsync()` for navigation
-- Use `await Page.WaitForLoadStateAsync()` for page load events
+
+Playwright also performs a range of actionability checks on the elements before making actions (such as `Locator.ClickAsync()`) to ensure these actions behave as expected.
+
+For more on Playwright actionability checks and auto-retrying assertions, see: https://playwright.dev/dotnet/docs/actionability.
### Prefer semantic selectors with GetByRole
diff --git a/ProcureHub.BlazorApp.E2ETests/Features/AccessControlTests.cs b/ProcureHub.BlazorApp.E2ETests/Features/AccessControlTests.cs
index 8466c2e..8aae127 100644
--- a/ProcureHub.BlazorApp.E2ETests/Features/AccessControlTests.cs
+++ b/ProcureHub.BlazorApp.E2ETests/Features/AccessControlTests.cs
@@ -11,12 +11,9 @@ public async Task Requester_cannot_access_admin_users_page()
{
await LoginAsRequesterAsync();
- await Page.GotoBlazorServerPageAsync("/admin/users");
+ await Page.GotoAsync("/admin/users");
- await Page.WaitForURLAsync(url => url.Contains("/Account/AccessDenied"), new()
- {
- Timeout = DefaultNavigationTimeoutMs
- });
+ await Page.WaitForURLAsync(url => url.Contains("/Account/AccessDenied"));
}
[Fact]
@@ -24,12 +21,9 @@ public async Task Requester_cannot_access_admin_departments_page()
{
await LoginAsRequesterAsync();
- await Page.GotoBlazorServerPageAsync("/admin/departments");
+ await Page.GotoAsync("/admin/departments");
- await Page.WaitForURLAsync(url => url.Contains("/Account/AccessDenied"), new()
- {
- Timeout = DefaultNavigationTimeoutMs
- });
+ await Page.WaitForURLAsync(url => url.Contains("/Account/AccessDenied"));
}
[Fact]
@@ -37,12 +31,9 @@ public async Task Approver_cannot_access_admin_users_page()
{
await LoginAsApproverAsync();
- await Page.GotoBlazorServerPageAsync("/admin/users");
+ await Page.GotoAsync("/admin/users");
- await Page.WaitForURLAsync(url => url.Contains("/Account/AccessDenied"), new()
- {
- Timeout = DefaultNavigationTimeoutMs
- });
+ await Page.WaitForURLAsync(url => url.Contains("/Account/AccessDenied"));
}
[Fact]
@@ -50,12 +41,9 @@ public async Task Approver_cannot_access_new_request_page()
{
await LoginAsApproverAsync();
- await Page.GotoBlazorServerPageAsync("/requests/new");
+ await Page.GotoAsync("/requests/new");
- await Page.WaitForURLAsync(url => url.Contains("/Account/AccessDenied"), new()
- {
- Timeout = DefaultNavigationTimeoutMs
- });
+ await Page.WaitForURLAsync(url => url.Contains("/Account/AccessDenied"));
}
[Fact]
@@ -63,12 +51,9 @@ public async Task Requester_cannot_access_approvals_page()
{
await LoginAsRequesterAsync();
- await Page.GotoBlazorServerPageAsync("/approvals");
+ await Page.GotoAsync("/approvals");
- await Page.WaitForURLAsync(url => url.Contains("/Account/AccessDenied"), new()
- {
- Timeout = DefaultNavigationTimeoutMs
- });
+ await Page.WaitForURLAsync(url => url.Contains("/Account/AccessDenied"));
}
[Fact]
@@ -76,7 +61,7 @@ public async Task Admin_can_access_admin_users_page()
{
await LoginAsAdminAsync();
- await Page.GotoBlazorServerPageAsync("/admin/users");
+ await Page.GotoAsync("/admin/users");
await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Users" })).ToBeVisibleAsync();
}
@@ -86,7 +71,7 @@ public async Task Admin_can_access_admin_departments_page()
{
await LoginAsAdminAsync();
- await Page.GotoBlazorServerPageAsync("/admin/departments");
+ await Page.GotoAsync("/admin/departments");
await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Departments" })).ToBeVisibleAsync();
}
diff --git a/ProcureHub.BlazorApp.E2ETests/Features/LoginTests.cs b/ProcureHub.BlazorApp.E2ETests/Features/LoginTests.cs
index 05c23c2..8d008b8 100644
--- a/ProcureHub.BlazorApp.E2ETests/Features/LoginTests.cs
+++ b/ProcureHub.BlazorApp.E2ETests/Features/LoginTests.cs
@@ -9,12 +9,9 @@ public class LoginTests : BlazorPageTest
[Fact]
public async Task Unauthenticated_user_is_redirected_to_login()
{
- await Page.GotoBlazorServerPageAsync("/");
+ await Page.GotoAsync("/");
- await Page.WaitForURLAsync(url => url.Contains("/Account/Login"), new()
- {
- Timeout = DefaultNavigationTimeoutMs
- });
+ await Page.WaitForURLAsync(url => url.Contains("/Account/Login"));
await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Log in" })).ToBeVisibleAsync();
}
@@ -31,7 +28,7 @@ public async Task Admin_can_login_and_see_dashboard()
[Fact]
public async Task Invalid_login_shows_error()
{
- await Page.GotoBlazorServerPageAsync("/Account/Login");
+ await Page.GotoAsync("/Account/Login");
await Page.FillAsync("[name='Input.Email']", "wrong@example.com");
await Page.FillAsync("[name='Input.Password']", "WrongPassword1!");
await Page.ClickAsync("button[type='submit']");
diff --git a/ProcureHub.BlazorApp.E2ETests/Features/NavigationTests.cs b/ProcureHub.BlazorApp.E2ETests/Features/NavigationTests.cs
index f9ca2ce..73adef4 100644
--- a/ProcureHub.BlazorApp.E2ETests/Features/NavigationTests.cs
+++ b/ProcureHub.BlazorApp.E2ETests/Features/NavigationTests.cs
@@ -60,10 +60,7 @@ public async Task Clicking_sidebar_users_navigates_to_users_page()
await Page.Locator(".rz-sidebar").GetByText("Users").ClickAsync();
- await Page.WaitForURLAsync(url => url.Contains("/admin/users"), new()
- {
- Timeout = DefaultNavigationTimeoutMs
- });
+ await Page.WaitForURLAsync(url => url.Contains("/admin/users"));
await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Users" })).ToBeVisibleAsync();
}
@@ -74,10 +71,7 @@ public async Task Clicking_sidebar_departments_navigates_to_departments_page()
await Page.Locator(".rz-sidebar").GetByText("Departments").ClickAsync();
- await Page.WaitForURLAsync(url => url.Contains("/admin/departments"), new()
- {
- Timeout = DefaultNavigationTimeoutMs
- });
+ await Page.WaitForURLAsync(url => url.Contains("/admin/departments"));
await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Departments" })).ToBeVisibleAsync();
}
@@ -102,10 +96,7 @@ public async Task Logout_redirects_to_login_page()
await Page.Locator(".rz-profile-menu").ClickAsync();
await Page.GetByText("Logout").ClickAsync();
- await Page.WaitForURLAsync(url => url.Contains("/Account/Login"), new()
- {
- Timeout = DefaultNavigationTimeoutMs
- });
+ await Page.WaitForURLAsync(url => url.Contains("/Account/Login"));
await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Log in" })).ToBeVisibleAsync();
}
}
diff --git a/ProcureHub.BlazorApp.E2ETests/Features/PurchaseRequestListTests.cs b/ProcureHub.BlazorApp.E2ETests/Features/PurchaseRequestListTests.cs
new file mode 100644
index 0000000..046e6a1
--- /dev/null
+++ b/ProcureHub.BlazorApp.E2ETests/Features/PurchaseRequestListTests.cs
@@ -0,0 +1,91 @@
+using Microsoft.Playwright;
+using ProcureHub.BlazorApp.E2ETests.Infrastructure;
+
+namespace ProcureHub.BlazorApp.E2ETests.Features;
+
+[Collection(BlazorE2ETestCollection.Name)]
+public class PurchaseRequestListTests : BlazorPageTest
+{
+ [Fact]
+ public async Task Requester_can_see_own_request_in_grid()
+ {
+ // Login as requester
+ await LoginAsRequesterAsync();
+
+ // Navigate to requests page
+ await Page.GotoAsync("/requests");
+
+ // Verify page loaded with heading
+ await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Requests" })).ToBeVisibleAsync();
+
+ // Wait for grid to load - check for a column header first
+ await Expect(Page.GetByText("Request #")).ToBeVisibleAsync();
+
+ // The requester should see their pre-seeded requests
+ await Expect(Page.GetByText("PR-2025-001")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-002")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-003")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-004")).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task Search_filters_requests_by_title()
+ {
+ // Login as requester
+ await LoginAsRequesterAsync();
+
+ // Navigate to requests page
+ await Page.GotoAsync("/requests");
+
+ // Wait for grid and initial data to load
+ await Expect(Page.GetByRole(AriaRole.Grid, new() { Name = "Purchase Requests" })).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-001")).ToBeVisibleAsync();
+
+ // Type "Laptops" in search box and press Enter to search immediately
+ var searchBox = Page.GetByRole(AriaRole.Textbox, new() { Name = "Search" });
+ await searchBox.FillAsync("Laptops");
+ await searchBox.PressAsync("Enter");
+
+ // Should only show PR-2025-001 ("New Development Laptops")
+ await Expect(Page.GetByText("PR-2025-001")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-002")).Not.ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-003")).Not.ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-004")).Not.ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task Clear_filters_shows_all_requests()
+ {
+ // Login as requester
+ await LoginAsRequesterAsync();
+
+ // Navigate to requests page
+ await Page.GotoAsync("/requests");
+
+ // Wait for grid to load
+ await Expect(Page.GetByRole(AriaRole.Grid, new() { Name = "Purchase Requests" })).ToBeVisibleAsync();
+
+ // Verify all 4 requests are initially visible
+ await Expect(Page.GetByText("PR-2025-001")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-002")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-003")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-004")).ToBeVisibleAsync();
+
+ // Select "Draft" from Status dropdown to filter
+ await Page.GetByRole(AriaRole.Combobox, new() { Name = "Status" }).ClickAsync();
+ await Page.GetByRole(AriaRole.Option, new() { Name = "Draft" }).ClickAsync();
+
+ // Should only show draft requests
+ await Expect(Page.GetByText("PR-2025-001")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-002")).Not.ToBeVisibleAsync();
+
+ // Click Clear button
+ await Page.GetByRole(AriaRole.Button, new() { Name = "Clear" }).ClickAsync();
+
+ // All requests should be visible again
+ await Expect(Page.GetByText("PR-2025-001")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-002")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-003")).ToBeVisibleAsync();
+ await Expect(Page.GetByText("PR-2025-004")).ToBeVisibleAsync();
+ }
+}
diff --git a/ProcureHub.BlazorApp.E2ETests/Features/UserManagementTests.cs b/ProcureHub.BlazorApp.E2ETests/Features/UserManagementTests.cs
index 6228403..5f340c0 100644
--- a/ProcureHub.BlazorApp.E2ETests/Features/UserManagementTests.cs
+++ b/ProcureHub.BlazorApp.E2ETests/Features/UserManagementTests.cs
@@ -9,7 +9,7 @@ public class UserManagementTests : BlazorPageTest
private async Task NavigateToUsersPage()
{
await LoginAsAdminAsync();
- await Page.GotoBlazorServerPageAsync("/admin/users");
+ await Page.GotoAsync("/admin/users");
await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Users" })).ToBeVisibleAsync();
}
@@ -29,6 +29,10 @@ public async Task Search_filters_users_by_email()
{
await NavigateToUsersPage();
+ // Wait for grid and data to load
+ await Expect(Page.GetByRole(AriaRole.Grid, new() { Name = "Users" })).ToBeVisibleAsync();
+ await Expect(Page.GetByText("test-requester@example.com")).ToBeVisibleAsync();
+
// Search for requester — press Tab after fill to blur the input and trigger Change
var searchBox = Page.GetByPlaceholder("Search users...");
await searchBox.FillAsync("requester");
diff --git a/ProcureHub.BlazorApp.E2ETests/Infrastructure/BlazorPageExtensions.cs b/ProcureHub.BlazorApp.E2ETests/Infrastructure/BlazorPageExtensions.cs
deleted file mode 100644
index c30d3c4..0000000
--- a/ProcureHub.BlazorApp.E2ETests/Infrastructure/BlazorPageExtensions.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using Microsoft.Playwright;
-
-namespace ProcureHub.BlazorApp.E2ETests.Infrastructure;
-
-public static class BlazorPageExtensions
-{
- ///
- /// Navigates to a Blazor Server page and waits for network idle,
- /// ensuring the Blazor circuit is fully established.
- ///
- public static Task GotoBlazorServerPageAsync(this IPage page, string url)
- => page.GotoAsync(url, new PageGotoOptions
- {
- WaitUntil = WaitUntilState.NetworkIdle
- });
-}
diff --git a/ProcureHub.BlazorApp.E2ETests/Infrastructure/BlazorPageTest.cs b/ProcureHub.BlazorApp.E2ETests/Infrastructure/BlazorPageTest.cs
index 333078c..424736f 100644
--- a/ProcureHub.BlazorApp.E2ETests/Infrastructure/BlazorPageTest.cs
+++ b/ProcureHub.BlazorApp.E2ETests/Infrastructure/BlazorPageTest.cs
@@ -16,7 +16,6 @@ public abstract class BlazorPageTest : BrowserTest
public const string RequesterEmail = "test-requester@example.com";
public const string ApproverEmail = "test-approver@example.com";
public const string DefaultPassword = "Password1!";
- public const int DefaultNavigationTimeoutMs = 10_000;
private BlazorApplicationFactory? _host;
private IPage? _page;
@@ -46,7 +45,6 @@ public override async ValueTask InitializeAsync()
if (Debugger.IsAttached)
{
Environment.SetEnvironmentVariable("PWDEBUG", "1");
-
}
var connectionString = Configuration.GetConnectionString();
@@ -65,6 +63,10 @@ public override async ValueTask InitializeAsync()
_context = await NewContext(options);
_page = await Context.NewPageAsync();
+
+ // Make things fail faster:
+ _page.SetDefaultNavigationTimeout(3000);
+ _page.SetDefaultTimeout(3000);
}
public override async ValueTask DisposeAsync()
@@ -97,9 +99,7 @@ protected async Task LoginAsync(string email, string password)
await Page.ClickAsync("button[type='submit']");
// Wait for navigation away from login page
- await Page.WaitForURLAsync(
- url => !url.Contains("/Account/Login"),
- new PageWaitForURLOptions { Timeout = DefaultNavigationTimeoutMs });
+ await Page.WaitForURLAsync(url => !url.Contains("/Account/Login"));
}
/// Login as the seeded admin user.
diff --git a/ProcureHub.BlazorApp/Components/Pages/Admin/Users/Index.razor b/ProcureHub.BlazorApp/Components/Pages/Admin/Users/Index.razor
index dd48afb..9fc3019 100644
--- a/ProcureHub.BlazorApp/Components/Pages/Admin/Users/Index.razor
+++ b/ProcureHub.BlazorApp/Components/Pages/Admin/Users/Index.razor
@@ -25,8 +25,8 @@
-
+
@@ -145,6 +145,13 @@
async Task ClearSearch()
{
_searchTerm = "";
+ _currentPage = 1;
+ await LoadUsers();
+ }
+
+ async Task OnSearchTriggered()
+ {
+ _currentPage = 1;
await LoadUsers();
}
diff --git a/ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor b/ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor
index 96dc484..b849e43 100644
--- a/ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor
+++ b/ProcureHub.BlazorApp/Components/Pages/Requests/Index.razor
@@ -1,7 +1,221 @@
@page "/requests"
@attribute [Authorize]
+@layout AuthenticatedLayout
+
+@using ProcureHub.BlazorApp.Helpers
+@using ProcureHub.Features.PurchaseRequests
+@using ProcureHub.Features.Departments
+@using ProcureHub.Common.Pagination
+@using ProcureHub.Infrastructure
+@using ProcureHub.Infrastructure.Authentication
+@using ProcureHub.Models
+
+@inject IQueryHandler>> QueryPurchaseRequestsHandler
+@inject IQueryHandler QueryDepartmentsHandler
+@inject ICurrentUserProvider CurrentUserProvider
+@inject NavigationManager Navigation
Requests
-
-@* TODO: Implement requests listing *@
+
+
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(_errorMessage))
+ {
+
+ @_errorMessage
+
+ }
+
+ @* Filter bar *@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @pr.RequestNumber
+
+
+
+
+
+ @pr.Title
+
+
+
+
+
+ @pr.Department.Name
+
+
+
+
+
+ @pr.Requester.FirstName @pr.Requester.LastName
+
+
+
+
+
+ @pr.EstimatedAmount.ToString("C")
+
+
+
+
+
+
+
+
+
+
+
+ @pr.CreatedAt.ToString("yyyy-MM-dd")
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ const int PageSize = 10;
+ PagedResult? _pagedRequests;
+ bool _loading;
+ string _searchTerm = "";
+ PurchaseRequestStatus? _selectedStatus;
+ Guid? _selectedDepartmentId;
+ int _currentPage = 1;
+
+ QueryDepartments.Response[] _departments = [];
+
+ readonly PurchaseRequestStatus[] _statuses = Enum.GetValues();
+
+ protected override async Task OnInitializedAsync()
+ {
+ _departments = await QueryDepartmentsHandler.HandleAsync(new QueryDepartments.Request(), CancellationToken.None);
+ }
+
+ async Task OnLoadData(LoadDataArgs args)
+ {
+ _currentPage = (args.Skip ?? 0) / (args.Top ?? PageSize) + 1;
+ await LoadRequests();
+ }
+
+ async Task OnStatusChanged(object? value)
+ {
+ _selectedStatus = (PurchaseRequestStatus?)value;
+ _currentPage = 1;
+ await LoadRequests();
+ }
+
+ async Task OnDepartmentChanged(object? value)
+ {
+ _selectedDepartmentId = (Guid?)value;
+ _currentPage = 1;
+ await LoadRequests();
+ }
+
+ async Task ApplyFilters()
+ {
+ _currentPage = 1;
+ await LoadRequests();
+ }
+
+ async Task ClearFilters()
+ {
+ _searchTerm = "";
+ _selectedStatus = null;
+ _selectedDepartmentId = null;
+ _currentPage = 1;
+ await LoadRequests();
+ }
+
+ string? _errorMessage;
+
+ async Task LoadRequests()
+ {
+ _errorMessage = null;
+ var currentUser = await CurrentUserProvider.GetCurrentUserAsync();
+ if (currentUser.UserId is null)
+ {
+ _errorMessage = "Authentication error: User not found";
+ return;
+ }
+
+ var search = string.IsNullOrWhiteSpace(_searchTerm) ? null : _searchTerm;
+ var request = new QueryPurchaseRequests.Request(
+ _selectedStatus,
+ search,
+ _selectedDepartmentId,
+ _currentPage,
+ PageSize,
+ currentUser.UserId.Value.ToString()
+ );
+
+ using (new BusyScope(b => _loading = b))
+ {
+ var result = await QueryPurchaseRequestsHandler.HandleAsync(request, CancellationToken.None);
+ if (result.IsSuccess)
+ {
+ _pagedRequests = result.Value;
+ }
+ else
+ {
+ _errorMessage = result.Error.Message;
+ }
+ }
+ }
+}
diff --git a/ProcureHub.BlazorApp/Components/Shared/DebouncedSearchInput.razor b/ProcureHub.BlazorApp/Components/Shared/DebouncedSearchInput.razor
new file mode 100644
index 0000000..788d4f9
--- /dev/null
+++ b/ProcureHub.BlazorApp/Components/Shared/DebouncedSearchInput.razor
@@ -0,0 +1,81 @@
+@using System.Timers
+@implements IDisposable
+
+
+
+@code {
+ [Parameter]
+ public string Value { get; set; } = "";
+
+ [Parameter]
+ public EventCallback ValueChanged { get; set; }
+
+ [Parameter]
+ public EventCallback SearchTriggered { get; set; }
+
+ [Parameter]
+ public string Placeholder { get; set; } = "Search...";
+
+ [Parameter]
+ public string? Style { get; set; }
+
+ [Parameter]
+ public string? Name { get; set; }
+
+ [Parameter]
+ public int DebounceMs { get; set; } = 350;
+
+ Timer? _debounceTimer;
+
+ void OnInput(ChangeEventArgs e)
+ {
+ UpdateValue(e.Value?.ToString() ?? "");
+ RestartDebounceTimer();
+ }
+
+ void UpdateValue(string newValue)
+ {
+ Value = newValue;
+ ValueChanged.InvokeAsync(newValue);
+ }
+
+ void RestartDebounceTimer()
+ {
+ StopAndDisposeTimer();
+
+ _debounceTimer = new Timer(DebounceMs);
+ _debounceTimer.Elapsed += async (_, _) =>
+ {
+ StopAndDisposeTimer();
+ await InvokeAsync(() => SearchTriggered.InvokeAsync());
+ };
+ _debounceTimer.AutoReset = false;
+ _debounceTimer.Start();
+ }
+
+ void StopAndDisposeTimer()
+ {
+ _debounceTimer?.Stop();
+ _debounceTimer?.Dispose();
+ _debounceTimer = null;
+ }
+
+ async Task OnKeyDown(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter")
+ {
+ StopAndDisposeTimer();
+ await SearchTriggered.InvokeAsync();
+ }
+ }
+
+ public void Dispose()
+ {
+ StopAndDisposeTimer();
+ }
+}
diff --git a/ProcureHub.BlazorApp/Components/Shared/PurchaseRequestStatusBadge.razor b/ProcureHub.BlazorApp/Components/Shared/PurchaseRequestStatusBadge.razor
new file mode 100644
index 0000000..e2b38fa
--- /dev/null
+++ b/ProcureHub.BlazorApp/Components/Shared/PurchaseRequestStatusBadge.razor
@@ -0,0 +1,22 @@
+@using ProcureHub.Models
+
+@switch (Status)
+{
+ case PurchaseRequestStatus.Draft:
+
+ break;
+ case PurchaseRequestStatus.Pending:
+
+ break;
+ case PurchaseRequestStatus.Approved:
+
+ break;
+ case PurchaseRequestStatus.Rejected:
+
+ break;
+}
+
+@code {
+ [Parameter, EditorRequired]
+ public PurchaseRequestStatus Status { get; set; }
+}
diff --git a/ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs b/ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs
index f9111c0..f0c00c5 100644
--- a/ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs
+++ b/ProcureHub.WebApi.Tests/Features/PurchaseRequestTests.cs
@@ -850,6 +850,41 @@ public async Task Can_search_purchase_requests_by_title()
Assert.Contains(softwareSearch.Data, pr => pr.Id == softwareId);
}
+ [Fact]
+ public async Task Can_filter_purchase_requests_by_department()
+ {
+ await LoginAsAdminAsync();
+ var categoryId = await CreateCategoryAsync();
+ var dept1Id = await CreateDepartmentAsync("IT");
+ var dept2Id = await CreateDepartmentAsync("HR");
+
+ await CreateUserAsync("requester@example.com", ValidPassword, [RoleNames.Requester], dept1Id);
+ await CreateUserAsync("requester2@example.com", ValidPassword, [RoleNames.Requester], dept2Id);
+
+ // Login as requester and create PR in IT dept
+ await LoginAsync("requester@example.com", ValidPassword);
+ var itRequestId = await CreatePurchaseRequestAsync(categoryId, dept1Id, "IT Request", 1000);
+
+ // Login as requester2 and create PR in HR dept
+ await LoginAsync("requester2@example.com", ValidPassword);
+ var hrRequestId = await CreatePurchaseRequestAsync(categoryId, dept2Id, "HR Request", 2000);
+
+ // Login as admin to query all
+ await LoginAsAdminAsync();
+
+ // Filter by IT department
+ var itFilter = await HttpClient.GetAsync($"/purchase-requests?departmentId={dept1Id}")
+ .ReadJsonAsync>();
+ Assert.Contains(itFilter.Data, pr => pr.Id == itRequestId);
+ Assert.DoesNotContain(itFilter.Data, pr => pr.Id == hrRequestId);
+
+ // Filter by HR department
+ var hrFilter = await HttpClient.GetAsync($"/purchase-requests?departmentId={dept2Id}")
+ .ReadJsonAsync>();
+ Assert.DoesNotContain(hrFilter.Data, pr => pr.Id == itRequestId);
+ Assert.Contains(hrFilter.Data, pr => pr.Id == hrRequestId);
+ }
+
[Fact]
public async Task Update_purchase_request_returns_not_found_for_nonexistent_request()
{
diff --git a/ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs b/ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs
index 882fb88..1314c7a 100644
--- a/ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs
+++ b/ProcureHub.WebApi/Features/PurchaseRequests/Endpoints.cs
@@ -50,6 +50,7 @@ CancellationToken token
CancellationToken token,
[FromQuery] Models.PurchaseRequestStatus? status,
[FromQuery] string? search,
+ [FromQuery] Guid? departmentId,
[FromQuery] int? page,
[FromQuery] int? pageSize
) =>
@@ -60,7 +61,7 @@ [FromQuery] int? pageSize
return Results.Unauthorized();
}
- var request = new QueryPurchaseRequests.Request(status, search, page, pageSize, userId);
+ var request = new QueryPurchaseRequests.Request(status, search, departmentId, page, pageSize, userId);
var result = await handler.HandleAsync(request, token);
return result.Match(
pagedResult => Results.Ok(PagedResponse.From(pagedResult)),
diff --git a/ProcureHub/Data/DataSeeder.cs b/ProcureHub/Data/DataSeeder.cs
index 8a61109..91ff532 100644
--- a/ProcureHub/Data/DataSeeder.cs
+++ b/ProcureHub/Data/DataSeeder.cs
@@ -102,13 +102,30 @@ private async Task SeedAdminUserAsync()
private async Task SeedUsersAsync()
{
- var seedUsersSection = _configuration.GetSection("SeedUsers");
- foreach (var userSection in seedUsersSection.GetChildren())
+ var testUsers = GetTestSeedUsers();
+ foreach (var userSection in testUsers)
{
await SeedUserAsync(userSection.Key);
}
}
+ ///
+ /// Gets SeedUsers configuration entries, preferring those whose keys start with "test-".
+ /// If any test users exist (e.g., keys like "test-requester@example.com"), only those are returned,
+ /// which allows test-specific users to override app users that may be merged from base config.
+ /// If no test-* users exist, all SeedUsers entries are returned.
+ ///
+ private IEnumerable GetTestSeedUsers()
+ {
+ var seedUsersSection = _configuration.GetSection("SeedUsers");
+ var allUsers = seedUsersSection.GetChildren().ToList();
+
+ var testUsers = allUsers.Where(u => u.Key.StartsWith("test-", StringComparison.OrdinalIgnoreCase)).ToList();
+
+ // If test users exist, use them exclusively. Otherwise fall back to all users (production case).
+ return testUsers.Any() ? testUsers : allUsers;
+ }
+
private async Task SeedUserAsync(string email)
{
var configKey = $"SeedUsers:{email}";
@@ -214,9 +231,20 @@ private async Task SeedPurchaseRequestsAsync()
return;
}
- // Hardcoded demo users for purchase request seeding
- var requester = await _userManager.FindByEmailAsync("requester@example.com");
- var approver = await _userManager.FindByEmailAsync("approver@example.com");
+ // Lookup requester and approver emails from SeedUsers config
+ var userEmails = GetTestSeedUsers().Select(c => c.Key).ToList();
+
+ var requesterEmail = userEmails.FirstOrDefault(e => e.Contains("requester", StringComparison.OrdinalIgnoreCase));
+ var approverEmail = userEmails.FirstOrDefault(e => e.Contains("approver", StringComparison.OrdinalIgnoreCase));
+
+ if (requesterEmail == null || approverEmail == null)
+ {
+ _logger.LogWarning("Cannot seed purchase requests - requester or approver email not found in SeedUsers config");
+ return;
+ }
+
+ var requester = await _userManager.FindByEmailAsync(requesterEmail);
+ var approver = await _userManager.FindByEmailAsync(approverEmail);
if (requester == null || approver == null)
{
diff --git a/ProcureHub/Features/PurchaseRequests/QueryPurchaseRequests.cs b/ProcureHub/Features/PurchaseRequests/QueryPurchaseRequests.cs
index 545e490..5b029bb 100644
--- a/ProcureHub/Features/PurchaseRequests/QueryPurchaseRequests.cs
+++ b/ProcureHub/Features/PurchaseRequests/QueryPurchaseRequests.cs
@@ -15,6 +15,7 @@ public static class QueryPurchaseRequests
public record Request(
PurchaseRequestStatus? Status,
string? Search,
+ Guid? DepartmentId,
int? Page,
int? PageSize,
string UserId
@@ -91,6 +92,11 @@ public async Task>> HandleAsync(Request request, Ca
pr.RequestNumber.ToLower().Contains(search));
}
+ if (request.DepartmentId.HasValue)
+ {
+ query = query.Where(pr => pr.DepartmentId == request.DepartmentId.Value);
+ }
+
var pagedResult = await query
.OrderByDescending(pr => pr.CreatedAt)
.ToPagedResultAsync(
diff --git a/compose.yaml b/compose.yaml
index e693698..b0c165f 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,30 +1,15 @@
services:
appdb:
- image: postgres:18
- container_name: postgres_appdb
+ image: mcr.microsoft.com/mssql/server:2025-latest
+ container_name: sqlserver_appdb
ports:
- - "5432:5432"
+ - "1433:1433"
environment:
- POSTGRES_USER: dbuser
- POSTGRES_PASSWORD: dbpassword
- POSTGRES_DB: procurehub
+ ACCEPT_EULA: Y
+ MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD}
volumes:
- - pgdata:/var/lib/postgresql
- restart: unless-stopped
-
- appdb_tests:
- image: postgres:18
- container_name: postgres_appdb_tests
- ports:
- - "5433:5432"
- environment:
- POSTGRES_USER: dbuser
- POSTGRES_PASSWORD: dbpassword
- POSTGRES_DB: procurehub_tests
- volumes:
- - pgdata_tests:/var/lib/postgresql
+ - sqldata:/var/opt/mssql
restart: unless-stopped
volumes:
- pgdata:
- pgdata_tests:
+ sqldata: