Skip to content

Commit 800dd81

Browse files
committed
Add k6 load testing suite and documentation
- Introduced load testing scripts for various endpoints: - `dashboard-load.js`: Simulates concurrent users accessing the Executive Dashboard. - `department-crud-load.js`: Tests CRUD operations for the Department controller. - `notification-load.js`: Tests the Notifications endpoint for concurrent access. - `task-api-load.js`: Tests the Task API for creation, updates, and retrieval. - `reporting-smoke.js`: Smoke test for reporting endpoints. - Created comprehensive README.md in `Tests/load-tests/` directory detailing: - Installation instructions for k6. - Test file descriptions and usage. - Key metrics to monitor during tests. - Performance targets for various endpoints. - CI integration guidelines. - Added deployment and QA infrastructure documentation: - `DEPLOYMENT.md`: Outlines environment configuration, branch workflow, deployment procedures, and rollback instructions. - `QA-INFRASTRUCTURE-REPORT.md`: Comprehensive report on QA improvements, test coverage metrics, and future recommendations.
1 parent 038305f commit 800dd81

47 files changed

Lines changed: 7946 additions & 3 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci-tests-coverage.yml

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,42 @@ jobs:
4343
--configuration Release \
4444
--no-build \
4545
--verbosity normal \
46+
--settings Tests/coverlet.runsettings.xml \
4647
--collect:"XPlat Code Coverage" \
4748
--results-directory TestResults \
48-
--logger "trx;LogFileName=test-results.trx"
49+
--logger "trx;LogFileName=test-results.trx" \
50+
/p:ExcludeByFile="**/Migrations/*.cs%3b**/Areas/Identity/**/*.cs%3b**/*.g.cs%3b**/*.designer.cs"
51+
- name: Install ReportGenerator for HTML coverage
52+
if: always()
53+
run: dotnet tool install -g dotnet-reportgenerator-globaltool || dotnet tool update -g dotnet-reportgenerator-globaltool
54+
55+
- name: Generate HTML coverage report
56+
if: always()
57+
run: |
58+
reportgenerator \
59+
-reports:TestResults/**/coverage.cobertura.xml \
60+
-targetdir:TestResults/coverage-html \
61+
-reporttypes:Html;HtmlSummary
62+
4963
5064
- name: Upload test results and coverage
5165
if: always()
5266
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
5367
with:
5468
name: test-results-and-coverage
5569
path: TestResults
70+
71+
- name: Upload HTML coverage report
72+
if: always()
73+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
74+
with:
75+
name: coverage-html-report
76+
path: TestResults/coverage-html
77+
78+
- name: Generate test & coverage summary
79+
if: always()
80+
run: |
81+
echo "### Test Results" >> $GITHUB_STEP_SUMMARY
82+
echo "- **Framework**: xUnit (FluentAssertions, Moq)" >> $GITHUB_STEP_SUMMARY
83+
echo "- **Coverage Tool**: Coverlet" >> $GITHUB_STEP_SUMMARY
84+
echo "- **Excluded**: Migrations, Identity scaffolding, generated code" >> $GITHUB_STEP_SUMMARY

.github/workflows/ci.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: CI – Build & Test
2+
3+
on:
4+
push:
5+
branches: [main, dev]
6+
pull_request:
7+
branches: [main, dev]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
build-and-test:
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 20
16+
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Setup .NET 8
22+
uses: actions/setup-dotnet@v4
23+
with:
24+
dotnet-version: 8.0.x
25+
26+
- name: Cache NuGet packages
27+
uses: actions/cache@v4
28+
with:
29+
path: ~/.nuget/packages
30+
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
31+
restore-keys: ${{ runner.os }}-nuget-
32+
33+
- name: Restore
34+
run: dotnet restore CompanyManagementSystem.sln
35+
36+
- name: Build
37+
run: dotnet build CompanyManagementSystem.sln --no-restore --configuration Release
38+
39+
- name: Test with coverage
40+
run: |
41+
dotnet test Tests/Tests.csproj \
42+
--no-build \
43+
--configuration Release \
44+
--logger "trx;LogFileName=test-results.trx" \
45+
--settings Tests/coverlet.runsettings.xml \
46+
--collect:"XPlat Code Coverage" \
47+
--results-directory ./TestResults \
48+
/p:ExcludeByFile="**/Migrations/*.cs%3b**/Areas/Identity/**/*.cs%3b**/*.g.cs%3b**/*.designer.cs"
49+
50+
- name: Upload test results
51+
if: always()
52+
uses: actions/upload-artifact@v4
53+
with:
54+
name: test-results
55+
path: ./TestResults/**/*.trx
56+
57+
- name: Upload coverage report
58+
if: always()
59+
uses: actions/upload-artifact@v4
60+
with:
61+
name: coverage-report
62+
path: ./TestResults/**/coverage.cobertura.xml

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Visual Studio / .NET
33
# =========================
44
.vs/
5+
.vscode/
56
bin/
67
obj/
78
[Bb]uild/

ERP.PL/ERP.PL.csproj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
<Project Sdk="Microsoft.NET.Sdk.Web">
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
44
<TargetFramework>net8.0</TargetFramework>
55
<Nullable>enable</Nullable>
66
<ImplicitUsings>enable</ImplicitUsings>
7+
<UserSecretsId>0b0d445a-714e-4a6e-8191-649508c06636</UserSecretsId>
78
</PropertyGroup>
89

910
<ItemGroup>
10-
<PackageReference Include="AutoMapper" Version="16.1.0" />
11+
<PackageReference Include="AutoMapper" Version="16.1.1" />
1112
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.24" />
1213
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
1314
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.24">

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
[![EF Core](https://img.shields.io/badge/EF%20Core-8.0-512BD4)](https://learn.microsoft.com/en-us/ef/core/)
77
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
88
[![Live Demo](https://img.shields.io/badge/Live%20Demo-companyflow.runasp.net-brightgreen?logo=microsoftazure)](https://companyflow.runasp.net/)
9+
[![Tests](https://img.shields.io/badge/tests-400+-brightgreen?logo=xunit)](Tests)
10+
[![Coverage](https://img.shields.io/badge/coverage-~70%25-yellowgreen)]()
11+
[![Unit Tests](https://img.shields.io/badge/unit%20tests-389%2B-blue)]()
912

1013
> **Live Demo:** [https://companyflow.runasp.net](https://companyflow.runasp.net/)
1114

Tests.UI/DepartmentTests.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using Microsoft.Playwright;
2+
using NUnit.Framework;
3+
using FluentAssertions;
4+
5+
namespace Tests.UI;
6+
7+
/// <summary>
8+
/// End-to-end tests for the Departments module.
9+
/// Logs in as CEO, creates a department, verifies it appears in the table,
10+
/// then edits and deletes it.
11+
/// </summary>
12+
[TestFixture]
13+
[Category("UI")]
14+
public class DepartmentTests : PlaywrightFixture
15+
{
16+
private static readonly string CeoEmail = Environment.GetEnvironmentVariable("CEO_EMAIL") ?? "ceo@companyflow.com";
17+
private static readonly string CeoPassword = Environment.GetEnvironmentVariable("CEO_PASSWORD") ?? "Admin@123456!";
18+
19+
[SetUp]
20+
public async Task SetUp()
21+
{
22+
await LoginAsAsync(Page, CeoEmail, CeoPassword);
23+
}
24+
25+
// ── Navigate to Departments ──
26+
27+
[Test]
28+
public async Task Department_Index_PageLoads()
29+
{
30+
await Page.GotoAsync($"{BaseUrl}/Department");
31+
await Page.WaitForLoadStateAsync();
32+
33+
var title = await Page.TitleAsync();
34+
title.Should().NotBeNullOrEmpty();
35+
// Verify we are on departments page (not redirected to login)
36+
Page.Url.Should().Contain("/Department");
37+
}
38+
39+
// ── Create Department ──
40+
41+
[Test]
42+
public async Task Department_Create_ValidData_AppearsInList()
43+
{
44+
var uniqueCode = $"TEST_{DateTime.Now:mmss}";
45+
var deptName = $"UI Test Dept {DateTime.Now:mmss}";
46+
47+
await Page.GotoAsync($"{BaseUrl}/Department/Create");
48+
await Page.WaitForLoadStateAsync();
49+
50+
// Fill form
51+
await Page.FillAsync("input[name='DepartmentCode']", uniqueCode);
52+
await Page.FillAsync("input[name='DepartmentName']", deptName);
53+
await Page.ClickAsync("button[type='submit']");
54+
55+
// Should redirect to index
56+
await Page.WaitForURLAsync(url => url.Contains("/Department") && !url.Contains("/Create"),
57+
new PageWaitForURLOptions { Timeout = 10_000 });
58+
59+
// Verify the new department appears in the list
60+
var pageContent = await Page.ContentAsync();
61+
pageContent.Should().Contain(deptName);
62+
}
63+
64+
// ── Create with duplicate code returns validation error ──
65+
66+
[Test]
67+
public async Task Department_Create_DuplicateCode_ShowsError()
68+
{
69+
await Page.GotoAsync($"{BaseUrl}/Department/Create");
70+
await Page.WaitForLoadStateAsync();
71+
72+
// Use a code that is very likely to already exist or use an obvious test one
73+
await Page.FillAsync("input[name='DepartmentCode']", "INVALID CODE!@#");
74+
await Page.FillAsync("input[name='DepartmentName']", "Test Department");
75+
await Page.ClickAsync("button[type='submit']");
76+
77+
// Should remain on create page when validation fails
78+
await Page.WaitForLoadStateAsync();
79+
// Either stays on create page or shows error
80+
var isOnPage = Page.Url.Contains("/Department");
81+
isOnPage.Should().BeTrue();
82+
}
83+
}

Tests.UI/LoginTests.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using Microsoft.Playwright;
2+
using NUnit.Framework;
3+
using FluentAssertions;
4+
5+
namespace Tests.UI;
6+
7+
/// <summary>
8+
/// End-to-end tests for the login flow.
9+
/// Tests valid login, invalid credentials, and inactive account handling.
10+
/// </summary>
11+
[TestFixture]
12+
[Category("UI")]
13+
public class LoginTests : PlaywrightFixture
14+
{
15+
// ── Valid login flow ──
16+
17+
[Test]
18+
public async Task Login_ValidCeoCredentials_RedirectsToDashboard()
19+
{
20+
var email = Environment.GetEnvironmentVariable("CEO_EMAIL") ?? "ceo@companyflow.com";
21+
var password = Environment.GetEnvironmentVariable("CEO_PASSWORD") ?? "Admin@123456!";
22+
23+
await Page.GotoAsync($"{BaseUrl}/Account/Login");
24+
await Page.FillAsync("input[name='Email']", email);
25+
await Page.FillAsync("input[name='Password']", password);
26+
await Page.ClickAsync("button[type='submit']");
27+
28+
await Page.WaitForURLAsync(url => !url.Contains("/Account/Login"),
29+
new PageWaitForURLOptions { Timeout = 10_000 });
30+
31+
Page.Url.Should().NotContain("/Account/Login");
32+
}
33+
34+
// ── Invalid credentials ──
35+
36+
[Test]
37+
public async Task Login_InvalidPassword_ShowsErrorMessage()
38+
{
39+
await Page.GotoAsync($"{BaseUrl}/Account/Login");
40+
await Page.FillAsync("input[name='Email']", "invalid@test.com");
41+
await Page.FillAsync("input[name='Password']", "WrongPassword123!");
42+
await Page.ClickAsync("button[type='submit']");
43+
44+
// Should remain on login page
45+
await Page.WaitForURLAsync(url => url.Contains("/Account/Login"),
46+
new PageWaitForURLOptions { Timeout = 5_000 });
47+
48+
var errorText = await Page.IsVisibleAsync("[class*='text-danger'], [class*='alert-danger'], .validation-summary-errors");
49+
errorText.Should().BeTrue("an error message should be displayed for invalid credentials");
50+
}
51+
52+
// ── Empty form ──
53+
54+
[Test]
55+
public async Task Login_EmptyForm_ShowsValidationErrors()
56+
{
57+
await Page.GotoAsync($"{BaseUrl}/Account/Login");
58+
await Page.ClickAsync("button[type='submit']");
59+
60+
await Page.WaitForURLAsync(url => url.Contains("/Account/Login"),
61+
new PageWaitForURLOptions { Timeout = 5_000 });
62+
63+
var hasErrors = await Page.IsVisibleAsync("[class*='text-danger'], [data-valmsg-for]");
64+
hasErrors.Should().BeTrue();
65+
}
66+
67+
// ── Logout ──
68+
69+
[Test]
70+
public async Task Logout_AfterLogin_RedirectsToLogin()
71+
{
72+
var email = Environment.GetEnvironmentVariable("CEO_EMAIL") ?? "ceo@companyflow.com";
73+
var password = Environment.GetEnvironmentVariable("CEO_PASSWORD") ?? "Admin@123456!";
74+
75+
await LoginAsAsync(Page, email, password);
76+
await LogoutAsync(Page);
77+
78+
// After logout, should be directed away from authenticated pages
79+
await Page.WaitForLoadStateAsync();
80+
Page.Url.Should().NotContain("/Executive");
81+
}
82+
83+
// ── Access denied without auth ──
84+
85+
[Test]
86+
public async Task UnauthenticatedAccess_ProtectedPage_RedirectsToLogin()
87+
{
88+
await Page.GotoAsync($"{BaseUrl}/Department");
89+
90+
await Page.WaitForLoadStateAsync();
91+
Page.Url.Should().Contain("/Account/Login");
92+
}
93+
}

Tests.UI/PlaywrightFixture.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using Microsoft.Playwright;
2+
using Microsoft.Playwright.NUnit;
3+
using NUnit.Framework;
4+
5+
namespace Tests.UI;
6+
7+
/// <summary>
8+
/// Shared Playwright fixture providing a configured browser page per test.
9+
/// Set BASE_URL env var to point at the running application (default: http://localhost:5000).
10+
/// Set HEADLESS=false to run tests with a visible browser (useful for local debugging).
11+
/// </summary>
12+
[TestFixture]
13+
public abstract class PlaywrightFixture : PageTest
14+
{
15+
protected static readonly string BaseUrl =
16+
Environment.GetEnvironmentVariable("BASE_URL") ?? "http://localhost:5000";
17+
18+
public override BrowserNewContextOptions ContextOptions()
19+
{
20+
return new BrowserNewContextOptions
21+
{
22+
IgnoreHTTPSErrors = true,
23+
ViewportSize = new ViewportSize { Width = 1280, Height = 720 }
24+
};
25+
}
26+
27+
protected async Task LoginAsAsync(IPage page, string email, string password)
28+
{
29+
await page.GotoAsync($"{BaseUrl}/Account/Login");
30+
await page.FillAsync("input[name='Email']", email);
31+
await page.FillAsync("input[name='Password']", password);
32+
await page.ClickAsync("button[type='submit']");
33+
// Wait for redirect after login
34+
await page.WaitForURLAsync(url => !url.Contains("/Account/Login"), new PageWaitForURLOptions { Timeout = 10_000 });
35+
}
36+
37+
protected async Task LogoutAsync(IPage page)
38+
{
39+
await page.GotoAsync($"{BaseUrl}/Account/Logout");
40+
}
41+
}

0 commit comments

Comments
 (0)