diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 713efae73..e531a4564 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -71,7 +71,7 @@ jobs: # TESTS: UNIT, INTEGRATION, UI # --------------------------- test: - name: Run Unit, Integration and UI Tests + name: Run Unit, Partial Integration and UI Tests if: ${{ github.event_name != 'workflow_dispatch' }} permissions: pull-requests: write @@ -110,12 +110,12 @@ jobs: working-directory: SAPPub.Web run: npm ci - - name: Run All Tests with Coverage + - name: Run Tests with Coverage env: HEADED: 0 PLAYWRIGHT_IGNORE_HTTPS_ERRORS: true run: | - TEST_PROJECTS=$(find ./Tests -type f -name "*.Tests.csproj" | sort) + TEST_PROJECTS=$(find ./Tests -type f -name "*.Tests.csproj" ! -path "*/SAPPub.Integration.Tests/*" | sort) for proj in $TEST_PROJECTS; do echo "=== Running tests in $proj ===" @@ -210,6 +210,8 @@ jobs: name: Deployment To Review (no refresh) concurrency: deploy_review_${{ github.event.pull_request.number }} needs: [build, test] + outputs: + environment_url: ${{ steps.deploy_review.outputs.environment_url }} if: ${{ github.event_name == 'pull_request' && ( github.event.action == 'labeled' || @@ -399,6 +401,8 @@ jobs: name: Deployment To Review (before refresh) concurrency: deploy_review_${{ github.event.pull_request.number }} needs: [build, test] + outputs: + environment_url: ${{ steps.deploy_review.outputs.environment_url }} if: ${{ github.event_name == 'pull_request' && ( github.event.action == 'labeled' || @@ -434,9 +438,30 @@ jobs: gcp-wip: ${{ vars.GCP_WIP }} gcp-project-id: ${{ vars.GCP_PROJECT_ID }} + # --------------------------- + # INTEGRATION TESTS (POST DEPLOYMENT) + # --------------------------- + integration-tests: + name: Integration Tests (Post Deployment) + needs: [deploy-review-app] + uses: ./.github/workflows/integration-tests.yml + with: + environment: review + base_url: ${{ needs.deploy-review-app.outputs.environment_url }} + + # --------------------------- + # INTEGRATION TESTS (POST DEPLOYMENT) + # --------------------------- + integration-tests_with_refresh: + name: Integration Tests (Post Deployment) + needs: [deploy-review-app-with-refresh] + uses: ./.github/workflows/integration-tests.yml + with: + environment: review + base_url: ${{ needs.deploy-review-app-with-refresh.outputs.environment_url }} # --------------------------- - # DEPLOY TEST (AUTO) + # DEPLOY TO TEST (AUTO) # --------------------------- deploy: name: Deploy to Test @@ -476,7 +501,7 @@ jobs: gcp-project-id: ${{ vars.GCP_PROJECT_ID }} # --------------------------- - # REFRESH TEST DATABSE (AUTO - IF REQUIRED) + # REFRESH TEST DATABASE (AUTO - IF REQUIRED) # --------------------------- detect-refresh-data-on-merge: @@ -533,7 +558,6 @@ jobs: actions: read packages: write - # --------------------------- # MANUAL DEPLOY # --------------------------- @@ -629,4 +653,4 @@ jobs: healthcheck: healthcheck teams-webhook-url: ${{ secrets.TEAMS_WEBHOOK_URL }} service: ${{ vars.TEAMS_MSG_SERVICE_NAME }} - terraform-base: terraform/domains/environment_domains + terraform-base: terraform/domains/environment_domains \ No newline at end of file diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 000000000..fb46409f4 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,61 @@ +name: Integration Tests + +on: + workflow_call: + inputs: + environment: + required: true + type: string + base_url: + required: true + type: string + +jobs: + integration-tests: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build integration tests + run: dotnet build Tests/SAPPub.Integration.Tests/SAPPub.IntegrationTests.csproj --configuration Release + + - name: Install Playwright browsers + run: pwsh ./Tests/SAPPub.Integration.Tests/bin/Release/net8.0/playwright.ps1 install + + - name: Run integration tests + env: + HEADED: 0 + PLAYWRIGHT_IGNORE_HTTPS_ERRORS: true + ASPNETCORE_ENVIRONMENT: CI + BASE_URL: ${{ inputs.base_url }} + run: | + dotnet test Tests/SAPPub.Integration.Tests/SAPPub.IntegrationTests.csproj \ + --no-build \ + --configuration Release \ + --results-directory "TestResults" \ + --logger "trx;LogFileName=integration-tests.trx" + + - name: Upload Test Result Files + uses: actions/upload-artifact@v4 + if: always() + with: + name: post-deployment-test-results + path: "**/TestResults/**/*" + + - name: Publish Test Results + uses: dorny/test-reporter@v2 + if: always() + with: + reporter: dotnet-trx + path: "**/TestResults/**/*.trx" + name: Post Deployment Test Results \ No newline at end of file diff --git a/SAPPub.Web/Program.cs b/SAPPub.Web/Program.cs index 5a156efb7..20fd7836d 100644 --- a/SAPPub.Web/Program.cs +++ b/SAPPub.Web/Program.cs @@ -110,12 +110,14 @@ public static void Main(string[] args) // Only required for real runtime environments if (builder.Environment.IsDevelopment() || builder.Environment.IsProduction() || builder.Environment.IsStaging()) throw new InvalidOperationException("Connection string 'PostgresConnectionString' is not configured."); - - // For Testing/UITests: use a harmless dummy so nothing accidentally connects - connectionString = "Host=127.0.0.1;Port=1;Database=x;Username=x;Password=x;Timeout=1;Command Timeout=1"; } - builder.Services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString)); + builder.Services.AddSingleton(_ => + { + var builder = new NpgsqlDataSourceBuilder(connectionString); + builder.EnableParameterLogging(); + return builder.Build(); + }); // Big Query client configuration builder.Services.AddDfeAnalytics().AddAspNetCoreIntegration(options => diff --git a/SAPPub.sln b/SAPPub.sln index 28cfdf8f6..e7a0c48cb 100644 --- a/SAPPub.sln +++ b/SAPPub.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAPData", "SAPData\SAPData. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAPData.Unit.Tests", "Tests\SAPData.Tests.Unit\SAPData.Unit.Tests.csproj", "{292387F8-1210-7181-8056-86EC3D855329}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAPPub.IntegrationTests", "Tests\SAPPub.Integration.Tests\SAPPub.IntegrationTests.csproj", "{D90BBB38-FA34-5ABD-F17F-F30AF1D84C6A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +69,10 @@ Global {292387F8-1210-7181-8056-86EC3D855329}.Debug|Any CPU.Build.0 = Debug|Any CPU {292387F8-1210-7181-8056-86EC3D855329}.Release|Any CPU.ActiveCfg = Release|Any CPU {292387F8-1210-7181-8056-86EC3D855329}.Release|Any CPU.Build.0 = Release|Any CPU + {D90BBB38-FA34-5ABD-F17F-F30AF1D84C6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D90BBB38-FA34-5ABD-F17F-F30AF1D84C6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D90BBB38-FA34-5ABD-F17F-F30AF1D84C6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D90BBB38-FA34-5ABD-F17F-F30AF1D84C6A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -75,6 +81,7 @@ Global {FF6CC6BE-8BD4-4353-BA24-F0B562F73534} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {EB1D1EAE-E264-4FF5-ACB9-8614FD694C69} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {5F164DC3-0185-4D92-B689-7A004F81C232} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {D90BBB38-FA34-5ABD-F17F-F30AF1D84C6A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {292387F8-1210-7181-8056-86EC3D855329} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/Tests/SAPPub.Integration.Tests/Infrastructure/TestBasePage.cs b/Tests/SAPPub.Integration.Tests/Infrastructure/TestBasePage.cs new file mode 100644 index 000000000..0a0f20b08 --- /dev/null +++ b/Tests/SAPPub.Integration.Tests/Infrastructure/TestBasePage.cs @@ -0,0 +1,72 @@ +using Microsoft.Playwright; +using Microsoft.Playwright.Xunit; + +namespace SAPPub.Integration.Tests; + +public abstract class BasePageTest : PageTest +{ + public override BrowserNewContextOptions ContextOptions() + { + var baseUrl = Environment.GetEnvironmentVariable("BASE_URL"); + + if (string.IsNullOrWhiteSpace(baseUrl)) + { + throw new InvalidOperationException( + "BASE_URL environment variable has not been configured."); + } + + return new BrowserNewContextOptions + { + BaseURL = baseUrl.TrimEnd('/'), + IgnoreHTTPSErrors = true, + ViewportSize = new() { Width = 1280, Height = 720 }, + Locale = "en-GB", + TimezoneId = "Europe/London", + JavaScriptEnabled = true, + }; + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + + Page.SetDefaultTimeout((float)TimeSpan.FromSeconds(60).TotalMilliseconds); + Page.SetDefaultNavigationTimeout((float)TimeSpan.FromSeconds(100).TotalMilliseconds); + } + + public async Task WaitForSearchInputsAsync(int timeoutMs = 5000) + { + var selector = "input[name='__Query'], input[name='Query'][type='hidden'], input[name='Query']"; + await Page.WaitForSelectorAsync(selector, new() { Timeout = timeoutMs }); + await Page.WaitForTimeoutAsync(100); + } + public async Task GetQueryInputLocatorAsync(int checkTimeoutMs = 1000) + { + var jsLocator = Page.Locator("input[name='__Query']"); + try + { + if (await jsLocator.CountAsync() > 0) + { + var isVisible = await jsLocator.IsVisibleAsync(); + if (isVisible) return jsLocator; + } + + var serverLocator = Page.Locator("input[name='Query']"); + if (await serverLocator.CountAsync() > 0) return serverLocator; + + var found = await Page.WaitForSelectorAsync("input[name='__Query'], input[name='Query']", new() { Timeout = checkTimeoutMs }); + if (found != null) + { + var nameAttr = await found.GetAttributeAsync("name"); + if (nameAttr == "__Query") + return Page.Locator("input[name='__Query']"); + return Page.Locator("input[name='Query']"); + } + return Page.Locator("input[name='Query']"); + } + catch + { + return Page.Locator("input[name='Query']"); + } + } +} \ No newline at end of file diff --git a/Tests/SAPPub.Integration.Tests/Properties/launchSettings.json b/Tests/SAPPub.Integration.Tests/Properties/launchSettings.json new file mode 100644 index 000000000..c017543b3 --- /dev/null +++ b/Tests/SAPPub.Integration.Tests/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "PlaywrightTests": { + "commandName": "Project", + "environmentVariables": { + "BASE_URL": "https://localhost:5001" + } + } + } +} diff --git a/Tests/SAPPub.Integration.Tests/Properties/playwright.runsettings b/Tests/SAPPub.Integration.Tests/Properties/playwright.runsettings new file mode 100644 index 000000000..70379d5d7 --- /dev/null +++ b/Tests/SAPPub.Integration.Tests/Properties/playwright.runsettings @@ -0,0 +1,9 @@ + + + + + 1 + http://localhost:5013 + + + diff --git a/Tests/SAPPub.Integration.Tests/SAPPub.IntegrationTests.csproj b/Tests/SAPPub.Integration.Tests/SAPPub.IntegrationTests.csproj new file mode 100644 index 000000000..78a388dd2 --- /dev/null +++ b/Tests/SAPPub.Integration.Tests/SAPPub.IntegrationTests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/Tests/SAPPub.Integration.Tests/SecondarySchoolTests/AboutSchoolPageTests.cs b/Tests/SAPPub.Integration.Tests/SecondarySchoolTests/AboutSchoolPageTests.cs new file mode 100644 index 000000000..be742e806 --- /dev/null +++ b/Tests/SAPPub.Integration.Tests/SecondarySchoolTests/AboutSchoolPageTests.cs @@ -0,0 +1,78 @@ +using SAPPub.Integration.Tests; + +namespace SAPPub.IntegrationTests.SecondarySchoolTests; + +[Collection("Integration Tests")] +public class AboutSchoolPageTests() : BasePageTest() +{ + private string PageUrl(string urn) => $"/school/{urn}"; + + [Fact] + public async Task AboutSchoolPage_LoadsSuccessfully() + { + var baseUrl = Environment.GetEnvironmentVariable("BASE_URL"); + + var response = await Page.GotoAsync("/school/105574"); + + Console.WriteLine($"Final URL = {Page.Url}"); + Console.WriteLine($"Status = {response?.Status}"); + // Arrange && Act + //var response = await Page.GotoAsync(PageUrl("105574")); + + // Assert + Assert.NotNull(response); + Assert.Equal(200, response.Status); + } + + [Theory] + [InlineData("114311", true, "31 December 2022")] + [InlineData("149251", false, null)] + public async Task AboutSchoolPage_DisplaysSchoolClosedInfo(string urn, bool isSchoolClosed, string? date) + { + // Act + await Page.GotoAsync(PageUrl(urn)); + + // Assert + var schoolClosedCard = Page.GetByTestId("school-closed-custom-card"); + + Assert.Equal(isSchoolClosed, await schoolClosedCard.IsVisibleAsync()); + + if (isSchoolClosed) + { + var schoolClosedPara = await schoolClosedCard.Locator("p").Filter(new() { HasText = "School closed" }).TextContentAsync(); + + var expectedText = date != null ? $"This school closed on {date}" : "Closed"; + + Assert.NotNull(schoolClosedPara); + Assert.Contains(expectedText, schoolClosedPara.Trim()); + } + } + + [Theory] + [InlineData("105574", null)] + [InlineData("137552", "THE PASSMORES CO-OPERATIVE LEARNING COMMUNITY")] + public async Task AboutSchoolPage_DisplaysTrustNameRow_WhenTrustNameNotNull(string urn, string? trustName) + { + // Act + await Page.GotoAsync(PageUrl(urn)); + + // Assert + var detailsSummary = Page.Locator("#school-details-summary"); + + Assert.True(await detailsSummary.IsVisibleAsync()); + var row = detailsSummary + .Locator(".govuk-summary-list__row") + .Filter(new() { Has = Page.Locator(".govuk-summary-list__key", new() { HasText = " Academy Trust " }) }); + + if (trustName != null) + { + var value = await row.Locator(".govuk-summary-list__value").TextContentAsync(); + Assert.NotNull(value); + Assert.Equal(trustName, value.Trim()); + } + else + { + Assert.False(await row.IsVisibleAsync()); + } + } +} \ No newline at end of file diff --git a/Tests/SAPPub.Integration.Tests/SecondarySchoolTests/AttainmentPageTests.cs b/Tests/SAPPub.Integration.Tests/SecondarySchoolTests/AttainmentPageTests.cs new file mode 100644 index 000000000..a7b3d2ceb --- /dev/null +++ b/Tests/SAPPub.Integration.Tests/SecondarySchoolTests/AttainmentPageTests.cs @@ -0,0 +1,128 @@ +using Microsoft.Playwright; +using SAPPub.Integration.Tests; +using System.Text.RegularExpressions; + +namespace SAPPub.IntegrationTests.SecondarySchoolTests; + +[Collection("Integration Tests")] +public class AttainmentPageTests() : BasePageTest() +{ + private string PageUrl(string urn) => $"/school/{urn}"; + + [Theory] + [InlineData("136745", 39.8, 44.1, 46.1)] + [InlineData("137638", 44.2, 44.1, 46.1)] + [InlineData("142894", 39.2, 44.1, 46.1)] + [InlineData("144496", 49.6, 44.1, 46.1)] + [InlineData("144991", 45.6, 44.1, 46.1)] + public async Task SecondaryAcademicPerformanceProgressAndAttainment_Current_ShowsExpectedAttainmentData(string urn, double expectedAttainmentSchool, double expectedAttainmentLA, double expectedAttainmentEngland) + { + // Arrange && Act + var _ = await Page.GotoAsync(PageUrl(urn)); + var response = await ClickAcademicPerformanceLinkAsync(); + + // Assert + var schoolAttainment8 = await GetScoreAsync("attainment8-establishment-card", "The attainment 8 score for this school is"); + Assert.NotNull(schoolAttainment8); + Assert.Equal(expectedAttainmentSchool.ToString("F1"), schoolAttainment8.Last()); + + var laAttainment8 = await GetScoreAsync("attainment8-localauthority-and-national-card", "the local council average of"); + Assert.NotNull(laAttainment8); + Assert.Equal(expectedAttainmentLA.ToString("F1"), laAttainment8.Last()); + + var englandAttainment8 = await GetScoreAsync("attainment8-localauthority-and-national-card", "the national average of"); + Assert.NotNull(englandAttainment8); + Assert.Equal(expectedAttainmentEngland.ToString("F1"), englandAttainment8.Last()); + } + + [Theory] + [InlineData("100054", 65.4, 47.9, 45.9)] + [InlineData("142894", 36.1, 44.2, 45.9)] + [InlineData("114308", 48.1, 44.2, 45.9)] + [InlineData("137228", 44.6, 45.4, 45.9)] + [InlineData("143362", 43.4, 43.2, 45.9)] + public async Task SecondaryAcademicPerformanceProgressAndAttainment_Previous_ShowsExpectedAttainmentData(string urn, double expectedAttainmentSchool, double expectedAttainmentLA, double expectedAttainmentEngland) + { + // Arrange && Act + var _ = await Page.GotoAsync(PageUrl(urn)); + var response = await ClickAcademicPerformanceLinkAsync(); + _ = await GotoAcademicPerformanceLink(response!.Url, "previous"); + + // Assert + var schoolAttainment8 = await GetScoreAsync("attainment8-establishment-card", "The attainment 8 score for this school is"); + Assert.NotNull(schoolAttainment8); + Assert.Equal(expectedAttainmentSchool.ToString("F1"), schoolAttainment8.Last()); + + var laAttainment8 = await GetScoreAsync("attainment8-localauthority-and-national-card", "the local council average of"); + Assert.NotNull(laAttainment8); + Assert.Equal(expectedAttainmentLA.ToString("F1"), laAttainment8.Last()); + + var englandAttainment8 = await GetScoreAsync("attainment8-localauthority-and-national-card", "the national average of"); + Assert.NotNull(englandAttainment8); + Assert.Equal(expectedAttainmentEngland.ToString("F1"), englandAttainment8.Last()); + } + + + [Theory] + [InlineData("142894", -0.99, -1.27, -0.71, -0.14)] + public async Task SecondaryAcademicPerformanceProgressAndAttainment_ShowsExpectedProgressData( + string urn, + double expectedProgressSchool, + double expectedBandingLower, + double expectedBandingHigher, + double expectedProgressLA) + { + // Arrange && Act + var _ = await Page.GotoAsync($"school/{urn}"); + var response = await ClickAcademicPerformanceLinkAsync(); + _ = await GotoAcademicPerformanceLink(response!.Url, "previous"); + + // Assert + var schoolProgress8 = await GetScoreAsync("progress8-establishment-card", "Pupils at this school score"); + Assert.NotNull(schoolProgress8); + Assert.Equal(expectedProgressSchool.ToString("F2"), schoolProgress8.Last()); + + var progress8Banding = await GetScoreAsync("progress8-establishment-card", "The confidence interval is"); + Assert.NotNull(progress8Banding); + Assert.Equal(expectedBandingLower.ToString("F2"), progress8Banding.First()); + Assert.Equal(expectedBandingHigher.ToString("F2"), progress8Banding.Last()); + + // TODO - pupils in this measure + + var laProgress8 = await GetScoreAsync("progress8-localauthority-card", "The local authority average is"); + Assert.NotNull(laProgress8); + Assert.Equal(expectedProgressLA.ToString("F2"), laProgress8.Last()); + } + + private Task ClickAcademicPerformanceLinkAsync() + { + var response = Page.RunAndWaitForResponseAsync( + async () => + { + await Page.GetByRole(AriaRole.Link, new() { Name = "Secondary academic performance" }).ClickAsync(); + }, + response => response.Url.Contains("/secondary-performance/progress-attainment/current") && response.Status == 200 + ); + return response; + } + + private Task GotoAcademicPerformanceLink(string urlstring, string year = "current") + { + const string marker = "school/"; + var i = urlstring.IndexOf(marker); + var j = urlstring.LastIndexOf('/'); + var previousYearPerformanceUrl = urlstring.Substring(i, j - i); + return Page.GotoAsync($"{previousYearPerformanceUrl}/{year}"); + } + + private async Task?> GetScoreAsync(string dataTestid, string textString) + { + var card = Page.Locator($"[data-testid='{dataTestid}']"); + var p = card.Locator("p.govuk-body", new() { HasTextString = textString }); + var input = await p.InnerTextAsync(); + var match = Regex.Matches(input, @"[+-]?\d+(?:\.\d+)?") + .Cast(); + + return match.Select(m => m.Value); + } +} diff --git a/playwright.runsettings b/playwright.runsettings index 5bc2167c1..70379d5d7 100644 --- a/playwright.runsettings +++ b/playwright.runsettings @@ -2,7 +2,8 @@ - 0 + 1 + http://localhost:5013