diff --git a/PPMTool.Tests/API/BaseApiTest.cs b/PPMTool.Tests/API/BaseApiTest.cs
index d4242aff3..a74b18a56 100644
--- a/PPMTool.Tests/API/BaseApiTest.cs
+++ b/PPMTool.Tests/API/BaseApiTest.cs
@@ -27,6 +27,12 @@ public static HttpClient GetClientAsManager()
return client;
}
+ [OneTimeSetUp]
+ public virtual void OneTimeSetup()
+ {
+ SetupForAPI();
+ }
+
public void SetupForAPI()
{
// Get the API key to use from the database
@@ -80,5 +86,20 @@ public void SetupForAPI()
throw new Exception("No valid API keys found for a manager with a report in the database. Please create one for testing.");
}
}
+
+ ///
+ /// Gets the start date for a date range query (one month ago).
+ ///
+ protected static string GetStartDate() => DateTime.Now.AddMonths(-1).ToString("yyyy-MM-dd");
+
+ ///
+ /// Gets the end date for a date range query (today).
+ ///
+ protected static string GetEndDate() => DateTime.Now.ToString("yyyy-MM-dd");
+
+ ///
+ /// Gets the current year for year-based queries.
+ ///
+ protected static int GetCurrentYear() => DateTime.Now.Year;
}
}
diff --git a/PPMTool.Tests/API/LeaveBookings/EndpointOKTests.cs b/PPMTool.Tests/API/LeaveBookings/EndpointOKTests.cs
new file mode 100644
index 000000000..2b536b3d2
--- /dev/null
+++ b/PPMTool.Tests/API/LeaveBookings/EndpointOKTests.cs
@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: 2026 University of Manchester
+//
+// SPDX-License-Identifier: apache-2.0
+
+namespace PPMTool.Tests.API.LeaveBookings
+{
+ [TestFixture]
+ public class EndpointOKTests : BaseApiTest
+ {
+ [Test]
+ public async Task GetStaffBookingsForYearShouldReturnOKOrErrorDependingOnDatabaseAvailability()
+ {
+ using (var client = GetClientAsManager())
+ {
+ var response = await client.GetAsync($"/leavebookings/getForSelfAndStaff?year={GetCurrentYear()}");
+ // This endpoint may return 500 if the Leave Bookings database is not available
+ // We just verify that the endpoint is accessible and returns a valid response
+ Assert.That(
+ response.StatusCode == System.Net.HttpStatusCode.OK ||
+ response.StatusCode == System.Net.HttpStatusCode.InternalServerError ||
+ response.StatusCode == System.Net.HttpStatusCode.BadRequest
+ );
+ }
+ }
+ }
+}
diff --git a/PPMTool.Tests/API/Skills/EndpointOKTests.cs b/PPMTool.Tests/API/Skills/EndpointOKTests.cs
index ac35bc6a1..071e50dc5 100644
--- a/PPMTool.Tests/API/Skills/EndpointOKTests.cs
+++ b/PPMTool.Tests/API/Skills/EndpointOKTests.cs
@@ -16,5 +16,25 @@ public async Task GetAllSkillsShouldReturnOK()
Assert.That(response.IsSuccessStatusCode);
}
}
+
+ [Test]
+ public async Task GetAllSkillsForPersonShouldReturnOK()
+ {
+ using (var client = GetClientAsManager())
+ {
+ var response = await client.GetAsync("/skills/getAllForPerson");
+ Assert.That(response.IsSuccessStatusCode);
+ }
+ }
+
+ [Test]
+ public async Task GetAllSkillsGroupedShouldReturnOK()
+ {
+ using (var client = GetClientAsManager())
+ {
+ var response = await client.GetAsync("/skills/getAllGrouped");
+ Assert.That(response.IsSuccessStatusCode);
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/PPMTool.Tests/API/Timesheets/EndpointOKTests.cs b/PPMTool.Tests/API/Timesheets/EndpointOKTests.cs
index 2a0dd7a0d..9854f4238 100644
--- a/PPMTool.Tests/API/Timesheets/EndpointOKTests.cs
+++ b/PPMTool.Tests/API/Timesheets/EndpointOKTests.cs
@@ -7,5 +7,24 @@ namespace PPMTool.Tests.API.Timesheets
[TestFixture]
public class EndpointOKTests : BaseApiTest
{
+ [Test]
+ public async Task GetTimesheetEntriesForPersonForDateRangeShouldReturnOK()
+ {
+ using (var client = GetClientAsManager())
+ {
+ var response = await client.GetAsync($"/timesheets/getEntries?startDate={GetStartDate()}&endDate={GetEndDate()}");
+ Assert.That(response.IsSuccessStatusCode);
+ }
+ }
+
+ [Test]
+ public async Task GetTimesheetBookingsByCodeAndTaskShouldReturnOK()
+ {
+ using (var client = GetClientAsManager())
+ {
+ var response = await client.GetAsync($"/timesheets/getByCodeTask?startDate={GetStartDate()}&endDate={GetEndDate()}");
+ Assert.That(response.IsSuccessStatusCode);
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/PPMTool.Tests/API/WorkloadModelAnalysis/EndpointOKTests.cs b/PPMTool.Tests/API/WorkloadModelAnalysis/EndpointOKTests.cs
new file mode 100644
index 000000000..a6d4f0de9
--- /dev/null
+++ b/PPMTool.Tests/API/WorkloadModelAnalysis/EndpointOKTests.cs
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2026 University of Manchester
+//
+// SPDX-License-Identifier: apache-2.0
+
+namespace PPMTool.Tests.API.WorkloadModelAnalysis
+{
+ [TestFixture]
+ public class EndpointOKTests : BaseApiTest
+ {
+ [Test]
+ public async Task GetWorkloadAnalysisDataShouldReturnOK()
+ {
+ using (var client = GetClientAsManager())
+ {
+ // Query requires personNames parameter
+ // Use empty string to get data for the API key owner by default
+ var response = await client.GetAsync($"/wlm/getAnalysis?personNames=&startDate={GetStartDate()}&endDate={GetEndDate()}");
+
+ // The endpoint can return various status codes depending on parameters:
+ // 200 OK if successful
+ // 400 Bad Request if parameters are invalid
+ // We verify the endpoint is accessible and returns a valid response
+ Assert.That(response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.BadRequest);
+ }
+ }
+
+ [Test]
+ public async Task GetWorkloadAnalysisDataWithComparisonShouldReturnOK()
+ {
+ using (var client = GetClientAsManager())
+ {
+ var response = await client.GetAsync($"/wlm/getAnalysis?personNames=&startDate={GetStartDate()}&endDate={GetEndDate()}&compareToWLM=true&normalisedByTotalHours=true");
+
+ Assert.That(response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.BadRequest);
+ }
+ }
+ }
+}
diff --git a/PPMTool.Tests/Core/PageLoadTests.cs b/PPMTool.Tests/Core/PageLoadTests.cs
index a83587679..4e1e40498 100644
--- a/PPMTool.Tests/Core/PageLoadTests.cs
+++ b/PPMTool.Tests/Core/PageLoadTests.cs
@@ -4,22 +4,323 @@
namespace PPMTool.Tests.Core
{
- [Parallelizable(ParallelScope.Self)]
+ [Parallelizable(ParallelScope.None)]
[TestFixture]
public class PageLoadTests : PageTest
{
- [Test]
- public async Task HomepageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ // Timeouts (in milliseconds)
+ private const int pageLoadTimeoutMs = 5000;
+ private const int pageTitleTimeoutMs = 2000;
+ private const int loginButtonTimeoutMs = 2000;
+ private const int tearDownDelayMs = 1000;
+ private const int pageStabilizationDelayMs = 1000;
+ private const int loginAuthenticationDelayMs = 1000;
+ private const int loginRetryDelayMs = 1000;
+
+ // Retry configuration
+ private const int navigationRetries = 5;
+ private const int retryDelayMs = 1000;
+ private const int maxLoginAttempts = 3;
+
+ [TearDown]
+ public async Task TearDownTest()
+ {
+ // Clear cookies and storage to ensure clean state between tests
+ // This forces re-authentication each time
+ try
+ {
+ await Page.Context.ClearCookiesAsync();
+ await Page.EvaluateAsync("() => localStorage.clear()");
+ await Page.EvaluateAsync("() => sessionStorage.clear()");
+ }
+ catch
+ {
+ // Ignore errors during cleanup
+ }
+
+ // Add a delay between tests to allow the server to recover
+ await Task.Delay(tearDownDelayMs);
+ }
+
+ ///
+ /// Verifies that a page loads correctly by checking the title and ensuring that the Blazor crash banner is not visible.
+ ///
+ ///
+ ///
+ ///
+ ///
+ private async Task VerifyPageLoaded(string url, string expectedTitle, bool skipLoginCheck = false)
{
- // Navigate the browser to the homepage
- await Page.GotoAsync($"{Setup.BaseUrl}");
+ // Navigate to the page with retry logic
+ await NavigateWithRetryAsync($"{Setup.BaseUrl}{url}");
+
+ // Check if we're on the login page (unauthenticated) by checking the page title
+ if (!skipLoginCheck)
+ {
+ // Wait a bit for the page to stabilize
+ await Task.Delay(pageStabilizationDelayMs);
+
+ // Wait for the page to have a title using timeout via context options
+ var titleRegex = new Regex(".*");
+ await Expect(Page).ToHaveTitleAsync(titleRegex);
+
+ var currentTitle = await Page.TitleAsync();
+ if (currentTitle == "CapX - Log In")
+ {
+ // We're on the login page, need to authenticate
+ Console.WriteLine($"Detected login page, authenticating before navigating to {url}");
+ await HandleLoginAndNavigateAsync(url);
+ }
+ else
+ {
+ // Verify we have the content we expect by waiting for page to fully load
+ await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ }
+ }
// Assert that the page title is correct
- await Expect(Page).ToHaveTitleAsync("CapX - Log In");
+ await Expect(Page).ToHaveTitleAsync(expectedTitle);
// Assert that the Blazor crash banner is not visible
var crashBanner = Page.Locator("#blazor-error-ui");
- await Expect(crashBanner).Not.ToBeVisibleAsync();
+ await Expect(crashBanner).ToBeHiddenAsync();
+ }
+
+ ///
+ /// Navigates to a URL with retry logic to handle temporary connection issues.
+ ///
+ ///
+ ///
+ private async Task NavigateWithRetryAsync(string url)
+ {
+ int retries = 0;
+ while (retries < navigationRetries)
+ {
+ try
+ {
+ await Page.GotoAsync(url, new() { WaitUntil = WaitUntilState.NetworkIdle });
+ return;
+ }
+ catch (Exception ex) when (retries < navigationRetries - 1 &&
+ (ex.Message.Contains("ERR_CONNECTION_REFUSED") ||
+ ex.Message.Contains("ERR_ABORTED") ||
+ ex.Message.Contains("ERR_NETWORK_CHANGED")))
+ {
+ retries++;
+ await Task.Delay(retryDelayMs);
+ }
+ }
+
+ // Final attempt without retry
+ await Page.GotoAsync(url, new() { WaitUntil = WaitUntilState.NetworkIdle });
+ }
+
+ ///
+ /// Handles the login process by clicking the Log In button and waiting for authentication to complete,
+ /// then navigates to the target URL. Includes retry logic to handle transient failures.
+ ///
+ ///
+ ///
+ private async Task HandleLoginAndNavigateAsync(string targetUrl)
+ {
+ int loginAttempts = 0;
+
+ while (loginAttempts < maxLoginAttempts)
+ {
+ try
+ {
+ // Find the Log in button - in LOCAL mode this link contains the username parameter
+ var loginButton = Page.Locator("a:has-text('Log in')").First;
+
+ if (await loginButton.IsVisibleAsync())
+ {
+ Console.WriteLine($"Login attempt {loginAttempts + 1}/{maxLoginAttempts}: Clicking login button");
+
+ // Click the Log in button
+ await loginButton.ClickAsync();
+
+ // Wait for navigation to complete
+ await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Small delay to let authentication finish
+ await Task.Delay(loginAuthenticationDelayMs);
+
+ // Navigate to the target page
+ await NavigateWithRetryAsync($"{Setup.BaseUrl}{targetUrl}");
+
+ // Verify we're not on the login page anymore
+ var titleAfterLogin = await Page.TitleAsync();
+ if (titleAfterLogin != "CapX - Log In")
+ {
+ Console.WriteLine($"Successfully authenticated and navigated to {targetUrl}");
+ return;
+ }
+ else
+ {
+ throw new InvalidOperationException("Still on login page after clicking login button");
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException("Login button not visible on login page");
+ }
+ }
+ catch (Exception ex)
+ {
+ loginAttempts++;
+ if (loginAttempts >= maxLoginAttempts)
+ {
+ throw new InvalidOperationException($"Failed to authenticate after {maxLoginAttempts} attempts: {ex.Message}", ex);
+ }
+
+ Console.WriteLine($"Login attempt failed: {ex.Message}. Retrying...");
+ await Task.Delay(loginRetryDelayMs);
+ }
+ }
+ }
+
+ [Test]
+ public async Task LogInPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/", "CapX - Log In", true);
+ }
+
+ [Test]
+ public async Task MyProjectsPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/myprojects", "My Projects - CapX");
+ }
+
+ [Test]
+ public async Task ProjectsPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/projects", "Projects - CapX");
+ }
+
+ [Test]
+ public async Task PeoplePageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/people", "People - CapX");
+ }
+
+ [Test]
+ public async Task CapacityPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/capacity", "Capacity - CapX");
+ }
+
+ [Test]
+ public async Task TimesheetsPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/timesheets", "Timesheets - CapX");
+ }
+
+ [Test]
+ public async Task AbsencesPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/absences", "Absences - CapX");
+ }
+
+ [Test]
+ public async Task CompetencyFrameworkPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/competencies", "Development Journey - CapX");
+ }
+
+ [Test]
+ public async Task ManageSkillsPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/manageskills", "Manage Skills - CapX");
+ }
+
+ [Test]
+ public async Task ManageAccessPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/manageaccess", "Manage Access - CapX");
+ }
+
+ [Test]
+ public async Task ManageSettingsPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/managesettings", "Manage Settings - CapX");
+ }
+
+ [Test]
+ public async Task ManageFeaturesPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/managefeatures", "Manage Features - CapX");
+ }
+
+ [Test]
+ public async Task ManageOrgUnitsPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/manageorgunits", "Manage Org Units - CapX");
+ }
+
+ [Test]
+ public async Task ManageInnateCodesPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/managecodes", "Manage Timesheet Codes - CapX");
+ }
+
+ [Test]
+ public async Task ManageFinancialItemsPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/managefinancialitems", "Manage Finance Items - CapX");
+ }
+
+ [Test]
+ public async Task ManageFinancialReferencesPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/managefinref", "Manage Financial Refs - CapX");
+ }
+
+ [Test]
+ public async Task FinanceSummaryPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/managefinancialitems/summary", "Finance Summary - CapX");
+ }
+
+ [Test]
+ public async Task DataDashboardPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/datadashboard", "Data Dashboard - CapX");
+ }
+
+ [Test]
+ public async Task EstimateCostPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/estimatecost", "Estimate Cost - CapX");
+ }
+
+ [Test]
+ public async Task ManagementCapacityPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/managementcapacity", "Management Capacity - CapX");
+ }
+
+ [Test]
+ public async Task WorkloadModelAnalysisPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/wlmanalysis", "WLM Analysis - CapX");
+ }
+
+ [Test]
+ public async Task ProjectBulletinBoardPageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/projectbulletinboard", "Available Projects - CapX");
+ }
+
+ [Test]
+ public async Task UserProfilePageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/profile", "Profile - CapX");
+ }
+
+ [Test]
+ public async Task NothingHerePageShouldLoadWithCorrectTitleAndNoCrashBanner()
+ {
+ await VerifyPageLoaded("/nothinghere", "Nothing Here - CapX");
}
}
}
diff --git a/PPMTool.Tests/PPMTool.Tests.csproj b/PPMTool.Tests/PPMTool.Tests.csproj
index 1071a657a..a5c0e447e 100644
--- a/PPMTool.Tests/PPMTool.Tests.csproj
+++ b/PPMTool.Tests/PPMTool.Tests.csproj
@@ -35,6 +35,7 @@ SPDX-License-Identifier: apache-2.0
+
diff --git a/PPMTool.Tests/Setup.cs b/PPMTool.Tests/Setup.cs
index 702dcdc69..5338867ed 100644
--- a/PPMTool.Tests/Setup.cs
+++ b/PPMTool.Tests/Setup.cs
@@ -10,13 +10,51 @@ public class Setup
public static string BaseUrl { get; } = "https://localhost:5001";
[OneTimeSetUp]
- public void SetupForAll()
+ public async Task SetupForAll()
{
+ // Wait for the server to be ready before running tests
+ await WaitForServerAsync();
}
[OneTimeTearDown]
public void TearDown()
{
}
+
+ ///
+ /// Waits for the application server to be ready before running tests.
+ /// This ensures that the server is accessible and responding to requests.
+ ///
+ private static async Task WaitForServerAsync(int maxRetries = 30, int delayMs = 1000)
+ {
+ using var handler = new HttpClientHandler();
+ // Ignore SSL certificate issues for localhost testing
+ handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true;
+
+ using var client = new HttpClient(handler);
+ var retries = 0;
+
+ while (retries < maxRetries)
+ {
+ try
+ {
+ var response = await client.GetAsync($"{BaseUrl}/");
+ if (response.IsSuccessStatusCode)
+ {
+ Console.WriteLine("✓ Server is ready");
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Server not ready yet ({retries + 1}/{maxRetries}): {ex.Message}");
+ }
+
+ retries++;
+ await Task.Delay(delayMs);
+ }
+
+ throw new InvalidOperationException($"Server at {BaseUrl} did not become ready after {maxRetries * delayMs}ms");
+ }
}
-}
\ No newline at end of file
+}