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 *@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +@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: