Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
80804a2
Add plan and steps
emertechie Feb 10, 2026
ededddf
Move skill to .agents directory
emertechie Feb 11, 2026
f342a8d
fix: lookup requester/approver emails from SeedUsers config
emertechie Feb 11, 2026
fce7f9b
Add separate "steps" skill
emertechie Feb 11, 2026
1dd4ae5
Phase 1: Add DepartmentId filter to purchase requests query
emertechie Feb 11, 2026
d06da9e
Phase 2: Implement /requests page with filters and grid
emertechie Feb 11, 2026
064282e
test: add E2E tests for purchase request list
emertechie Feb 11, 2026
9c01ce2
Tweaks
emertechie Feb 11, 2026
a9c76f3
Update AGENTS.md for e2e tests
emertechie Feb 11, 2026
9154e15
(fix): Switch to sql server to run ci tests
emertechie Feb 11, 2026
df534b4
fix Docker compose file to also use SQL Server now
emertechie Feb 11, 2026
994bb63
Update ProcureHub/Data/DataSeeder.cs
emertechie Feb 11, 2026
9e689ef
Move test db pwds to external file/secret
emertechie Feb 11, 2026
b3b0026
try fix ci error
emertechie Feb 11, 2026
1424834
Try another CI fix
emertechie Feb 11, 2026
270639f
Try secret without quotes
emertechie Feb 11, 2026
99df939
Try access SA pwd in separate step
emertechie Feb 11, 2026
425c07d
Revert ci secret change
emertechie Feb 11, 2026
26234c4
Fx playwright tests
emertechie Feb 11, 2026
053588f
Use separate DB for API and e2e tests
emertechie Feb 11, 2026
f32ec00
Update agents to avoid use of `WaitForTimeoutAsync`
emertechie Feb 12, 2026
a60ecb8
Remove `GotoBlazorServerPageAsync` extension method
emertechie Feb 12, 2026
38404a8
Remove `DefaultNavigationTimeoutMs` const
emertechie Feb 12, 2026
1f607cf
Make tests fail faster
emertechie Feb 12, 2026
9d6b7d4
Update agents
emertechie Feb 12, 2026
5b616fb
Remove last use of `WaitForTimeoutAsync`
emertechie Feb 12, 2026
2958692
Add DebouncedSearchInput component and auto-search on filter changes
emertechie Feb 12, 2026
33a7587
Fix tests
emertechie Feb 12, 2026
9c5725e
Test dropdown filter
emertechie Feb 12, 2026
a136b15
Add aria attributes to fix tests
emertechie Feb 12, 2026
70e4509
Refactor
emertechie Feb 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .agents/skills/steps/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
32 changes: 21 additions & 11 deletions .github/workflows/api-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,15 +36,26 @@ 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
if: ${{ !cancelled() }} # run this step even if previous step failed
with:
name: Test Report
path: TestResults/*.trx
reporter: dotnet-trx
reporter: dotnet-trx
1 change: 0 additions & 1 deletion .github/workflows/api-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,3 @@ on:
jobs:
build-and-test:
uses: ./.github/workflows/api-build-and-test.yml

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ todos.md
.notes/
.context/ignore
.bruno/
.env
Prototype/

# Terraform
Expand Down
67 changes: 67 additions & 0 deletions .plans/use-cases/01_view_and_search_purchase_requests.md
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions .plans/use-cases/01_view_and_search_purchase_requests_steps.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 6 additions & 4 deletions ProcureHub.BlazorApp.E2ETests/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
39 changes: 12 additions & 27 deletions ProcureHub.BlazorApp.E2ETests/Features/AccessControlTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,72 +11,57 @@ 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]
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]
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]
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]
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]
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();
}
Expand All @@ -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();
}
Expand Down
9 changes: 3 additions & 6 deletions ProcureHub.BlazorApp.E2ETests/Features/LoginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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']");
Expand Down
15 changes: 3 additions & 12 deletions ProcureHub.BlazorApp.E2ETests/Features/NavigationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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();
}

Expand All @@ -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();
}
}
Loading
Loading