From 06265477b0d124923b0f4603ba965f1519c7107b Mon Sep 17 00:00:00 2001 From: Lilach Date: Wed, 23 Sep 2026 14:48:43 +0100 Subject: [PATCH 1/7] Task/fct2 22065 revise cicd for new branching strategy (#244) * tracking of events using journey id (#234) * tracking of events using journey id * pr comments * Story/FCT2-22040 - Added a TelemetryLogger Service for ui (#233) * added a TelemetryLogger Service for ui * clean up and refactor and added unit tests * updated the test * Task/FCT2-19358 integrate e2e test suite into cicd (#237) * telemetry tracking properties (#240) * telemetry tracking properties * pr comment fixes * Story/FCT2-22041 : implemented ui tracking page views and few custom events (#239) * implemented ui tracking page views and few custom events * added journeyId and areaOrDivisionText for caseregistration request * fixed the pull request comments * minor refactor * update pipelines with development branch triggers * add src/tests directory to backend PR pipeline trigeer paths * update parameter empty string value option * story/FCT2-22092- Unauthorised page (#241) * handled 401 error on initial page load * added unit tests * fixed the pr comments * minor refactor * test(e2e): assert the Repealed tag on offence search results (#245) The tag itself shipped in #229; this covers it against the real /api/v1/offences response, where previously only the MSW integration suite asserted it. Expectations come from the search response rather than hardcoded codes: the offences are split into repealed (an "effective to" date is present) and active, and the rendered table is checked against that split, so the test holds as the reference data changes. It fails loudly if a search returns only one kind, rather than passing vacuously. Covers all three acceptance criteria - a tag for every offence with an "effective to" date, no tag for any without one, and the tag sitting in the Description column beneath the description text, carrying the GOV.UK warning styling that distinguishes it. Each was falsified before being kept: treating active offences as repealed fails the tag count (expected 19, received 1), looking for the tag in the Statute column fails on visibility, and expecting the description without the tag appended fails the cell text. * fix stage dependency --------- Co-authored-by: Rhys Bridges Co-authored-by: Renjith Abby Co-authored-by: kmshrajcps --- .gitignore | 2 +- .../backend/backend-build-and-deploy.yml | 84 +++--- .../backend/backend-dev-build-and-deploy.yml | 52 ++++ .../backend/backend-pr-build-and-test.yml | 17 +- .../templates/backend-deploy-stages.yml | 142 ++++------ .../templates/dotnet-build-steps.yml | 131 ++++++--- .../templates/e2e-playwright-test-job.yml | 62 ++++ .../templates/ui-build-and-deploy-stages.yml | 268 ++++++++---------- devops-pipelines/ui/ui-build-and-deploy.yml | 48 ++-- .../ui/ui-dev-build-and-deploy.yml | 14 + devops-pipelines/ui/ui-pr-build-and-test.yml | 1 + .../Constants/CaseRegistrationDefaults.cs | 5 + .../Functions/RegisterCase.cs | 8 +- .../TelemetryEvents/CaseRegisteredEvent.cs | 8 +- .../CaseRegistrationRequestValidator.cs | 2 + .../Telemetry/AppInsightsTelemetryClient.cs | 24 +- .../Telemetry/BaseUiTelemetryEvent.cs | 13 +- .../Telemetry/IAppInsightsTelemetryClient.cs | 4 +- .../Telemetry/TelemetryClient.cs | 12 +- .../Factories/MdsRequestFactory.cs | 37 ++- .../Entities/CaseRegistrationRequest.cs | 6 + .../Unit/Functions/RegisterCaseTests.cs | 39 ++- .../CaseRegisteredEventTests.cs | 24 +- .../CaseRegistrationRequestTests.cs | 36 +++ .../AppInsightsTelemetryClientTests.cs | 87 ++++++ .../Telemetry/BaseUiTelemetryEventTests.cs | 59 ++++ .../Unit/Telemetry/TelemetryClientTests.cs | 140 ++++++++- .../Factories/MdsRequestFactoryTests.cs | 63 ++++ src/ui-spa/e2e-tests/config.ts | 6 +- .../offence-results-repealed-tag.spec.ts | 26 ++ .../pages/chargesOffenceSearchPage.ts | 58 +++- src/ui-spa/e2e-tests/playwright.e2e.config.ts | 6 +- .../unauthorised-page.spec.ts | 68 +++++ src/ui-spa/src/TelemetryLogger.test.ts | 96 +++++++ src/ui-spa/src/TelemetryLogger.ts | 76 +++++ src/ui-spa/src/apis/gateway-api.test.ts | 77 ++++- src/ui-spa/src/apis/gateway-api.ts | 26 ++ src/ui-spa/src/common/constants/pageTitles.ts | 38 +++ .../src/common/hooks/useAuthedQuery.test.tsx | 105 +++++++ .../src/common/hooks/useAuthedQuery.tsx | 29 ++ .../src/common/hooks/usePageView.test.tsx | 109 +++++++ src/ui-spa/src/common/hooks/usePageView.tsx | 36 +++ .../common/hooks/useRouteDocumentTitle.tsx | 42 +-- .../reducers/caseRegistrationReducer.test.ts | 22 ++ .../reducers/caseRegistrationReducer.ts | 23 ++ .../getCaseRegistartionRequestData.test.ts | 12 + .../utils/getCaseRegistrationRequestData.ts | 3 + src/ui-spa/src/components/AppRoutes.tsx | 4 + .../index.tsx | 16 +- .../case-summary-page/index.tsx | 1 + .../case-registration/index.module.scss | 10 + .../components/case-registration/index.tsx | 66 +++-- .../src/components/common/Spinner.module.scss | 17 ++ src/ui-spa/src/components/common/Spinner.tsx | 13 + .../src/components/unauthorised/index.tsx | 16 ++ src/ui-spa/src/mocks/handlers.ts | 4 + .../src/schemas/requests/caseRegistration.ts | 2 + 57 files changed, 1943 insertions(+), 452 deletions(-) create mode 100644 devops-pipelines/backend/backend-dev-build-and-deploy.yml create mode 100644 devops-pipelines/templates/e2e-playwright-test-job.yml create mode 100644 devops-pipelines/ui/ui-dev-build-and-deploy.yml create mode 100644 src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/AppInsightsTelemetryClientTests.cs create mode 100644 src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/BaseUiTelemetryEventTests.cs create mode 100644 src/tests/Cps.CaseManagement.MdsClient.Tests/Factories/MdsRequestFactoryTests.cs create mode 100644 src/ui-spa/e2e-tests/offence-results-repealed-tag.spec.ts create mode 100644 src/ui-spa/integration-tests/unauthorised-page.spec.ts create mode 100644 src/ui-spa/src/TelemetryLogger.test.ts create mode 100644 src/ui-spa/src/TelemetryLogger.ts create mode 100644 src/ui-spa/src/common/constants/pageTitles.ts create mode 100644 src/ui-spa/src/common/hooks/useAuthedQuery.test.tsx create mode 100644 src/ui-spa/src/common/hooks/useAuthedQuery.tsx create mode 100644 src/ui-spa/src/common/hooks/usePageView.test.tsx create mode 100644 src/ui-spa/src/common/hooks/usePageView.tsx create mode 100644 src/ui-spa/src/components/common/Spinner.module.scss create mode 100644 src/ui-spa/src/components/common/Spinner.tsx create mode 100644 src/ui-spa/src/components/unauthorised/index.tsx diff --git a/.gitignore b/.gitignore index 22d16f4ae..ead9f57f5 100644 --- a/.gitignore +++ b/.gitignore @@ -327,4 +327,4 @@ coverage-reports/ unit-test-results.xml # newman -newman \ No newline at end of file +newman diff --git a/devops-pipelines/backend/backend-build-and-deploy.yml b/devops-pipelines/backend/backend-build-and-deploy.yml index 69187f607..aa42afd1e 100644 --- a/devops-pipelines/backend/backend-build-and-deploy.yml +++ b/devops-pipelines/backend/backend-build-and-deploy.yml @@ -10,71 +10,63 @@ trigger: pr: none variables: -- group: 'cmrc-deployment-global' -- name: 'buildConfiguration' - value: 'Release' -- name: 'dotNetVersion' - value: '10.x' -- name: 'buildArtifact' - value: 'cmrc-main-api-drop' + - group: "cmrc-deployment-global" + - name: "buildConfiguration" + value: "Release" + - name: "dotNetVersion" + value: "10.x" + - name: "buildArtifact" + value: "cmrc-main-api-drop" stages: - stage: Build - displayName: 'Build Main API' - pool: 'CMRC PreProd Pool' + displayName: "Build Main API" + pool: "CMRC PreProd Pool" jobs: - job: Build_Main_API - displayName: 'Build Main API' + displayName: "Build Main API" steps: - template: ../templates/dotnet-build-steps.yml parameters: - projectPath: 'src/backend/Cps.CaseManagement.Api/Cps.CaseManagement.Api.csproj' + projectPath: "src/backend/Cps.CaseManagement.Api/Cps.CaseManagement.Api.csproj" projectName: $(buildArtifact) - buildConfiguration: $(buildConfiguration) dotNetVersion: $(dotNetVersion) - useLocalNuGet: true publishOutput: true runTests: false - + releaseVersionArg: "/p:Version=$(releaseVersion)" + releaseSourceVersionArg: "/p:SourceRevisionId=$(Build.SourceVersion)" + - template: ../templates/backend-deploy-stages.yml parameters: - buildStageName: 'Build' - environment: 'dev' - requireValidation: false + dependsOn: "Build" + environment: "staging" + deployToSlot: true + slotName: "stg" buildArtifact: $(buildArtifact) - - stage: Run_Postman_Tests_dev - displayName: 'Dev - Run Postman Tests' - dependsOn: 'Deploy_Backend_dev' # final stage of /templates/backend-deploy-stages.yml - pool: 'CMRC PreProd Pool' + - stage: Release_Gate + displayName: "Release Gate" jobs: - - template: ../templates/postman-test-job.yml - parameters: - appName: 'fa-cmrc-api-dev' - environment: 'dev' - postmanCollectionPath: '$(System.DefaultWorkingDirectory)/src/backend/postman/RegisterCaseAPITests.postman_collection.json' - nodeVersion: '24.x' + - job: Manual_Validation + pool: server + timeoutInMinutes: 6500 + steps: + - task: ManualValidation@1 + displayName: Request Validation + timeoutInMinutes: 6480 + inputs: + notifyUsers: $(prodNotifyUsers) + approvers: $(prodApprovers) + allowApproversToApproveTheirOwnRuns: true + instructions: "Approval is required to deploy to production." + onTimeout: "reject" - template: ../templates/backend-deploy-stages.yml parameters: - buildStageName: 'Build' - environment: 'staging' - requireValidation: true - notifyUsers: $(preprodNotifyUsers) - approvers: $(preprodApprovers) + dependsOn: "Release_Gate" + environment: "prod" + agentPool: "CMRC Prod Pool" + azureSubscription: "Azure Pipeline: CMRC - Prod" deployToSlot: true - slotName: 'stg' + slotName: "stg" buildArtifact: $(buildArtifact) - - - template: ../templates/backend-deploy-stages.yml - parameters: - buildStageName: 'Build' - environment: 'prod' - requireValidation: true - notifyUsers: $(prodNotifyUsers) - approvers: $(prodApprovers) - agentPool: 'CMRC Prod Pool' - azureSubscription: 'Azure Pipeline: CMRC - Prod' - deployToSlot: true - slotName: 'stg' - buildArtifact: $(buildArtifact) \ No newline at end of file diff --git a/devops-pipelines/backend/backend-dev-build-and-deploy.yml b/devops-pipelines/backend/backend-dev-build-and-deploy.yml new file mode 100644 index 000000000..1c47e39f9 --- /dev/null +++ b/devops-pipelines/backend/backend-dev-build-and-deploy.yml @@ -0,0 +1,52 @@ +trigger: + batch: true + branches: + include: + - development + paths: + include: + - src/backend/* + +pr: none + +variables: + - name: "buildConfiguration" + value: "Release" + - name: "dotNetVersion" + value: "10.x" + - name: "buildArtifact" + value: "cmrc-main-api-drop" + +stages: + - stage: Build + displayName: "Build Main API" + pool: "CMRC PreProd Pool" + jobs: + - job: Build_Main_API + displayName: "Build Main API" + steps: + - template: ../templates/dotnet-build-steps.yml + parameters: + projectPath: "src/backend/Cps.CaseManagement.Api/Cps.CaseManagement.Api.csproj" + projectName: $(buildArtifact) + dotNetVersion: $(dotNetVersion) + publishOutput: true + runTests: false + + - template: ../templates/backend-deploy-stages.yml + parameters: + dependsOn: "Build" + environment: "dev" + buildArtifact: $(buildArtifact) + + - stage: Run_Postman_Tests_dev + displayName: "Dev - Run Postman Tests" + dependsOn: "Deploy_Backend_dev" # final stage of /templates/backend-deploy-stages.yml + pool: "CMRC PreProd Pool" + jobs: + - template: ../templates/postman-test-job.yml + parameters: + appName: "fa-cmrc-api-dev" + environment: "dev" + postmanCollectionPath: "$(System.DefaultWorkingDirectory)/src/backend/postman/RegisterCaseAPITests.postman_collection.json" + nodeVersion: "24.x" diff --git a/devops-pipelines/backend/backend-pr-build-and-test.yml b/devops-pipelines/backend/backend-pr-build-and-test.yml index a49d5313c..615d379f8 100644 --- a/devops-pipelines/backend/backend-pr-build-and-test.yml +++ b/devops-pipelines/backend/backend-pr-build-and-test.yml @@ -4,9 +4,11 @@ pr: branches: include: - main + - development paths: include: - src/backend/* + - src/tests/* variables: buildConfiguration: 'Release' @@ -26,23 +28,8 @@ stages: parameters: projectPath: 'src/backend/Cps.CaseManagement.sln' projectName: 'Backend Solution' - buildConfiguration: '$(buildConfiguration)' dotNetVersion: $(dotNetVersion) publishOutput: false runTests: true checkFormatting: true testResultsDirectory: $(testResultsDirectory) - - - job: Build_Main_API - displayName: 'Build Main API' - dependsOn: Build_And_Test_Solution - steps: - # Build and publish Main API - - template: ../templates/dotnet-build-steps.yml - parameters: - projectPath: 'src/backend/Cps.CaseManagement.Api/Cps.CaseManagement.Api.csproj' - projectName: 'cmrc-main-api-drop' - buildConfiguration: $(buildConfiguration) - dotNetVersion: $(dotNetVersion) - publishOutput: false - runTests: false diff --git a/devops-pipelines/templates/backend-deploy-stages.yml b/devops-pipelines/templates/backend-deploy-stages.yml index 033eb3bf1..a076a71ea 100644 --- a/devops-pipelines/templates/backend-deploy-stages.yml +++ b/devops-pipelines/templates/backend-deploy-stages.yml @@ -1,107 +1,87 @@ parameters: - name: environment - displayName: 'Target Environment' + displayName: "Target Environment" type: string - default: 'dev' + default: "dev" values: - dev - staging - prod - name: agentPool type: string - default: 'CMRC PreProd Pool' + default: "CMRC PreProd Pool" values: - - 'CMRC PreProd Pool' - - 'CMRC Prod Pool' - - name: requireValidation - type: boolean - default: false - - name: notifyUsers - type: string - default: '' - - name: approvers - type: string - default: '' + - "CMRC PreProd Pool" + - "CMRC Prod Pool" - name: azureSubscription type: string - default: 'Azure Pipeline: CMRC - Pre-Prod' + default: "Azure Pipeline: CMRC - Pre-Prod" + values: + - "Azure Pipeline: CMRC - Pre-Prod" + - "Azure Pipeline: CMRC - Prod" - name: deployToSlot type: boolean default: false - name: slotName type: string - default: '' - - name: buildStageName + default: "" + - name: dependsOn type: string + default: "" - name: buildArtifact type: string stages: -- ${{ if eq(parameters.requireValidation, true) }}: - - stage: Approve_Deployment_${{ parameters.environment }} - displayName: '${{ parameters.environment }} - Approve Deployment' + - stage: Deploy_Backend_${{ parameters.environment }} + displayName: "${{ parameters.environment }} - Deploy Backend" + dependsOn: ${{ parameters.dependsOn }} + variables: + - template: ./variables/backend-deploy-vars.yml + parameters: + environment: ${{ parameters.environment }} + - template: ./variables/fa-config-${{parameters.environment}}.yml + pool: ${{ parameters.agentPool }} jobs: - - template: ./manual-validation-jobs.yml - parameters: - instructions: 'Please verify the deployment to the lower environment worked as expected before approving deployment to ${{parameters.environment}}.' - notifyUsers: ${{ parameters.notifyUsers }} - approvers: ${{ parameters.approvers }} + - deployment: Deploy_Backend + displayName: "${{ parameters.environment }} - Deploy Backend" + environment: "CMRC-Backend-${{ parameters.environment }}" + strategy: + runOnce: + deploy: + steps: + - checkout: self + sparseCheckoutDirectories: devops-pipelines/scripts + fetchDepth: 1 + + - download: "current" + artifact: ${{ parameters.buildArtifact }} + + - task: Bash@3 + displayName: "Ensure Azure CLI" + inputs: + targetType: "inline" + script: | + echo "Checking if Azure CLI is installed..." + + # Check if az command exists + if command -v az &> /dev/null; then + echo "Azure CLI is already installed" + az --version + exit 0 + fi -- stage: Deploy_Backend_${{ parameters.environment }} - displayName: '${{ parameters.environment }} - Deploy Backend' - ${{ if eq(parameters.requireValidation, true) }}: - dependsOn: - - ${{ parameters.buildStageName }} - - Approve_Deployment_${{ parameters.environment }} - ${{ else }}: - dependsOn: ${{ parameters.buildStageName }} - variables: - - template: ./variables/backend-deploy-vars.yml - parameters: - environment: ${{ parameters.environment }} - - template: ./variables/fa-config-${{parameters.environment}}.yml - pool: ${{ parameters.agentPool }} - jobs: - - deployment: Deploy_Backend - displayName: '${{ parameters.environment }} - Deploy Backend' - environment: 'CMRC-Backend-${{ parameters.environment }}' - strategy: - runOnce: - deploy: - steps: - - checkout: self - sparseCheckoutDirectories: devops-pipelines/scripts - fetchDepth: 1 - - - download: 'current' - artifact: ${{ parameters.buildArtifact }} + echo "Azure CLI not found, installing..." + bash '$(System.DefaultWorkingDirectory)/devops-pipelines/scripts/installAzureCli.sh' - - task: Bash@3 - displayName: 'Ensure Azure CLI' - inputs: - targetType: 'inline' - script: | - echo "Checking if Azure CLI is installed..." - - # Check if az command exists - if command -v az &> /dev/null; then - echo "Azure CLI is already installed" - az --version - exit 0 - fi + - template: ./fa-deploy-steps.yml + parameters: + environment: ${{ parameters.environment }} + azureSubscription: ${{ parameters.azureSubscription }} + functionAppName: "fa-cmrc-api-${{ parameters.environment }}" + buildArtifact: ${{ parameters.buildArtifact }} + deployToSlot: ${{ parameters.deployToSlot }} + slotName: ${{ parameters.slotName }} - echo "Azure CLI not found, installing..." - bash '$(System.DefaultWorkingDirectory)/devops-pipelines/scripts/installAzureCli.sh' - - - template: ./fa-deploy-steps.yml - parameters: - environment: ${{ parameters.environment }} - azureSubscription: ${{ parameters.azureSubscription }} - functionAppName: 'fa-cmrc-api-${{ parameters.environment }}' - buildArtifact: ${{ parameters.buildArtifact }} - deployToSlot: ${{ parameters.deployToSlot }} - slotName: ${{ parameters.slotName }} - - - template: ./runner-cleanup-steps.yml - parameters: - buildArtifact: ${{ parameters.buildArtifact }} \ No newline at end of file + - template: ./runner-cleanup-steps.yml + parameters: + buildArtifact: ${{ parameters.buildArtifact }} diff --git a/devops-pipelines/templates/dotnet-build-steps.yml b/devops-pipelines/templates/dotnet-build-steps.yml index 2c45f1fcb..9be89ef24 100644 --- a/devops-pipelines/templates/dotnet-build-steps.yml +++ b/devops-pipelines/templates/dotnet-build-steps.yml @@ -1,26 +1,51 @@ parameters: - projectPath: '' - projectName: '' - buildConfiguration: 'Release' - dotNetVersion: '10.x' - publishOutput: false - runTests: false - checkFormatting: false - testResultsDirectory: '' + - name: projectPath + type: string + default: "" + - name: projectName + type: string + default: "" + - name: dotNetVersion + type: string + default: "10.x" + - name: publishOutput + type: boolean + default: false + - name: runTests + type: boolean + default: false + - name: checkFormatting + type: boolean + default: false + - name: testResultsDirectory + type: string + default: "" + - name: releaseVersionArg + type: string + default: " " + values: + - " " + - "/p:Version=$(releaseVersion)" + - name: releaseSourceVersionArg + type: string + default: " " + values: + - " " + - "/p:SourceRevisionId=$(Build.SourceVersion)" steps: - task: UseDotNet@2 - displayName: 'Use .NET SDK' + displayName: "Use .NET SDK" inputs: - packageType: 'sdk' + packageType: "sdk" version: ${{ parameters.dotNetVersion }} # Restore packages - task: DotNetCoreCLI@2 - displayName: 'Restore ${{ parameters.projectName }}' + displayName: "Restore ${{ parameters.projectName }}" inputs: - command: 'restore' - projects: '${{ parameters.projectPath }}' + command: "restore" + projects: "${{ parameters.projectPath }}" # Check formatting - ${{ if eq(parameters.checkFormatting, true) }}: @@ -49,44 +74,58 @@ steps: # Build project - task: DotNetCoreCLI@2 - displayName: 'Build ${{ parameters.projectName }}' + displayName: "Build ${{ parameters.projectName }}" inputs: - command: 'build' - projects: '${{ parameters.projectPath }}' - arguments: '--configuration ${{ parameters.buildConfiguration }} --no-restore' + command: "build" + projects: "${{ parameters.projectPath }}" + arguments: > + --configuration $(buildConfiguration) + --no-restore + ${{ parameters.releaseSourceVersionArg }} + ${{ parameters.releaseVersionArg }} # Run tests if enabled - ${{ if eq(parameters.runTests, true) }}: - - task: DotNetCoreCLI@2 - displayName: 'Run Tests' - inputs: - command: 'test' - projects: '${{ parameters.projectPath }}' - arguments: '--configuration ${{ parameters.buildConfiguration }} --no-build --collect:"XPlat Code Coverage;Format=cobertura" --settings src/tests/CodeCoverage.runsettings' - publishTestResults: true - testResultsFormat: 'VSTest' - testResultsFiles: '**/*.trx' - testRunTitle: '${{ parameters.projectName }} Tests' - workingDirectory: '$(Build.SourcesDirectory)' - resultsDirectory: '${{ parameters.testResultsDirectory }}' + - task: DotNetCoreCLI@2 + displayName: "Run Tests" + inputs: + command: "test" + projects: "${{ parameters.projectPath }}" + arguments: > + --configuration $(buildConfiguration) + --no-build + --collect "XPlat Code Coverage;Format=cobertura" + --settings "src/tests/CodeCoverage.runsettings" + publishTestResults: true + testResultsFormat: "VSTest" + testResultsFiles: "**/*.trx" + testRunTitle: "${{ parameters.projectName }} Tests" + workingDirectory: "$(Build.SourcesDirectory)" + resultsDirectory: "${{ parameters.testResultsDirectory }}" - - task: PublishCodeCoverageResults@2 - displayName: 'Publish Code Coverage' - inputs: - summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml' - failIfCoverageEmpty: true - condition: succeededOrFailed() + - task: PublishCodeCoverageResults@2 + displayName: "Publish Code Coverage" + inputs: + summaryFileLocation: "$(Agent.TempDirectory)/**/coverage.cobertura.xml" + failIfCoverageEmpty: true + condition: succeededOrFailed() - # Publish if enabled + # Publish if enabled, no build or restore as these are already done in previous steps - ${{ if eq(parameters.publishOutput, true) }}: - - task: DotNetCoreCLI@2 - displayName: 'Publish ${{ parameters.projectName }}' - inputs: - command: 'publish' - projects: '${{ parameters.projectPath }}' - publishWebProjects: false - arguments: '--configuration ${{ parameters.buildConfiguration }} --output $(Build.ArtifactStagingDirectory)/${{ parameters.projectName }} --no-restore --self-contained false /p:PublishReadyToRun=false /p:SourceRevisionId=$(Build.SourceVersion)' + - task: DotNetCoreCLI@2 + displayName: "Publish ${{ parameters.projectName }}" + inputs: + command: "publish" + projects: "${{ parameters.projectPath }}" + publishWebProjects: false + arguments: > + --configuration $(buildConfiguration) + --output $(Build.ArtifactStagingDirectory)/${{ parameters.projectName }} + --no-restore + --no-build + --self-contained false + /p:PublishReadyToRun=false - - publish: '$(Build.ArtifactStagingDirectory)/${{ parameters.projectName }}' - artifact: '${{ parameters.projectName }}' - displayName: 'Publish ${{ parameters.projectName }} Artifact' + - publish: "$(Build.ArtifactStagingDirectory)/${{ parameters.projectName }}" + artifact: "${{ parameters.projectName }}" + displayName: "Publish ${{ parameters.projectName }} Artifact" diff --git a/devops-pipelines/templates/e2e-playwright-test-job.yml b/devops-pipelines/templates/e2e-playwright-test-job.yml new file mode 100644 index 000000000..522c71bbd --- /dev/null +++ b/devops-pipelines/templates/e2e-playwright-test-job.yml @@ -0,0 +1,62 @@ +parameters: + - name: environment + type: string + values: + - dev + - staging + - name: agentPool + type: string + default: "CMRC PreProd Pool" + +jobs: + - job: runE2ETests + displayName: "Run E2E Tests with Playwright" + pool: ${{ parameters.agentPool }} + variables: + - group: cmrc-automated-testing-${{ parameters.environment }} + - group: cmrc-backend-config-${{ parameters.environment }} + - group: cmrc-ui-config-${{ parameters.environment }} + - name: nodeVersion + value: "24.x" + - name: workingDir + value: "$(System.DefaultWorkingDirectory)/src/ui-spa" + + steps: + - task: UseNode@1 + displayName: "Use Node.js" + inputs: + version: "$(nodeVersion)" + + - task: Npm@1 + displayName: "Install NPM Dependencies" + inputs: + command: "ci" + workingDir: "$(workingDir)" + + - script: node_modules/.bin/playwright install --with-deps chromium + displayName: "Install Browsers" + workingDirectory: $(workingDir) + + - script: npm run e2e:test:ci + workingDirectory: $(workingDir) + displayName: "Run E2E Tests" + env: + E2E_FRONTEND_URL: $(RedirectUrlRegisterCaseUi) + E2E_API_BASE_URL: $(VITE_GATEWAY_BASE_URL) + E2E_AAD_USERNAME: $(AadUserName) + E2E_AAD_PASSWORD: $(AadUserPassword) + E2E_CMS_USERNAME: $(CmsUserName) + E2E_CMS_PASSWORD: $(CmsUserPassword) + + - task: PublishTestResults@2 + displayName: "Publish Test Results (JUnit)" + inputs: + testResultsFormat: JUnit + testResultsFiles: "$(workingDir)/e2e-tests/e2e-test-results.xml" + publishRunAttachments: false + condition: succeededOrFailed() + + - publish: "$(workingDir)/e2e-tests/playwright-report" + artifact: "$(System.JobId)-e2e-playwright-report" + displayName: "Publish Report Artifact" + condition: succeededOrFailed() diff --git a/devops-pipelines/templates/ui-build-and-deploy-stages.yml b/devops-pipelines/templates/ui-build-and-deploy-stages.yml index 4aafef34c..548717183 100644 --- a/devops-pipelines/templates/ui-build-and-deploy-stages.yml +++ b/devops-pipelines/templates/ui-build-and-deploy-stages.yml @@ -1,173 +1,157 @@ parameters: - name: environment - displayName: 'Target Environment' + displayName: "Target Environment" type: string - default: 'dev' + default: "dev" values: - dev - staging - prod - name: agentPool type: string - default: 'CMRC PreProd Pool' + default: "CMRC PreProd Pool" values: - - 'CMRC PreProd Pool' - - 'CMRC Prod Pool' + - "CMRC PreProd Pool" + - "CMRC Prod Pool" - name: azureSubscription type: string - default: 'Azure Pipeline: CMRC - Pre-Prod' - - name: requireValidation - type: boolean - default: false - - name: notifyUsers - type: string - default: '' - - name: approvers - type: string - default: '' + default: "Azure Pipeline: CMRC - Pre-Prod" + values: + - "Azure Pipeline: CMRC - Pre-Prod" + - "Azure Pipeline: CMRC - Prod" - name: deployToSlot type: boolean default: false - name: slotName type: string - default: '' + default: "" stages: -- ${{ if eq(parameters.requireValidation, true) }}: - - stage: Approve_Deployment_${{ parameters.environment }} - displayName: 'CMRC ${{ parameters.environment }} - Approve Deployment' + - stage: Build_Deploy_${{ parameters.environment }} + displayName: "${{ parameters.environment }} - Build & Deploy Web App" + variables: + buildArtifact: "cmrc-ui-${{ parameters.environment }}-drop" jobs: - - template: ./manual-validation-jobs.yml - parameters: - instructions: 'Please verify the deployment to the lower environment worked as expected before approving deployment to ${{parameters.environment}}.' - notifyUsers: ${{ parameters.notifyUsers }} - approvers: ${{ parameters.approvers }} + - job: Build_and_Publish_Artifact + displayName: "Build UI SPA for the ${{ parameters.environment}} environment" + pool: ${{ parameters.agentPool }} + variables: + - group: cmrc-ui-config-${{ parameters.environment }} + - name: nodeVersion + value: "24.x" + - name: workingDir + value: "src/ui-spa" + steps: + - checkout: self + sparseCheckoutDirectories: "$(workingDir)" + + - task: UseNode@1 + displayName: "Use Node.js" + inputs: + version: "$(nodeVersion)" -- stage: Build_Deploy_${{ parameters.environment }} - displayName: '${{ parameters.environment }} - Build & Deploy Web App' - variables: - buildArtifact: 'cmrc-ui-${{ parameters.environment }}-drop' - jobs: - - job: Build_and_Publish_Artifact - displayName: 'Build UI SPA for the ${{ parameters.environment}} environment' - pool: ${{ parameters.agentPool }} - variables: - - group: cmrc-ui-config-${{ parameters.environment }} - - name: nodeVersion - value: '24.x' - - name: workingDir - value: 'src/ui-spa' - steps: - - checkout: self - sparseCheckoutDirectories: '$(workingDir)' + - task: Npm@1 + displayName: "Install NPM Dependencies" + inputs: + command: "ci" + workingDir: "$(workingDir)" - - task: UseNode@1 - displayName: 'Use Node.js' - inputs: - version: '$(nodeVersion)' + - task: Npm@1 + displayName: "Build Web App Package" + inputs: + command: "custom" + customCommand: "run build" + workingDir: "$(workingDir)" + env: + VITE_GATEWAY_BASE_URL: $(VITE_GATEWAY_BASE_URL) + VITE_GATEWAY_SCOPE: $(VITE_GATEWAY_SCOPE) + VITE_CLIENT_ID: $(VITE_CLIENT_ID) + VITE_TENANT_ID: $(VITE_TENANT_ID) + VITE_PRIVATE_BETA_USER_GROUP: $(VITE_PRIVATE_BETA_USER_GROUP) + VITE_PRIVATE_BETA_CONTACT_EMAIL: $(VITE_PRIVATE_BETA_CONTACT_EMAIL) + VITE_HOME_PAGE_URL: $(VITE_HOME_PAGE_URL) - - task: Npm@1 - displayName: 'Install NPM Dependencies' - inputs: - command: 'ci' - workingDir: '$(workingDir)' + - task: PublishPipelineArtifact@1 + displayName: "Publish Web App Package" + inputs: + targetPath: "$(workingDir)/dist" + artifact: "$(buildArtifact)" - - task: Npm@1 - displayName: 'Build Web App Package' - inputs: - command: 'custom' - customCommand: 'run build' - workingDir: '$(workingDir)' - env: - VITE_GATEWAY_BASE_URL: $(VITE_GATEWAY_BASE_URL) - VITE_GATEWAY_SCOPE: $(VITE_GATEWAY_SCOPE) - VITE_CLIENT_ID: $(VITE_CLIENT_ID) - VITE_TENANT_ID: $(VITE_TENANT_ID) - VITE_PRIVATE_BETA_USER_GROUP: $(VITE_PRIVATE_BETA_USER_GROUP) - VITE_PRIVATE_BETA_CONTACT_EMAIL: $(VITE_PRIVATE_BETA_CONTACT_EMAIL) - VITE_HOME_PAGE_URL: $(VITE_HOME_PAGE_URL) + - deployment: Deploy_UI + displayName: "Deploy UI SPA to the ${{ parameters.environment}} environment" + dependsOn: Build_and_Publish_Artifact + environment: "CMRC-UI-${{ parameters.environment }}" + pool: ${{ parameters.agentPool }} + variables: + - name: resourceGroupName + value: "rg-cmrc-${{ parameters.environment }}" + - name: webAppName + value: "cmrc-app-ui-spa-${{ parameters.environment }}" + strategy: + runOnce: + deploy: + steps: + - task: AzureRmWebAppDeployment@4 + inputs: + ConnectionType: "AzureRM" + azureSubscription: ${{ parameters.azureSubscription }} + resourceGroupName: $(resourceGroupName) + appType: "webAppLinux" + WebAppName: "$(webAppName)" + deployToSlotOrASE: ${{ parameters.deployToSlot }} + slotName: ${{ parameters.slotName }} + packageForLinux: "$(Pipeline.Workspace)/$(buildArtifact)" + RuntimeStack: "NODE|24-lts" + StartupCommand: "pm2 serve /home/site/wwwroot/ --no-daemon --spa" + displayName: "Deploy app to ${{ parameters.environment }}" - - task: PublishPipelineArtifact@1 - displayName: 'Publish Web App Package' - inputs: - targetPath: '$(workingDir)/dist' - artifact: '$(buildArtifact)' + - checkout: self + sparseCheckoutDirectories: devops-pipelines/scripts + fetchDepth: 1 - - deployment: Deploy_UI - displayName: 'Deploy UI SPA to the ${{ parameters.environment}} environment' - dependsOn: Build_and_Publish_Artifact - environment: 'CMRC-UI-${{ parameters.environment }}' - pool: ${{ parameters.agentPool }} - variables: - - name: resourceGroupName - value: 'rg-cmrc-${{ parameters.environment }}' - - name: webAppName - value: 'cmrc-app-ui-spa-${{ parameters.environment }}' - strategy: - runOnce: - deploy: - steps: - - task: AzureRmWebAppDeployment@4 - inputs: - ConnectionType: 'AzureRM' - azureSubscription: ${{ parameters.azureSubscription }} - resourceGroupName: $(resourceGroupName) - appType: 'webAppLinux' - WebAppName: '$(webAppName)' - deployToSlotOrASE: ${{ parameters.deployToSlot }} - slotName: ${{ parameters.slotName }} - packageForLinux: '$(Pipeline.Workspace)/$(buildArtifact)' - RuntimeStack: 'NODE|24-lts' - StartupCommand: 'pm2 serve /home/site/wwwroot/ --no-daemon --spa' - displayName: 'Deploy app to ${{ parameters.environment }}' - - - checkout: self - sparseCheckoutDirectories: devops-pipelines/scripts - fetchDepth: 1 + - task: Bash@3 + displayName: "Ensure Azure CLI" + inputs: + targetType: "inline" + script: | + echo "Checking if Azure CLI is installed..." - - task: Bash@3 - displayName: 'Ensure Azure CLI' - inputs: - targetType: 'inline' - script: | - echo "Checking if Azure CLI is installed..." - - # Check if az command exists - if command -v az &> /dev/null; then - echo "Azure CLI is already installed" - az --version - exit 0 - fi - echo "Azure CLI not found, installing..." - bash '$(System.DefaultWorkingDirectory)/devops-pipelines/scripts/installAzureCli.sh' + # Check if az command exists + if command -v az &> /dev/null; then + echo "Azure CLI is already installed" + az --version + exit 0 + fi + echo "Azure CLI not found, installing..." + bash '$(System.DefaultWorkingDirectory)/devops-pipelines/scripts/installAzureCli.sh' - - task: AzureCLI@2 - displayName: 'Verify App Deployment' - inputs: - azureSubscription: ${{ parameters.azureSubscription }} - scriptType: 'bash' - scriptLocation: 'inlineScript' - inlineScript: | - bash '$(System.DefaultWorkingDirectory)/devops-pipelines/scripts/verifyDeployment.sh' - env: - APP_TYPE: webapp - APP_NAME: $(webAppName) - RG: $(resourceGroupName) - USE_SLOT: ${{ lower(parameters.deployToSlot) }} - SLOT_NAME: ${{ parameters.slotName }} + - task: AzureCLI@2 + displayName: "Verify App Deployment" + inputs: + azureSubscription: ${{ parameters.azureSubscription }} + scriptType: "bash" + scriptLocation: "inlineScript" + inlineScript: | + bash '$(System.DefaultWorkingDirectory)/devops-pipelines/scripts/verifyDeployment.sh' + env: + APP_TYPE: webapp + APP_NAME: $(webAppName) + RG: $(resourceGroupName) + USE_SLOT: ${{ lower(parameters.deployToSlot) }} + SLOT_NAME: ${{ parameters.slotName }} - # ADD step to verify deployment in the staging slot e.g health check, smoke test etc. + # ADD step to verify deployment in the staging slot e.g health check, smoke test etc. - - task: AzureAppServiceManage@0 - condition: and(succeeded(), ${{ eq(parameters.deployToSlot, true) }}) - displayName: "Perform Slot Swap" - inputs: - azureSubscription: ${{ parameters.azureSubscription }} - WebAppName: $(webAppName) - ResourceGroupName: $(resourceGroupName) - SourceSlot: ${{ parameters.slotName }} - SwapWithProduction: true + - task: AzureAppServiceManage@0 + condition: and(succeeded(), ${{ eq(parameters.deployToSlot, true) }}) + displayName: "Perform Slot Swap" + inputs: + azureSubscription: ${{ parameters.azureSubscription }} + WebAppName: $(webAppName) + ResourceGroupName: $(resourceGroupName) + SourceSlot: ${{ parameters.slotName }} + SwapWithProduction: true - - template: ../templates/runner-cleanup-steps.yml - parameters: - buildArtifact: $(buildArtifact) \ No newline at end of file + - template: ../templates/runner-cleanup-steps.yml + parameters: + buildArtifact: $(buildArtifact) diff --git a/devops-pipelines/ui/ui-build-and-deploy.yml b/devops-pipelines/ui/ui-build-and-deploy.yml index 91de3f556..15b87a97c 100644 --- a/devops-pipelines/ui/ui-build-and-deploy.yml +++ b/devops-pipelines/ui/ui-build-and-deploy.yml @@ -14,25 +14,37 @@ variables: stages: - template: ../templates/ui-build-and-deploy-stages.yml parameters: - environment: 'dev' - requireValidation: false - - - template: ../templates/ui-build-and-deploy-stages.yml - parameters: - environment: 'staging' - requireValidation: true - notifyUsers: $(preprodNotifyUsers) - approvers: $(preprodApprovers) + environment: "staging" deployToSlot: true - slotName: 'stg' - + slotName: "stg" + + - stage: Release_Gate + displayName: "Release Gate" + jobs: + # Run playwright E2E tests + - template: ../templates/e2e-playwright-test-job.yml + parameters: + environment: "staging" + + - job: Manual_Validation + pool: server + dependsOn: runE2ETests + timeoutInMinutes: 6500 + steps: + - task: ManualValidation@1 + displayName: Request Validation + timeoutInMinutes: 6480 + inputs: + notifyUsers: $(prodNotifyUsers) + approvers: $(prodApprovers) + allowApproversToApproveTheirOwnRuns: true + instructions: "Approval is required to deploy to production." + onTimeout: "reject" + - template: ../templates/ui-build-and-deploy-stages.yml parameters: - environment: 'prod' - requireValidation: true - notifyUsers: $(prodNotifyUsers) - approvers: $(prodApprovers) - agentPool: 'CMRC Prod Pool' - azureSubscription: 'Azure Pipeline: CMRC - Prod' + environment: "prod" + agentPool: "CMRC Prod Pool" + azureSubscription: "Azure Pipeline: CMRC - Prod" deployToSlot: true - slotName: 'stg' + slotName: "stg" diff --git a/devops-pipelines/ui/ui-dev-build-and-deploy.yml b/devops-pipelines/ui/ui-dev-build-and-deploy.yml new file mode 100644 index 000000000..41876202a --- /dev/null +++ b/devops-pipelines/ui/ui-dev-build-and-deploy.yml @@ -0,0 +1,14 @@ +trigger: + branches: + include: + - development + paths: + include: + - src/ui-spa/* + +pr: none + +stages: + - template: ../templates/ui-build-and-deploy-stages.yml + parameters: + environment: "dev" diff --git a/devops-pipelines/ui/ui-pr-build-and-test.yml b/devops-pipelines/ui/ui-pr-build-and-test.yml index 026d048ef..fc95665fb 100644 --- a/devops-pipelines/ui/ui-pr-build-and-test.yml +++ b/devops-pipelines/ui/ui-pr-build-and-test.yml @@ -4,6 +4,7 @@ pr: branches: include: - main + - development paths: include: - src/ui-spa/* diff --git a/src/backend/Cps.CaseManagement.Api/Constants/CaseRegistrationDefaults.cs b/src/backend/Cps.CaseManagement.Api/Constants/CaseRegistrationDefaults.cs index f5a6e5cd6..1ccd34940 100644 --- a/src/backend/Cps.CaseManagement.Api/Constants/CaseRegistrationDefaults.cs +++ b/src/backend/Cps.CaseManagement.Api/Constants/CaseRegistrationDefaults.cs @@ -49,4 +49,9 @@ public static class CaseRegistrationDefaults public const int MaxAgeYears = 120; public const int HearingDateYearsWindow = 2; + + /// + /// Maximum length for the optional frontend journey identifier used in telemetry. + /// + public const int JourneyIdMaxLength = 100; } diff --git a/src/backend/Cps.CaseManagement.Api/Functions/RegisterCase.cs b/src/backend/Cps.CaseManagement.Api/Functions/RegisterCase.cs index 90313dc94..2831d465c 100644 --- a/src/backend/Cps.CaseManagement.Api/Functions/RegisterCase.cs +++ b/src/backend/Cps.CaseManagement.Api/Functions/RegisterCase.cs @@ -34,7 +34,7 @@ public class RegisterCase(ILogger logger, IMdsService mdsService, [CmsAuthValuesAuth] [BearerTokenAuth] [OpenApiParameter(name: HttpHeaderKeys.CorrelationId, In = Microsoft.OpenApi.Models.ParameterLocation.Header, Required = true, Type = typeof(string), Description = "Correlation identifier for tracking the request.")] - [OpenApiRequestBody(ContentType.ApplicationJson, typeof(CaseRegistrationRequest), Description = "Body containing the NetApp connection to create")] + [OpenApiRequestBody(ContentType.ApplicationJson, typeof(CaseRegistrationRequest), Description = "Body containing the case registration details to create.")] [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: ContentType.ApplicationJson, bodyType: typeof(CaseRegistrationResponseDto), Description = ApiResponseDescriptions.Success)] [OpenApiResponseWithBody(statusCode: HttpStatusCode.BadRequest, contentType: ContentType.TextPlain, typeof(string), Description = ApiResponseDescriptions.BadRequest)] [OpenApiResponseWithBody(statusCode: HttpStatusCode.Unauthorized, contentType: ContentType.TextPlain, typeof(string), Description = ApiResponseDescriptions.Unauthorized)] @@ -76,7 +76,9 @@ public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, EventTimestamp = DateTime.UtcNow, Urn = result.Urn, CaseId = result.CaseId, - Username = context.Username + Username = context.Username, + JourneyId = caseRegistrationRequest.Value.JourneyId, + AreaOrDivisionText = caseRegistrationRequest.Value.AreaOrDivisionText }); return new OkObjectResult(result); @@ -103,4 +105,4 @@ private static async Task> ValidateDefendantsAsync(IEnumerable Properties, IDictionary Metrics) ToTelemetryEventProps() { return ( @@ -17,7 +21,9 @@ public override (IDictionary Properties, IDictionary()); } diff --git a/src/backend/Cps.CaseManagement.Api/Validators/CaseRegistrationRequestValidator.cs b/src/backend/Cps.CaseManagement.Api/Validators/CaseRegistrationRequestValidator.cs index 4a3617361..a44137d60 100644 --- a/src/backend/Cps.CaseManagement.Api/Validators/CaseRegistrationRequestValidator.cs +++ b/src/backend/Cps.CaseManagement.Api/Validators/CaseRegistrationRequestValidator.cs @@ -41,6 +41,8 @@ public CaseRegistrationRequestValidator() .Matches(CaseRegistrationInputPatterns.Alphanumeric) .WithMessage($"OicShoulderNumber {CaseRegistrationInputPatterns.AlphanumericMessage}"); this.RuleFor(x => x.OicPoliceUnit).MaximumLength(CaseRegistrationDefaults.ShortTextMaxLength); + this.RuleFor(x => x.JourneyId).MaximumLength(CaseRegistrationDefaults.JourneyIdMaxLength); + this.RuleFor(x => x.AreaOrDivisionText).MaximumLength(CaseRegistrationDefaults.ShortTextMaxLength); this.RuleFor(x => x.HearingDate) .GreaterThanOrEqualTo(DateTime.Today.AddYears(-CaseRegistrationDefaults.HearingDateYearsWindow)) diff --git a/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/AppInsightsTelemetryClient.cs b/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/AppInsightsTelemetryClient.cs index 7fee3b486..265c48946 100644 --- a/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/AppInsightsTelemetryClient.cs +++ b/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/AppInsightsTelemetryClient.cs @@ -29,13 +29,31 @@ public void TrackMetric(string name, double value, IDictionary? _telemetryClient.TrackMetric(name, value, properties); } - public void TrackPageView(string name) + public void TrackPageView(string name, IDictionary? properties = null, IDictionary? metrics = null) { - _telemetryClient.TrackPageView(name); + var pageViewTelemetry = new PageViewTelemetry(name); + + if (properties != null) + { + foreach (var property in properties) + { + pageViewTelemetry.Properties[property.Key] = property.Value; + } + } + + if (metrics != null) + { + foreach (var metric in metrics) + { + pageViewTelemetry.Metrics[metric.Key] = metric.Value; + } + } + + _telemetryClient.TrackPageView(pageViewTelemetry); } public void TrackTrace(string message, SeverityLevel severityLevel, IDictionary? properties = null) { _telemetryClient.TrackTrace(message, severityLevel, properties); } -} \ No newline at end of file +} diff --git a/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/BaseUiTelemetryEvent.cs b/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/BaseUiTelemetryEvent.cs index e4a07ac5f..0fa6d6d18 100644 --- a/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/BaseUiTelemetryEvent.cs +++ b/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/BaseUiTelemetryEvent.cs @@ -6,8 +6,17 @@ public class BaseUiTelemetryEvent : BaseTelemetryEvent public override (IDictionary Properties, IDictionary Metrics) ToTelemetryEventProps() { - var telemetryProperties = Properties.SelectMany(dict => dict) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + var telemetryProperties = new Dictionary(); + if (Properties != null) + { + foreach (var dict in Properties.Where(d => d != null)) + { + foreach (var kvp in dict) + { + telemetryProperties[kvp.Key] = kvp.Value; + } + } + } if (telemetryProperties == null || telemetryProperties.Count == 0) { diff --git a/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/IAppInsightsTelemetryClient.cs b/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/IAppInsightsTelemetryClient.cs index 0fb1a544c..e193f95b1 100644 --- a/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/IAppInsightsTelemetryClient.cs +++ b/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/IAppInsightsTelemetryClient.cs @@ -9,6 +9,6 @@ public interface IAppInsightsTelemetryClient void TrackEvent(string eventName, IDictionary? properties = null, IDictionary? metrics = null); void TrackException(Exception exception, IDictionary? properties = null, IDictionary? metrics = null); void TrackMetric(string name, double value, IDictionary? properties = null); - void TrackPageView(string name); + void TrackPageView(string name, IDictionary? properties = null, IDictionary? metrics = null); void TrackTrace(string message, SeverityLevel severityLevel, IDictionary? properties = null); -} \ No newline at end of file +} diff --git a/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/TelemetryClient.cs b/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/TelemetryClient.cs index 3bfd9ba40..bafa04567 100644 --- a/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/TelemetryClient.cs +++ b/src/backend/Cps.CaseManagement.Infrastructure/Telemetry/TelemetryClient.cs @@ -67,15 +67,10 @@ public void TrackPageView(BaseTelemetryEvent telemetryEvent) var (properties, metrics) = PrepareTelemetryEventProps(telemetryEvent); - if (!properties.ContainsKey(PageNameKey)) + if (properties == null || !properties.TryGetValue(PageNameKey, out var pageName) || string.IsNullOrWhiteSpace(pageName)) return; - var pageName = properties?[PageNameKey]?.ToString() ?? string.Empty; - - if (string.IsNullOrEmpty(pageName)) - return; - - _telemetryClient.TrackPageView(pageName); + _telemetryClient.TrackPageView(pageName, properties, metrics); } public void TrackTrace(BaseTelemetryEvent telemetryEvent) @@ -123,6 +118,9 @@ private void TrackInternalEvent(BaseTelemetryEvent telemetryEvent, bool isFailur { var (properties, metrics) = telemetryEvent.ToTelemetryEventProps(); + properties ??= new Dictionary(); + metrics ??= new Dictionary(); + var nonNullMetrics = metrics.Where(kvp => kvp.Value.HasValue) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!.Value); diff --git a/src/backend/Cps.CaseManagement.MdsClient/Factories/MdsRequestFactory.cs b/src/backend/Cps.CaseManagement.MdsClient/Factories/MdsRequestFactory.cs index 9a06ab893..8a2a85549 100644 --- a/src/backend/Cps.CaseManagement.MdsClient/Factories/MdsRequestFactory.cs +++ b/src/backend/Cps.CaseManagement.MdsClient/Factories/MdsRequestFactory.cs @@ -2,8 +2,10 @@ namespace Cps.CaseManagement.MdsClient.Factories; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Cps.CaseManagement.MdsClient.Models.Args; using Cps.CaseManagement.MdsClient.Models.Constants; +using Cps.CaseManagement.MdsClient.Models.Entities; using Microsoft.AspNetCore.WebUtilities; public class MdsRequestFactory : IMdsRequestFactory @@ -11,6 +13,8 @@ public class MdsRequestFactory : IMdsRequestFactory private const string CorrelationId = "Correlation-Id"; private const string CmsAuthValues = "Cms-Auth-Values"; + private static readonly JsonSerializerOptions RegisterCaseSerializerOptions = CreateRegisterCaseSerializerOptions(); + public HttpRequestMessage CreateGetTitlesRequest(MdsBaseArgDto arg) => BuildRequest(HttpMethod.Get, "api/titles", arg); @@ -59,7 +63,10 @@ public HttpRequestMessage CreateGetCmsModernTokenRequest(MdsBaseArgDto arg) => public HttpRequestMessage CreateRegisterCaseRequest(MdsRegisterCaseArg arg) { var request = BuildRequest(HttpMethod.Post, "api/cases", arg); - request.Content = new StringContent(JsonSerializer.Serialize(arg.CaseDetails), Encoding.UTF8, "application/json"); + request.Content = new StringContent( + JsonSerializer.Serialize(arg.CaseDetails, RegisterCaseSerializerOptions), + Encoding.UTF8, + "application/json"); return request; } @@ -99,4 +106,32 @@ private HttpRequestMessage BuildRequest(HttpMethod method, string path, MdsBaseA request.Headers.Add(CmsAuthValues, arg.CmsAuthValues); return request; } + + private static JsonSerializerOptions CreateRegisterCaseSerializerOptions() + { + return new JsonSerializerOptions + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = { ExcludeTelemetryProperties } + } + }; + } + + private static void ExcludeTelemetryProperties(JsonTypeInfo typeInfo) + { + if (typeInfo.Type != typeof(CaseRegistrationRequest)) + { + return; + } + + foreach (var property in typeInfo.Properties) + { + if (string.Equals(property.Name, "journeyId", StringComparison.OrdinalIgnoreCase) || + string.Equals(property.Name, "areaOrDivisionText", StringComparison.OrdinalIgnoreCase)) + { + property.ShouldSerialize = static (_, _) => false; + } + } + } } diff --git a/src/backend/Cps.CaseManagement.MdsClient/Models/Entities/CaseRegistrationRequest.cs b/src/backend/Cps.CaseManagement.MdsClient/Models/Entities/CaseRegistrationRequest.cs index 9bc4f9b9c..e026aff0c 100644 --- a/src/backend/Cps.CaseManagement.MdsClient/Models/Entities/CaseRegistrationRequest.cs +++ b/src/backend/Cps.CaseManagement.MdsClient/Models/Entities/CaseRegistrationRequest.cs @@ -66,4 +66,10 @@ public class CaseRegistrationRequest [JsonPropertyName("oicPoliceUnit")] public string OicPoliceUnit { get; set; } = string.Empty; + + [JsonPropertyName("journeyId")] + public string? JourneyId { get; set; } + + [JsonPropertyName("areaOrDivisionText")] + public string? AreaOrDivisionText { get; set; } } \ No newline at end of file diff --git a/src/tests/Cps.CaseManagement.Api.Tests/Unit/Functions/RegisterCaseTests.cs b/src/tests/Cps.CaseManagement.Api.Tests/Unit/Functions/RegisterCaseTests.cs index 4f6ca17ca..a9753757c 100644 --- a/src/tests/Cps.CaseManagement.Api.Tests/Unit/Functions/RegisterCaseTests.cs +++ b/src/tests/Cps.CaseManagement.Api.Tests/Unit/Functions/RegisterCaseTests.cs @@ -144,7 +144,9 @@ public async Task Run_WithValidRequest_ReturnsOkObjectResult() e.Urn == expectedResponse.Urn && e.CaseId == expectedResponse.CaseId && e.Username == username && - e.CorrelationId == correlationId)), + e.CorrelationId == correlationId && + e.JourneyId == caseDetails.JourneyId && + e.AreaOrDivisionText == caseDetails.AreaOrDivisionText)), Times.Once); } @@ -254,6 +256,37 @@ public async Task Run_WithNoDefendants_ValidatesInjectedPlaceholderAndForwards() Assert.Equal("Operation Nightingale", defendant.Surname); } + [Fact] + public async Task Run_WithMissingJourneyIdAndArea_TracksEventWithNullTelemetryFields() + { + var caseDetails = CreateValidCaseRegistrationRequest(); + caseDetails.JourneyId = null; + caseDetails.AreaOrDivisionText = null; + var correlationId = _fixture.Create(); + + _requestValidatorMock + .Setup(x => x.GetJsonBody(It.IsAny())) + .ReturnsAsync(new ValidatableRequest + { + IsValid = true, + Value = caseDetails + }); + + _mdsServiceMock.Setup(x => x.RegisterCaseAsync(It.IsAny())) + .ReturnsAsync(new CaseRegistrationResponseDto { CaseId = 12345, Urn = "12AB1234567" }); + + var functionContext = FunctionContextStubHelper.CreateFunctionContextStub(correlationId, _fixture.Create(), _fixture.Create()); + var httpRequest = CreateHttpRequestFromJson(caseDetails, correlationId); + + await _function.Run(httpRequest, functionContext); + + _telemetryClientMock.Verify( + t => t.TrackEvent(It.Is(e => + e.JourneyId == null && + e.AreaOrDivisionText == null)), + Times.Once); + } + private static HttpRequest CreateHttpRequestFromJson(object obj, Guid correlationId) { var req = HttpRequestStubHelper.CreateHttpRequest(correlationId); @@ -307,7 +340,9 @@ private static CaseRegistrationRequest CreateValidCaseRegistrationRequest() MonitoringCodes = new List { new CaseRegistrationMonitoringCode("MON1", true) - } + }, + JourneyId = "journey-123", + AreaOrDivisionText = "London" }; } } diff --git a/src/tests/Cps.CaseManagement.Api.Tests/Unit/TelemetryEvents/CaseRegisteredEventTests.cs b/src/tests/Cps.CaseManagement.Api.Tests/Unit/TelemetryEvents/CaseRegisteredEventTests.cs index 4151744a8..16585d215 100644 --- a/src/tests/Cps.CaseManagement.Api.Tests/Unit/TelemetryEvents/CaseRegisteredEventTests.cs +++ b/src/tests/Cps.CaseManagement.Api.Tests/Unit/TelemetryEvents/CaseRegisteredEventTests.cs @@ -6,23 +6,27 @@ namespace Cps.CaseManagement.Api.Tests.Unit.TelemetryEvents; public class CaseRegisteredEventTests { [Fact] - public void ToTelemetryEventProps_ReturnsOnlyUrnCaseIdAndUsername() + public void ToTelemetryEventProps_ReturnsUrnCaseIdUsernameJourneyIdAndAreaOrDivisionText() { var telemetryEvent = new CaseRegisteredEvent { Urn = "12AB1234567", CaseId = 98765, Username = "user@example.com", + JourneyId = "journey-123", + AreaOrDivisionText = "London", CorrelationId = Guid.NewGuid(), EventTimestamp = DateTime.UtcNow }; var (properties, metrics) = telemetryEvent.ToTelemetryEventProps(); - Assert.Equal(3, properties.Count); + Assert.Equal(5, properties.Count); Assert.Equal("12AB1234567", properties["urn"]); Assert.Equal("98765", properties["caseId"]); Assert.Equal("user@example.com", properties["username"]); + Assert.Equal("journey-123", properties["journeyId"]); + Assert.Equal("London", properties["areaOrDivisionText"]); Assert.Empty(metrics); } @@ -33,4 +37,20 @@ public void EventName_IsCaseRegisteredEvent() Assert.Equal(nameof(CaseRegisteredEvent), telemetryEvent.EventName); } + + [Fact] + public void ToTelemetryEventProps_WhenJourneyIdAndAreaAreNull_ReturnsEmptyStrings() + { + var telemetryEvent = new CaseRegisteredEvent + { + Urn = "12AB1234567", + CaseId = 98765, + Username = "user@example.com" + }; + + var (properties, _) = telemetryEvent.ToTelemetryEventProps(); + + Assert.Equal(string.Empty, properties["journeyId"]); + Assert.Equal(string.Empty, properties["areaOrDivisionText"]); + } } diff --git a/src/tests/Cps.CaseManagement.Api.Tests/Unit/Validators/CaseRegistrationRequestTests.cs b/src/tests/Cps.CaseManagement.Api.Tests/Unit/Validators/CaseRegistrationRequestTests.cs index 4783dd92b..375a12b2a 100644 --- a/src/tests/Cps.CaseManagement.Api.Tests/Unit/Validators/CaseRegistrationRequestTests.cs +++ b/src/tests/Cps.CaseManagement.Api.Tests/Unit/Validators/CaseRegistrationRequestTests.cs @@ -1068,4 +1068,40 @@ public void OicShoulderNumber_InvalidCharacters_ShouldFail() var result = _validator.TestValidate(req); result.ShouldHaveValidationErrorFor(x => x.OicShoulderNumber); } + + [Fact] + public void JourneyId_Null_ShouldPass() + { + var req = GetValidRequest(); + req.JourneyId = null; + var result = _validator.TestValidate(req); + result.ShouldNotHaveValidationErrorFor(x => x.JourneyId); + } + + [Fact] + public void JourneyId_TooLong_ShouldFail() + { + var req = GetValidRequest(); + req.JourneyId = new string('A', CaseRegistrationDefaults.JourneyIdMaxLength + 1); + var result = _validator.TestValidate(req); + result.ShouldHaveValidationErrorFor(x => x.JourneyId); + } + + [Fact] + public void AreaOrDivisionText_Null_ShouldPass() + { + var req = GetValidRequest(); + req.AreaOrDivisionText = null; + var result = _validator.TestValidate(req); + result.ShouldNotHaveValidationErrorFor(x => x.AreaOrDivisionText); + } + + [Fact] + public void AreaOrDivisionText_TooLong_ShouldFail() + { + var req = GetValidRequest(); + req.AreaOrDivisionText = new string('A', CaseRegistrationDefaults.ShortTextMaxLength + 1); + var result = _validator.TestValidate(req); + result.ShouldHaveValidationErrorFor(x => x.AreaOrDivisionText); + } } diff --git a/src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/AppInsightsTelemetryClientTests.cs b/src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/AppInsightsTelemetryClientTests.cs new file mode 100644 index 000000000..62f2b1043 --- /dev/null +++ b/src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/AppInsightsTelemetryClientTests.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using Cps.CaseManagement.Infrastructure.Telemetry; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility; +using Xunit; +using AppInsights = Microsoft.ApplicationInsights; + +namespace Cps.CaseManagement.Infrastructure.Tests.Unit.Telemetry; + +public class AppInsightsTelemetryClientTests +{ + [Fact] + public void TrackPageView_WithPropertiesAndMetrics_PopulatesPageViewTelemetry() + { + // Arrange + var channel = new StubTelemetryChannel(); + using var configuration = new TelemetryConfiguration + { + ConnectionString = "InstrumentationKey=", + TelemetryChannel = channel + }; + var wrapper = new AppInsightsTelemetryClientWrapper(new AppInsights.TelemetryClient(configuration)); + + var properties = new Dictionary + { + { "pageName", "CaseSummary" }, + { "journeyId", "journey-123" } + }; + var metrics = new Dictionary + { + { "duration", 1.5 } + }; + + // Act + wrapper.TrackPageView("CaseSummary", properties, metrics); + + // Assert + var pageView = Assert.Single(channel.Items.OfType()); + Assert.Equal("CaseSummary", pageView.Name); + Assert.Equal("journey-123", pageView.Properties["journeyId"]); + Assert.Equal("CaseSummary", pageView.Properties["pageName"]); + Assert.Equal(1.5, pageView.Metrics["duration"]); + } + + [Fact] + public void TrackPageView_WithNullPropertiesAndMetrics_DoesNotThrow() + { + // Arrange + var channel = new StubTelemetryChannel(); + using var configuration = new TelemetryConfiguration + { + ConnectionString = "InstrumentationKey=", + TelemetryChannel = channel + }; + var wrapper = new AppInsightsTelemetryClientWrapper(new AppInsights.TelemetryClient(configuration)); + + // Act + var exception = Record.Exception(() => wrapper.TrackPageView("CaseSummary", null, null)); + + // Assert + Assert.Null(exception); + var pageView = Assert.Single(channel.Items.OfType()); + Assert.Equal("CaseSummary", pageView.Name); + Assert.Empty(pageView.Properties); + Assert.Empty(pageView.Metrics); + } + + private sealed class StubTelemetryChannel : ITelemetryChannel + { + public IList Items { get; } = new List(); + + public bool? DeveloperMode { get; set; } + + public string? EndpointAddress { get; set; } + + public void Send(ITelemetry item) => Items.Add(item); + + public void Flush() + { + } + + public void Dispose() + { + } + } +} diff --git a/src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/BaseUiTelemetryEventTests.cs b/src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/BaseUiTelemetryEventTests.cs new file mode 100644 index 000000000..cb835b74c --- /dev/null +++ b/src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/BaseUiTelemetryEventTests.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using Cps.CaseManagement.Infrastructure.Telemetry; +using Xunit; + +namespace Cps.CaseManagement.Infrastructure.Tests.Unit.Telemetry; + +public class BaseUiTelemetryEventTests +{ + [Fact] + public void ToTelemetryEventProps_WithDuplicateKeys_LaterEntriesOverwriteEarlierOnes() + { + var telemetryEvent = new BaseUiTelemetryEvent + { + Properties = new List> + { + new() { { "pageName", "FirstPage" }, { "journeyId", "journey-1" } }, + new() { { "pageName", "CaseSummary" }, { "journeyId", "journey-123" } } + } + }; + + var (properties, _) = telemetryEvent.ToTelemetryEventProps(); + + Assert.Equal("CaseSummary", properties["pageName"]); + Assert.Equal("journey-123", properties["journeyId"]); + } + + [Fact] + public void ToTelemetryEventProps_WithNullProperties_ReturnsEmptyDictionaries() + { + var telemetryEvent = new BaseUiTelemetryEvent + { + Properties = null! + }; + + var exception = Record.Exception(() => telemetryEvent.ToTelemetryEventProps()); + + Assert.Null(exception); + var (properties, metrics) = telemetryEvent.ToTelemetryEventProps(); + Assert.Empty(properties); + Assert.Empty(metrics); + } + + [Fact] + public void ToTelemetryEventProps_WithNullDictionaryInCollection_IgnoresNullEntry() + { + var telemetryEvent = new BaseUiTelemetryEvent + { + Properties = new List> + { + null!, + new() { { "journeyId", "journey-123" } } + } + }; + + var (properties, _) = telemetryEvent.ToTelemetryEventProps(); + + Assert.Equal("journey-123", properties["journeyId"]); + } +} diff --git a/src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/TelemetryClientTests.cs b/src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/TelemetryClientTests.cs index adcd6d2ba..7b77494e1 100644 --- a/src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/TelemetryClientTests.cs +++ b/src/tests/Cps.CaseManagement.Infrastructure.Tests/Unit/Telemetry/TelemetryClientTests.cs @@ -234,7 +234,72 @@ public void TrackPageView_WithValidPageName_CallsTrackPageView() // Assert _mockAppInsightsTelemetryClient.Verify( - x => x.TrackPageView("HomePage"), + x => x.TrackPageView( + "HomePage", + It.Is>(p => p.ContainsKey("pageName") && p["pageName"] == "HomePage"), + It.IsAny>()), + Times.Once); + } + + [Fact] + public void TrackPageView_WithUiTelemetryEvent_PassesJourneyId() + { + // Arrange + var telemetryEvent = new BaseUiTelemetryEvent + { + Properties = new List> + { + new() { { "pageName", "CaseSummary" } }, + new() { { "journeyId", "journey-123" } } + } + }; + + // Act + _telemetryClient.TrackPageView(telemetryEvent); + + // Assert + _mockAppInsightsTelemetryClient.Verify( + x => x.TrackPageView( + "CaseSummary", + It.Is>(p => + p.ContainsKey("journeyId") && + p["journeyId"] == "journey-123"), + It.IsAny>()), + Times.Once); + } + + [Fact] + public void TrackPageView_WithCustomPropertiesAndMetrics_PassesJourneyIdAndMetrics() + { + // Arrange + var telemetryEvent = new TestTelemetryEvent + { + Properties = new Dictionary + { + { "pageName", "CaseSummary" }, + { "journeyId", "journey-123" } + }, + Metrics = new Dictionary + { + { "duration", 1.5 } + } + }; + + // Act + _telemetryClient.TrackPageView(telemetryEvent); + + // Assert + _mockAppInsightsTelemetryClient.Verify( + x => x.TrackPageView( + "CaseSummary", + It.Is>(p => + p.ContainsKey("pageName") && + p["pageName"] == "CaseSummary" && + p.ContainsKey("journeyId") && + p["journeyId"] == "journey-123"), + It.Is>(m => + m.ContainsKey("duration") && + m["duration"] == 1.5)), Times.Once); } @@ -249,7 +314,70 @@ public void TrackPageView_WithMissingPageName_DoesNotCallTrackPageView() // Assert _mockAppInsightsTelemetryClient.Verify( - x => x.TrackPageView(It.IsAny()), + x => x.TrackPageView( + It.IsAny(), + It.IsAny>(), + It.IsAny>()), + Times.Never); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TrackPageView_WithEmptyPageName_DoesNotCallTrackPageView(string pageName) + { + // Arrange + var telemetryEvent = new TestTelemetryEvent + { + Properties = new Dictionary + { + { "pageName", pageName } + } + }; + + // Act + _telemetryClient.TrackPageView(telemetryEvent); + + // Assert + _mockAppInsightsTelemetryClient.Verify( + x => x.TrackPageView( + It.IsAny(), + It.IsAny>(), + It.IsAny>()), + Times.Never); + } + + [Fact] + public void TrackPageView_WithNullEvent_DoesNothing() + { + // Act + _telemetryClient.TrackPageView(null!); + + // Assert + _mockAppInsightsTelemetryClient.Verify( + x => x.TrackPageView( + It.IsAny(), + It.IsAny>(), + It.IsAny>()), + Times.Never); + } + + [Fact] + public void TrackPageView_WithNullPropertyPayload_DoesNotThrow() + { + // Arrange + var telemetryEvent = new NullPropsTelemetryEvent(); + + // Act + var exception = Record.Exception(() => _telemetryClient.TrackPageView(telemetryEvent)); + + // Assert + Assert.Null(exception); + _mockAppInsightsTelemetryClient.Verify( + x => x.TrackPageView( + It.IsAny(), + It.IsAny>(), + It.IsAny>()), Times.Never); } @@ -452,4 +580,12 @@ public override (IDictionary Properties, IDictionary Properties, IDictionary Metrics) ToTelemetryEventProps() + { + return (null!, null!); + } + } } diff --git a/src/tests/Cps.CaseManagement.MdsClient.Tests/Factories/MdsRequestFactoryTests.cs b/src/tests/Cps.CaseManagement.MdsClient.Tests/Factories/MdsRequestFactoryTests.cs new file mode 100644 index 000000000..4e037039a --- /dev/null +++ b/src/tests/Cps.CaseManagement.MdsClient.Tests/Factories/MdsRequestFactoryTests.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using Cps.CaseManagement.MdsClient.Factories; +using Cps.CaseManagement.MdsClient.Models.Args; +using Cps.CaseManagement.MdsClient.Models.Entities; + +namespace Cps.CaseManagement.MdsClient.Tests.Factories; + +public class MdsRequestFactoryTests +{ + [Fact] + public async Task CreateRegisterCaseRequest_ExcludesTelemetryFieldsFromMdsPayload() + { + var factory = new MdsRequestFactory(); + var request = factory.CreateRegisterCaseRequest(new MdsRegisterCaseArg + { + CmsAuthValues = "cms-auth", + CorrelationId = Guid.NewGuid(), + CaseDetails = new CaseRegistrationRequest + { + Urn = new CaseRegistrationUrn + { + UniqueRef = "12345", + Year = 30, + PoliceForce = "AA", + PoliceUnit = "BB" + }, + RegisteringAreaId = 1, + RegisteringUnitId = 2, + JourneyId = "journey-123", + AreaOrDivisionText = "London" + } + }); + + var json = await request.Content!.ReadAsStringAsync(); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + Assert.False(root.TryGetProperty("journeyId", out _)); + Assert.False(root.TryGetProperty("areaOrDivisionText", out _)); + Assert.Equal(1, root.GetProperty("registeringAreaId").GetInt32()); + Assert.Equal(2, root.GetProperty("registeringUnitId").GetInt32()); + } + + [Fact] + public void CaseRegistrationRequest_DeserializesOptionalTelemetryFields() + { + const string json = """ + { + "urn": { "policeForce": "AA", "policeUnit": "BB", "uniqueRef": "12345", "year": 30 }, + "registeringAreaId": 1, + "registeringUnitId": 2, + "journeyId": "journey-123", + "areaOrDivisionText": "London" + } + """; + + var request = JsonSerializer.Deserialize(json); + + Assert.NotNull(request); + Assert.Equal("journey-123", request!.JourneyId); + Assert.Equal("London", request.AreaOrDivisionText); + } +} diff --git a/src/ui-spa/e2e-tests/config.ts b/src/ui-spa/e2e-tests/config.ts index c217899e1..3601757c8 100644 --- a/src/ui-spa/e2e-tests/config.ts +++ b/src/ui-spa/e2e-tests/config.ts @@ -3,7 +3,9 @@ import { fileURLToPath } from "node:url"; import dotenv from "dotenv"; const here = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.join(here, ".env.e2e.local") }); +if (!process.env.CI) { + dotenv.config({ path: path.join(here, ".env.e2e.local") }); +} export const STORAGE_STATE = path.join(here, ".auth", "state.json"); @@ -11,7 +13,7 @@ const required = (name: string): string => { const value = process.env[name]; if (!value) { throw new Error( - `Missing required env var ${name}. Set it in e2e-tests/.env.e2e.local`, + `Missing required env var ${name}. Add it to your environment or set it in e2e-tests/.env.e2e.local`, ); } return value; diff --git a/src/ui-spa/e2e-tests/offence-results-repealed-tag.spec.ts b/src/ui-spa/e2e-tests/offence-results-repealed-tag.spec.ts new file mode 100644 index 000000000..639579e97 --- /dev/null +++ b/src/ui-spa/e2e-tests/offence-results-repealed-tag.spec.ts @@ -0,0 +1,26 @@ +import { test } from "@playwright/test"; +import { ChargesOffenceSearchPage } from "./pages/chargesOffenceSearchPage"; +import { generateUniqueUrn } from "./utils/generateUrn"; +import { expectStep } from "./utils/expectStep"; +import { + startSingleSuspectUpToCharges, + chargeDates, +} from "./journeys/suspectChargeSteps"; + +const SEARCH_TERM = process.env.E2E_REPEALED_SEARCH_TERM ?? "burglary"; + +test("Scenario 22: repealed offences are tagged in the search results and active ones are not", async ({ + page, +}) => { + const urn = generateUniqueUrn(); + const { arrestDate } = chargeDates(); + + await startSingleSuspectUpToCharges(page, { urn, arrestDate }); + + const offenceSearchPage = new ChargesOffenceSearchPage(page); + await expectStep( + page, + "/case-registration/suspect-0/charge-0/charges-offence-search", + ); + await offenceSearchPage.searchAndVerifyRepealedTags(SEARCH_TERM); +}); diff --git a/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts b/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts index 25596d872..b02d23f3a 100644 --- a/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts +++ b/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts @@ -1,6 +1,14 @@ import { type Page, type Locator, expect } from "@playwright/test"; import { ChargesOffenceSearchPagePage as IntegrationChargesOffenceSearchPage } from "../../integration-tests/pages/chargesOffenceSearchPage"; +const REPEALED_TAG_PREFIX = "offence-repealed-warning-"; + +interface Offence { + code: string; + description: string; + effectiveToDate: string | null; +} + // Reuses the integration offence-search page object (identical selectors) and // adds e2e-only assertions against the real /api/v1/offences response: an // offence code with no matches (covering both "zero results" and an invalid @@ -17,7 +25,7 @@ export class ChargesOffenceSearchPage extends IntegrationChargesOffenceSearchPag return this.currentPage.getByTestId("offence-search-results-wrapper"); } - private async submitSearch(searchText: string): Promise { + private async submitSearch(searchText: string): Promise { await this.addOffenceSearchText(searchText); // Match regardless of status so a non-2xx fails on the assertion, not a hang. const offencesResponse = this.currentPage.waitForResponse( @@ -30,6 +38,8 @@ export class ChargesOffenceSearchPage extends IntegrationChargesOffenceSearchPag response.ok(), `offence search for "${searchText}" returned ${response.status()}`, ).toBe(true); + const body = (await response.json()) as { offences: Offence[] }; + return body.offences; } async searchAndExpectNoResults(searchText: string): Promise { @@ -48,4 +58,50 @@ export class ChargesOffenceSearchPage extends IntegrationChargesOffenceSearchPag ).toBeVisible({ timeout: 30_000 }); await this.addOffence(0); } + + async searchAndVerifyRepealedTags(searchText: string): Promise { + const offences = await this.submitSearch(searchText); + const repealed = offences.filter((offence) => offence.effectiveToDate); + const active = offences.filter((offence) => !offence.effectiveToDate); + + expect( + repealed.length, + `"${searchText}" returned no repealed offence, so the tag assertions prove nothing`, + ).toBeGreaterThan(0); + expect( + active.length, + `"${searchText}" returned no active offence, so the absent-tag assertion proves nothing`, + ).toBeGreaterThan(0); + + const resultsTable = this.resultsWrapper().getByRole("table"); + await expect(resultsTable).toBeVisible(); + + await expect( + resultsTable.locator(`[data-testid^="${REPEALED_TAG_PREFIX}"]`), + ).toHaveCount(repealed.length); + + for (const offence of repealed) { + const tagId = `${REPEALED_TAG_PREFIX}${offence.code}`; + const row = resultsTable + .locator("tbody tr") + .filter({ has: this.currentPage.getByTestId(tagId) }); + await expect(row).toHaveCount(1); + await expect(row.locator("td").nth(1)).toHaveText(offence.code); + + const descriptionCell = row.locator("td").nth(2); + const tag = descriptionCell.getByTestId(tagId); + await expect(tag).toBeVisible(); + await expect(tag).toHaveClass(/govuk-warning-text/); + await expect(tag).toHaveText("!Repealed"); + await expect(descriptionCell).toHaveText( + `${offence.description}!Repealed`, + ); + } + + for (const offence of active) { + await expect( + this.currentPage.getByTestId(`${REPEALED_TAG_PREFIX}${offence.code}`), + ).toHaveCount(0); + } + } } diff --git a/src/ui-spa/e2e-tests/playwright.e2e.config.ts b/src/ui-spa/e2e-tests/playwright.e2e.config.ts index e3f8a271d..873f96353 100644 --- a/src/ui-spa/e2e-tests/playwright.e2e.config.ts +++ b/src/ui-spa/e2e-tests/playwright.e2e.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 2 : undefined, reporter: [ ["list"], ["html", { outputFolder: "./playwright-report", open: "never" }], @@ -17,8 +17,8 @@ export default defineConfig({ use: { baseURL: FRONTEND_URL, storageState: STORAGE_STATE, - trace: "on-first-retry", - video: "on", + trace: process.env.CI ? "off" : "retain-on-failure", + video: process.env.CI ? "retain-on-failure" : "on", screenshot: "only-on-failure", }, projects: [ diff --git a/src/ui-spa/integration-tests/unauthorised-page.spec.ts b/src/ui-spa/integration-tests/unauthorised-page.spec.ts new file mode 100644 index 000000000..ad79a4e92 --- /dev/null +++ b/src/ui-spa/integration-tests/unauthorised-page.spec.ts @@ -0,0 +1,68 @@ +import { delay, HttpResponse, http } from "msw"; +import { expect, test } from "./utils/test"; + +test("Should show unauthorised page if the units api return 401", async ({ + page, + worker, +}) => { + await worker.use( + http.get("https://mocked-out-api/api/v1/units", async () => { + await delay(200); + return new HttpResponse(null, { status: 401 }); + }), + ); + + await page.goto("http://localhost:5173"); + await expect(page.getByText("Loading...")).toBeVisible(); + await expect(page.getByText("Loading...")).not.toBeVisible(); + await expect(page).toHaveURL("http://localhost:5173/unauthorised"); + await expect(page.locator("h1")).toHaveText("You cannot access this service"); + await expect( + page.getByText( + "Close this tab and open the homepage again from CMS Classic.", + ), + ).toBeVisible(); +}); + +test("Should show unauthorised page if the wms-units api return 401", async ({ + page, + worker, +}) => { + await worker.use( + http.get("https://mocked-out-api/api/v1/wms-units", async () => { + await delay(200); + return new HttpResponse(null, { status: 401 }); + }), + ); + await page.goto("http://localhost:5173"); + await expect(page.getByText("Loading...")).toBeVisible(); + await expect(page.getByText("Loading...")).not.toBeVisible(); + await expect(page).toHaveURL("http://localhost:5173/unauthorised"); + await expect(page.locator("h1")).toHaveText("You cannot access this service"); + await expect( + page.getByText( + "Close this tab and open the homepage again from CMS Classic.", + ), + ).toBeVisible(); +}); +test("Should show unauthorised page if the complexities api return 401", async ({ + page, + worker, +}) => { + await worker.use( + http.get("https://mocked-out-api/api/v1/complexities", async () => { + await delay(200); + return new HttpResponse(null, { status: 401 }); + }), + ); + await page.goto("http://localhost:5173"); + await expect(page.getByText("Loading...")).toBeVisible(); + await expect(page.getByText("Loading...")).not.toBeVisible(); + await expect(page).toHaveURL("http://localhost:5173/unauthorised"); + await expect(page.locator("h1")).toHaveText("You cannot access this service"); + await expect( + page.getByText( + "Close this tab and open the homepage again from CMS Classic.", + ), + ).toBeVisible(); +}); diff --git a/src/ui-spa/src/TelemetryLogger.test.ts b/src/ui-spa/src/TelemetryLogger.test.ts new file mode 100644 index 000000000..889e544cf --- /dev/null +++ b/src/ui-spa/src/TelemetryLogger.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { telemetryService, TelemetryType } from "./TelemetryLogger"; + +// mock the gateway API used by TelemetryLogger +vi.mock("./apis/gateway-api", () => ({ + logTelemetryEvent: vi.fn(), +})); + +import { logTelemetryEvent } from "./apis/gateway-api"; + +const getSentPayload = () => + (logTelemetryEvent as any).mock.calls[0][0] as Record; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("TelemetryService", () => { + it("trackEvent sends an Event payload with name + props", async () => { + await telemetryService.trackEvent("JourneyStarted" as any, [ + { journeyId: "J1" }, + ]); + + expect(logTelemetryEvent).toHaveBeenCalledTimes(1); + const sent = getSentPayload(); + + const expected = { + telemetryType: TelemetryType.Event, + properties: [{ name: "JourneyStarted" }, { journeyId: "J1" }], + }; + + expect(sent).toEqual(expected); + }); + + it("trackPageView sends a PageView payload with pageName and additional props", async () => { + await telemetryService.trackPageView("HomePage", [ + { foo: "bar" }, + { id: "123" }, + ]); + + expect(logTelemetryEvent).toHaveBeenCalledTimes(1); + const sent = getSentPayload(); + + const expected = { + telemetryType: TelemetryType.PageView, + properties: [{ pageName: "HomePage" }, { foo: "bar" }, { id: "123" }], + }; + + expect(sent).toEqual(expected); + }); + + it("trackException sends Exception payload with message/name/stack plus extra props", async () => { + const err = new Error("boom"); + err.name = "BoomError"; + + await telemetryService.trackException(err, [{ k: "v" }]); + + expect(logTelemetryEvent).toHaveBeenCalledTimes(1); + const sent = getSentPayload(); + + const exceptionProp = sent.properties[0]; + const expected = { + telemetryType: TelemetryType.Exception, + + properties: [ + { + exceptionMessage: exceptionProp.exceptionMessage, + errorName: exceptionProp.errorName, + errorStack: exceptionProp.errorStack, + }, + { k: "v" }, + ], + }; + + expect(sent).toEqual(expected); + }); + + it("trackTrace forwards provided properties", async () => { + await telemetryService.trackTrace([{ trace: "t" } as any]); + + expect(logTelemetryEvent).toHaveBeenCalledTimes(1); + const sent = getSentPayload(); + + const expected = { + telemetryType: TelemetryType.Trace, + eventTimestamp: sent.eventTimestamp, + properties: [{ trace: "t" }], + }; + + expect(sent).toEqual(expected); + }); +}); diff --git a/src/ui-spa/src/TelemetryLogger.ts b/src/ui-spa/src/TelemetryLogger.ts new file mode 100644 index 000000000..26e43c663 --- /dev/null +++ b/src/ui-spa/src/TelemetryLogger.ts @@ -0,0 +1,76 @@ +import { logTelemetryEvent } from "./apis/gateway-api"; + +export const TelemetryType = { + Event: "Event", + Exception: "Exception", + Metric: "Metric", + PageView: "PageView", + Trace: "Trace", +} as const; + +export type TelemetryPayload = { + telemetryType: (typeof TelemetryType)[keyof typeof TelemetryType]; + properties: Record[]; +}; + +export type TelemetryEventPropsMap = { + JourneyStarted: [{ journeyId: string }]; + JourneyCancelled: [{ journeyId: string }, { cancelledFrom: string }]; +}; +export type CustomEventName = keyof TelemetryEventPropsMap; + +export type TelemetryEventProps = + TelemetryEventPropsMap[T]; + +export type TelemetryPageViewProps = Record[]; + +export class TelemetryService { + async trackEvent( + eventName: T, + properties: TelemetryEventProps, + ): Promise { + const payload: TelemetryPayload = { + telemetryType: TelemetryType.Event, + properties: [{ name: eventName }, ...properties], + }; + await logTelemetryEvent(payload); + } + + async trackPageView( + pageName: string, + properties: TelemetryPageViewProps, + ): Promise { + const payload: TelemetryPayload = { + telemetryType: TelemetryType.PageView, + properties: [{ pageName: pageName }, ...properties], + }; + await logTelemetryEvent(payload); + } + + async trackException( + error: Error, + properties: Record[] = [], + ): Promise { + const payload: TelemetryPayload = { + telemetryType: TelemetryType.Exception, + properties: [ + { + exceptionMessage: error.message, + errorName: error.name, + errorStack: error.stack ?? "", + }, + ...properties, + ], + }; + await logTelemetryEvent(payload); + } + + async trackTrace(properties: Record[] = []): Promise { + const payload: TelemetryPayload = { + telemetryType: TelemetryType.Trace, + properties: [...properties], + }; + await logTelemetryEvent(payload); + } +} +export const telemetryService = new TelemetryService(); diff --git a/src/ui-spa/src/apis/gateway-api.test.ts b/src/ui-spa/src/apis/gateway-api.test.ts index 5ade92ea4..496b583ed 100644 --- a/src/ui-spa/src/apis/gateway-api.test.ts +++ b/src/ui-spa/src/apis/gateway-api.test.ts @@ -15,6 +15,7 @@ import { getOffenderTypes, getPoliceUnits, getOffences, + logTelemetryEvent, } from "./gateway-api"; import { ApiError } from "../common/errors/ApiError"; @@ -30,14 +31,17 @@ vi.mock("../config", () => ({ vi.mock("uuid", () => ({ v4: () => "mock-uuid", })); +let consoleWarnMock: any; describe("gateway-api", () => { beforeEach(() => { globalThis.fetch = vi.fn(); + consoleWarnMock = vi.spyOn(console, "warn").mockImplementation(() => {}); }); - beforeEach(() => { + afterEach(() => { vi.clearAllMocks(); + consoleWarnMock.mockRestore(); }); it("getCaseAreasAndRegisteringUnits - success", async () => { @@ -1131,4 +1135,75 @@ describe("gateway-api", () => { "API Error: https://mocked-out-api/api/v1/cases returned 200 OK - response schema validation failed", ); }); + + it("logTelemetryEvent - posts payload to gateway telemetry endpoint with common headers", async () => { + const mockRequest = { mockRequestData: {} } as any; + + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + }); + await logTelemetryEvent(mockRequest); + + expect(fetch).toHaveBeenCalledWith( + "https://mocked-out-api/api/v1/telemetry", + expect.objectContaining({ + method: "POST", + credentials: "include", + headers: { + Authorization: "Bearer access-token", + "Correlation-Id": "mock-uuid", + }, + }), + ); + expect(fetch).toHaveBeenCalledTimes(1); + expect(consoleWarnMock).not.toHaveBeenCalled(); + }); + + it("logs a warning when response is not ok", async () => { + const mockRequest = { mockRequestData: {} } as any; + (globalThis.fetch as any).mockResolvedValue({ + ok: false, + status: 500, + }); + await logTelemetryEvent(mockRequest); + + expect(fetch).toHaveBeenCalledWith( + "https://mocked-out-api/api/v1/telemetry", + expect.objectContaining({ + method: "POST", + credentials: "include", + headers: { + Authorization: "Bearer access-token", + "Correlation-Id": "mock-uuid", + }, + }), + ); + expect(fetch).toHaveBeenCalledTimes(1); + expect(consoleWarnMock).toHaveBeenCalledWith( + "Logging telemetry event failed with status: 500", + ); + }); + + it("catches network/auth errors and warns", async () => { + const mockRequest = { mockRequestData: {} } as any; + (globalThis.fetch as any).mockRejectedValue(new Error("network down")); + await logTelemetryEvent(mockRequest); + + expect(fetch).toHaveBeenCalledWith( + "https://mocked-out-api/api/v1/telemetry", + expect.objectContaining({ + method: "POST", + credentials: "include", + headers: { + Authorization: "Bearer access-token", + "Correlation-Id": "mock-uuid", + }, + }), + ); + expect(fetch).toHaveBeenCalledTimes(1); + expect(consoleWarnMock).toHaveBeenCalledWith( + "Logging telemetry event failed due to network or auth error:", + Error("network down"), + ); + }); }); diff --git a/src/ui-spa/src/apis/gateway-api.ts b/src/ui-spa/src/apis/gateway-api.ts index ea9809935..e9e0d7e7f 100644 --- a/src/ui-spa/src/apis/gateway-api.ts +++ b/src/ui-spa/src/apis/gateway-api.ts @@ -38,6 +38,7 @@ import { caseRegistrationResponseSchema, } from "../schemas"; import { ApiError } from "../common/errors/ApiError"; +import { type TelemetryPayload } from "../TelemetryLogger"; export const CORRELATION_ID = "Correlation-Id"; @@ -447,3 +448,28 @@ export const submitCaseRegistration = async ( ); return result; }; + +export const logTelemetryEvent = async (payload: TelemetryPayload) => { + try { + const url = `${GATEWAY_BASE_URL}/api/v1/telemetry`; + const response = await fetch(url, { + method: "POST", + credentials: "include", + headers: { + ...(await buildCommonHeaders()), + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + console.warn( + `Logging telemetry event failed with status: ${response.status}`, + ); + } + } catch (error) { + // Fail silently to ensure UI flows remain unblocked + console.warn( + "Logging telemetry event failed due to network or auth error:", + error, + ); + } +}; diff --git a/src/ui-spa/src/common/constants/pageTitles.ts b/src/ui-spa/src/common/constants/pageTitles.ts new file mode 100644 index 000000000..21028f1f7 --- /dev/null +++ b/src/ui-spa/src/common/constants/pageTitles.ts @@ -0,0 +1,38 @@ +export const pageTitles: Record = { + "/case-registration": "Home", + "/areas": "Case Areas", + "/case-details": "Case Details", + "/first-hearing": "First Hearing Details", + "/case-complexity": "Case Complexity", + "/case-monitoring-codes": "Case Monitoring Codes", + "/case-assignee": "Case Assignee", + "/case-summary": "Case Summary", + "/case-registration-confirmation": "Case Registration Confirmation", + "/add-suspect": "Add Suspect", + "/suspect-dob": "Suspect Date of Birth", + "/suspect-gender": "Suspect Gender", + "/suspect-ethnicity": "Suspect Ethnicity", + "/suspect-religion": "Suspect Religion", + "/suspect-disability": "Suspect Disability", + "/suspect-add-aliases": "Suspect Add Aliases", + "/suspect-aliases-summary": "Suspect Aliases Summary", + "/suspect-asn": "Suspect ASN", + "/suspect-offender": "Suspect Offender Type", + "/suspect-summary": "Suspect Summary", + "/suspect-remove-confirmation": "Suspect Remove Confirmation", + "/want-to-add-charges": "Want To Add Charges", + "/add-charge-suspect": "Add Charge Suspect", + "/charges-offence-search": "Charges Offence Search", + "/add-charge-details": "Add Charge Details", + "/charges-summary": "Charges Summary", + "/add-charge-victim": "Add Charge Victim", + "/charge-remove-confirmation": "Charge Remove Confirmation", + "/charges-victim-duplicate-confirmation": + "Charges Victim Duplicate Confirmation", + "/change-area-confirmation": "Change Area Confirmation", + "/change-registering-unit-confirmation": + "Change Registering Unit Confirmation", + "/remove-all-suspects-confirmation": "Remove All Suspects Confirmation", + "/cancel-case-registration-confirmation": + "Cancel Case Registration Confirmation", +}; diff --git a/src/ui-spa/src/common/hooks/useAuthedQuery.test.tsx b/src/ui-spa/src/common/hooks/useAuthedQuery.test.tsx new file mode 100644 index 000000000..b5f3dd7e0 --- /dev/null +++ b/src/ui-spa/src/common/hooks/useAuthedQuery.test.tsx @@ -0,0 +1,105 @@ +import { render, waitFor } from "@testing-library/react"; +import { + type Mock, + vi, + describe, + it, + beforeEach, + afterEach, + expect, +} from "vitest"; +import { type UseQueryOptions } from "@tanstack/react-query"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(), +})); + +vi.mock("react-router", () => ({ + useNavigate: vi.fn(), +})); + +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import useAuthedQuery from "./useAuthedQuery"; +import { ApiError } from "../errors/ApiError"; + +const useQueryMock = useQuery as unknown as Mock; +const useNavigateMock = useNavigate as unknown as Mock; + +function TestComponent(props: { options: UseQueryOptions }) { + useAuthedQuery(props.options); + return null; +} + +describe("useAuthedQuery", () => { + const navigateFn = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + useNavigateMock.mockReturnValue(navigateFn); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("navigates to /unauthorised when query.error is ApiError with code 401", async () => { + const apiErr = new ApiError("unauth", "/api/v1/units", { + status: 401, + statusText: "Unauthorized", + }); + + useQueryMock.mockReturnValue({ error: apiErr }); + + render(); + + await waitFor(() => { + expect(navigateFn).toHaveBeenCalledWith("/unauthorised", { + replace: true, + }); + }); + }); + + it("does not navigate for non-401 ApiError", async () => { + const apiErr = new ApiError("unauth", "/api/v1/units", { + status: 403, + statusText: "Forbidden", + }); + useQueryMock.mockReturnValue({ error: apiErr }); + + render(); + + await waitFor(() => { + expect(navigateFn).not.toHaveBeenCalled(); + }); + }); + + it("provides a throwOnError function that returns false for ApiError(401) and true otherwise", () => { + useQueryMock.mockReturnValue({ error: null }); + + render(); + + const param = useQueryMock.mock.calls[0][0]; + expect(typeof param.throwOnError).toBe("function"); + + expect( + param.throwOnError( + new ApiError("api error", "path", { + status: 401, + statusText: "Unauthorized", + }), + ), + ).toBe(false); + + expect( + param.throwOnError( + new ApiError("api error", "path", { + status: 403, + statusText: "Forbidden", + }), + ), + ).toBe(true); + + expect(param.throwOnError(new Error("other"))).toBe(true); + }); +}); diff --git a/src/ui-spa/src/common/hooks/useAuthedQuery.tsx b/src/ui-spa/src/common/hooks/useAuthedQuery.tsx new file mode 100644 index 000000000..41d545146 --- /dev/null +++ b/src/ui-spa/src/common/hooks/useAuthedQuery.tsx @@ -0,0 +1,29 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import { ApiError } from "../errors/ApiError"; +import { useEffect } from "react"; + +const useAuthedQuery = ( + options: UseQueryOptions, +) => { + const navigate = useNavigate(); + const query = useQuery({ + ...options, + throwOnError: (error) => { + if (error instanceof ApiError && error.code === 401) { + return false; + } + return true; + }, + }); + + useEffect(() => { + if (query.error instanceof ApiError && query.error.code === 401) { + navigate("/unauthorised", { replace: true }); + } + }, [query.error, navigate]); + + return query; +}; + +export default useAuthedQuery; diff --git a/src/ui-spa/src/common/hooks/usePageView.test.tsx b/src/ui-spa/src/common/hooks/usePageView.test.tsx new file mode 100644 index 000000000..0cdb4fb2a --- /dev/null +++ b/src/ui-spa/src/common/hooks/usePageView.test.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { type Mock } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../TelemetryLogger", () => ({ + telemetryService: { + trackPageView: vi.fn(), + }, +})); + +vi.mock("react-router", () => ({ + useLocation: vi.fn(), +})); + +import { telemetryService } from "../../TelemetryLogger"; +import { useLocation } from "react-router"; +import { CaseRegistrationFormContext } from "../providers/CaseRegistrationProvider"; +import usePageView from "./usePageView"; + +function TestComponent() { + usePageView(); + return null; +} + +describe("usePageView", () => { + const mockedUseLocation = useLocation as Mock; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("calls telemetryService.trackPageView with the expected payload when pathname changes and journeyId is present", () => { + const pathname = "/case-registration"; + mockedUseLocation.mockImplementation(() => ({ + pathname, + })); + + const providerValue = { + state: { telemetryData: { journeyId: "J1" } }, + } as unknown as React.ContextType; + + render( + + + , + ); + + expect(telemetryService.trackPageView).toHaveBeenCalledTimes(1); + + const call = (telemetryService.trackPageView as Mock).mock.calls[0]; + + const expectedPageName = "Home"; + const expectedProperties = [{ journeyId: "J1" }, { path: pathname }]; + + expect(call).toEqual([expectedPageName, expectedProperties]); + }); + + it("does not call trackPageView when journeyId is missing", () => { + mockedUseLocation.mockImplementation(() => ({ + pathname: "/case-registration/step-1", + })); + + const providerValue = { + state: { telemetryData: { journeyId: "" } }, + } as unknown as React.ContextType; + + render( + + + , + ); + + expect(telemetryService.trackPageView).not.toHaveBeenCalled(); + }); + + it("does not call trackPageView again if pathname hasn't changed", () => { + mockedUseLocation.mockImplementation(() => ({ + pathname: "/case-registration/", + })); + + const providerValue = { + state: { telemetryData: { journeyId: "J1" } }, + } as unknown as React.ContextType; + + const { rerender } = render( + + + , + ); + + expect(telemetryService.trackPageView).toHaveBeenCalledTimes(1); + + mockedUseLocation.mockImplementation(() => ({ + pathname: "/case-registration/", + })); + rerender( + + + , + ); + + expect(telemetryService.trackPageView).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ui-spa/src/common/hooks/usePageView.tsx b/src/ui-spa/src/common/hooks/usePageView.tsx new file mode 100644 index 000000000..69d732698 --- /dev/null +++ b/src/ui-spa/src/common/hooks/usePageView.tsx @@ -0,0 +1,36 @@ +import { useEffect, useRef, useContext } from "react"; +import { useLocation } from "react-router"; +import { telemetryService } from "../../TelemetryLogger"; +import { CaseRegistrationFormContext } from "../providers/CaseRegistrationProvider"; +import { pageTitles } from "../constants/pageTitles"; + +const usePageView = () => { + const { state } = useContext(CaseRegistrationFormContext); + const location = useLocation(); + const lastPath = useRef(null); + const cleanPath = location.pathname.replace(/\/+$/, "") || "/"; + useEffect(() => { + if (lastPath.current === cleanPath) return; + + if (!state.telemetryData.journeyId) { + return; + } + + const lastSlashIndex = cleanPath.lastIndexOf("/"); + const lastSegment = cleanPath.substring(lastSlashIndex); + const pageName = pageTitles[lastSegment] || "Unknown Page"; + + telemetryService.trackPageView(pageName, [ + { + journeyId: state.telemetryData.journeyId, + }, + { + path: cleanPath, + }, + ]); + + lastPath.current = cleanPath; + }, [cleanPath, state.telemetryData.journeyId]); +}; + +export default usePageView; diff --git a/src/ui-spa/src/common/hooks/useRouteDocumentTitle.tsx b/src/ui-spa/src/common/hooks/useRouteDocumentTitle.tsx index f120994bb..f9c6bc153 100644 --- a/src/ui-spa/src/common/hooks/useRouteDocumentTitle.tsx +++ b/src/ui-spa/src/common/hooks/useRouteDocumentTitle.tsx @@ -1,44 +1,6 @@ import { useEffect, useState } from "react"; import { useLocation } from "react-router"; - -const routeTitles: Record = { - "/case-registration": "Home", - "/areas": "Case Areas", - "/case-details": "Case Details", - "/first-hearing": "First Hearing Details", - "/case-complexity": "Case Complexity", - "/case-monitoring-codes": "Case Monitoring Codes", - "/case-assignee": "Case Assignee", - "/case-summary": "Case Summary", - "/case-registration-confirmation": "Case Registration Confirmation", - "/add-suspect": "Add Suspect", - "/suspect-dob": "Suspect Date of Birth", - "/suspect-gender": "Suspect Gender", - "/suspect-ethnicity": "Suspect Ethnicity", - "/suspect-religion": "Suspect Religion", - "/suspect-disability": "Suspect Disability", - "/suspect-add-aliases": "Suspect Add Aliases", - "/suspect-aliases-summary": "Suspect Aliases Summary", - "/suspect-asn": "Suspect ASN", - "/suspect-offender": "Suspect Offender Type", - "/suspect-summary": "Suspect Summary", - "/suspect-remove-confirmation": "Suspect Remove Confirmation", - "/want-to-add-charges": "Want To Add Charges", - "/add-charge-suspect": "Add Charge Suspect", - "/charges-offence-search": "Charges Offence Search", - "/add-charge-details": "Add Charge Details", - "/charges-summary": "Charges Summary", - "/add-charge-victim": "Add Charge Victim", - "/charge-remove-confirmation": "Charge Remove Confirmation", - "/charges-victim-duplicate-confirmation": - "Charges Victim Duplicate Confirmation", - "/change-area-confirmation": "Change Area Confirmation", - "/change-registering-unit-confirmation": - "Change Registering Unit Confirmation", - "/remove-all-suspects-confirmation": "Remove All Suspects Confirmation", - "/cancel-case-registration-confirmation": - "Cancel Case Registration Confirmation", -}; +import { pageTitles } from "../constants/pageTitles"; const DEFAULT_TITLE = "Home"; @@ -48,7 +10,7 @@ export const useRouteDocumentTitle = (): { title: string } => { useEffect(() => { const lastSlashIndex = pathname.lastIndexOf("/"); const lastSegment = pathname.substring(lastSlashIndex); - const title = `${routeTitles[lastSegment] ?? DEFAULT_TITLE} - Register A Case`; + const title = `${pageTitles[lastSegment] ?? DEFAULT_TITLE} - Register A Case`; document.title = title; setTitle(title); }, [pathname]); diff --git a/src/ui-spa/src/common/reducers/caseRegistrationReducer.test.ts b/src/ui-spa/src/common/reducers/caseRegistrationReducer.test.ts index c376b064d..e5e706a3a 100644 --- a/src/ui-spa/src/common/reducers/caseRegistrationReducer.test.ts +++ b/src/ui-spa/src/common/reducers/caseRegistrationReducer.test.ts @@ -141,6 +141,9 @@ describe("caseRegistrationReducer", () => { }, }, apiData: apiData, + telemetryData: { + journeyId: "", + }, }; const action: CaseRegistrationActions = { type: "RESET_FORM_DATA" }; const state = caseRegistrationReducer(modifiedState, action); @@ -1587,6 +1590,25 @@ describe("caseRegistrationReducer", () => { const state = caseRegistrationReducer(modifiedState, action); expect(state).toEqual(modifiedState); }); + + it("SET_TELEMETRY_JOURNEY_ID should set the telemetryData journeyId", () => { + const startState = { + ...initialState, + formData: { + ...initialState.formData, + }, + }; + + const action = { + type: "SET_TELEMETRY_JOURNEY_ID" as const, + payload: { journeyId: "J1" }, + }; + + const next = caseRegistrationReducer(startState, action); + + expect(next.telemetryData.journeyId).toBe("J1"); + expect(next.apiData).toEqual(startState.apiData); + }); }); describe("getResetFieldValues", () => { it("should reset caseProsecutorText and caseCaseworkerText when caseProsecutorRadio is 'no'", () => { diff --git a/src/ui-spa/src/common/reducers/caseRegistrationReducer.ts b/src/ui-spa/src/common/reducers/caseRegistrationReducer.ts index 2c0398238..181fef911 100644 --- a/src/ui-spa/src/common/reducers/caseRegistrationReducer.ts +++ b/src/ui-spa/src/common/reducers/caseRegistrationReducer.ts @@ -164,6 +164,9 @@ export type CaseRegistrationState = { suspectOffenderTypes?: OffenderTypes | null; offencesSearchResults?: Offences | null; }; + telemetryData: { + journeyId: string; + }; }; export const suspectInitialState: SuspectFormData = { @@ -261,6 +264,10 @@ export const initialState: CaseRegistrationState = { suspectReligions: null, suspectOffenderTypes: null, }, + + telemetryData: { + journeyId: "", + }, }; export type CaseRegistrationActions = @@ -484,6 +491,12 @@ export type CaseRegistrationActions = } | { type: "REMOVE_ALL_SUSPECTS"; + } + | { + type: "SET_TELEMETRY_JOURNEY_ID"; + payload: { + journeyId: string; + }; }; export type DispatchType = React.Dispatch; @@ -879,6 +892,16 @@ export const caseRegistrationReducer = ( }; } + case "SET_TELEMETRY_JOURNEY_ID": { + return { + ...state, + telemetryData: { + ...state.telemetryData, + journeyId: action.payload.journeyId, + }, + }; + } + default: return state; } diff --git a/src/ui-spa/src/common/utils/getCaseRegistartionRequestData.test.ts b/src/ui-spa/src/common/utils/getCaseRegistartionRequestData.test.ts index 78e1b52f3..8296ea508 100644 --- a/src/ui-spa/src/common/utils/getCaseRegistartionRequestData.test.ts +++ b/src/ui-spa/src/common/utils/getCaseRegistartionRequestData.test.ts @@ -99,10 +99,13 @@ describe("getCaseRegistrationRequestData", () => { }, defendants: [], victims: [], + journeyId: "JOURNEY_ID", + areaOrDivisionText: "Area 1", }; const result = getCaseRegistrationRequestData( formData, monitoringCodesData, + "JOURNEY_ID", ); expect(result).toEqual(expectedResult); }); @@ -201,10 +204,13 @@ describe("getCaseRegistrationRequestData", () => { }, defendants: [], victims: [], + journeyId: "JOURNEY_ID", + areaOrDivisionText: "Area 1", }; const result = getCaseRegistrationRequestData( formData, monitoringCodesData, + "JOURNEY_ID", { unitId: 2091, unitDescription: "Plymouth Magistrates Court Unit", @@ -383,11 +389,14 @@ describe("getCaseRegistrationRequestData", () => { oicRank: "PC", oicShoulderNumber: "12", oicSurname: "last", + journeyId: "JOURNEY_ID", + areaOrDivisionText: "CAMBRIDGESHIRE", }; const result = getCaseRegistrationRequestData( formData, monitoringCodesData, + "JOURNEY_ID", ); expect(result).toEqual(expectedResult); }); @@ -755,11 +764,14 @@ describe("getCaseRegistrationRequestData", () => { oicRank: "PC", oicShoulderNumber: "12", oicSurname: "last", + journeyId: "JOURNEY_ID", + areaOrDivisionText: "CAMBRIDGESHIRE", }; const result = getCaseRegistrationRequestData( formData, monitoringCodesData, + "JOURNEY_ID", ); expect(result).toEqual(expectedResult); }); diff --git a/src/ui-spa/src/common/utils/getCaseRegistrationRequestData.ts b/src/ui-spa/src/common/utils/getCaseRegistrationRequestData.ts index 80aab9dc8..f6152a58a 100644 --- a/src/ui-spa/src/common/utils/getCaseRegistrationRequestData.ts +++ b/src/ui-spa/src/common/utils/getCaseRegistrationRequestData.ts @@ -14,6 +14,7 @@ import { export const getCaseRegistrationRequestData = ( formData: CaseRegistrationFormData, monitoringCodesData: CaseMonitoringCode[], + journeyId: string, policeUnit?: PoliceUnit, ): CaseRegistrationRequestData => { const monitoringCodes = monitoringCodesData @@ -56,6 +57,8 @@ export const getCaseRegistrationRequestData = ( oicPoliceUnit: policeUnit ? policeUnit.code : "", defendants: getSuspectRequestData(formData.suspects, formData.victimsList), victims: getVictimRequestData(formData.victimsList), + journeyId: journeyId, + areaOrDivisionText: formData.areaOrDivisionText.description, }; const validatedData = diff --git a/src/ui-spa/src/components/AppRoutes.tsx b/src/ui-spa/src/components/AppRoutes.tsx index 9d10e8ef8..4b0a90c26 100644 --- a/src/ui-spa/src/components/AppRoutes.tsx +++ b/src/ui-spa/src/components/AppRoutes.tsx @@ -33,8 +33,11 @@ import ChangeRegisteringUnitConfirmationPage from "./case-registration/change-re import RemoveAllSuspectsConfirmationPage from "./case-registration/remove-all-suspects-confirmation"; import CancelCaseRegistrationConfirmationPage from "./case-registration/cancel-case-registration-confirmation"; import ProtectedRoutes from "./ProtectedRoutes"; +import UnAuthorisedPage from "./unauthorised"; +import usePageView from "../common/hooks/usePageView"; const AppRoutes = () => { + usePageView(); return ( } /> @@ -165,6 +168,7 @@ const AppRoutes = () => { path="/case-registration/cancel-case-registration-confirmation" element={} /> + } /> } /> ); diff --git a/src/ui-spa/src/components/case-registration/cancel-case-registration-confirmation/index.tsx b/src/ui-spa/src/components/case-registration/cancel-case-registration-confirmation/index.tsx index 5cf1b80b4..cb20b609a 100644 --- a/src/ui-spa/src/components/case-registration/cancel-case-registration-confirmation/index.tsx +++ b/src/ui-spa/src/components/case-registration/cancel-case-registration-confirmation/index.tsx @@ -1,12 +1,22 @@ -import { useRef, useEffect, useState, useCallback, useMemo } from "react"; +import { + useRef, + useEffect, + useState, + useCallback, + useMemo, + useContext, +} from "react"; import { Radios, Button, ErrorSummary } from "../../govuk"; import { type GeneralRadioValue } from "../../../common/reducers/caseRegistrationReducer"; import { HOME_PAGE_URL } from "../../../config"; import { useNavigate, useLocation } from "react-router"; import PageContentWrapper from "../../common/PageContentWrapper"; +import { telemetryService } from "../../../TelemetryLogger"; +import { CaseRegistrationFormContext } from "../../../common/providers/CaseRegistrationProvider"; import styles from "../index.module.scss"; const CancelCaseRegistrationConfirmationPage = () => { + const { state } = useContext(CaseRegistrationFormContext); const navigate = useNavigate(); const { state: { backRoute }, @@ -90,6 +100,10 @@ const CancelCaseRegistrationConfirmationPage = () => { if (!validateFormData()) return; if (formData.cancelRegistrationRadio === "yes") { + telemetryService.trackEvent("JourneyCancelled", [ + { journeyId: state.telemetryData.journeyId }, + { cancelledFrom: backRoute || "unknown" }, + ]); globalThis.location.href = HOME_PAGE_URL; return; } diff --git a/src/ui-spa/src/components/case-registration/case-summary-page/index.tsx b/src/ui-spa/src/components/case-registration/case-summary-page/index.tsx index 6c9776216..761fc4782 100644 --- a/src/ui-spa/src/components/case-registration/case-summary-page/index.tsx +++ b/src/ui-spa/src/components/case-registration/case-summary-page/index.tsx @@ -146,6 +146,7 @@ const CaseSummaryPage = () => { const requestData = getCaseRegistrationRequestData( state.formData, state.apiData.caseMonitoringCodes!, + state.telemetryData.journeyId, policeUnit, ); diff --git a/src/ui-spa/src/components/case-registration/index.module.scss b/src/ui-spa/src/components/case-registration/index.module.scss index f0ac1c8da..371d91f58 100644 --- a/src/ui-spa/src/components/case-registration/index.module.scss +++ b/src/ui-spa/src/components/case-registration/index.module.scss @@ -27,3 +27,13 @@ } } } +.pageLoadingSpinnerWrapper { + inset: 0; + display: flex; + gap: 1rem; + flex-direction: column; + align-items: center; + justify-content: center; + position: absolute; + padding-bottom: 12.5rem; +} diff --git a/src/ui-spa/src/components/case-registration/index.tsx b/src/ui-spa/src/components/case-registration/index.tsx index c24f2b4b3..f0e5030ad 100644 --- a/src/ui-spa/src/components/case-registration/index.tsx +++ b/src/ui-spa/src/components/case-registration/index.tsx @@ -8,13 +8,16 @@ import { getCaseAreasAndWitnessCareUnits, getCaseComplexities, } from "../../apis/gateway-api"; -import { useQuery } from "@tanstack/react-query"; +import useAuthedQuery from "../../common/hooks/useAuthedQuery"; import { useIsAreaSensitive } from "../../common/hooks/useIsAreaSensitive"; import { sanitizeOperationNameText } from "../../common/utils/sanitizeOperationNameText"; import { DEFAULT_COMPLEXITY_DESCRIPTION } from "../../common/constants/general"; import useErrorSummaryList from "../../common/hooks/useErrorSummaryList"; import { useNavigate } from "react-router"; import PageContentWrapper from "../common/PageContentWrapper"; +import { v4 as uuidv4 } from "uuid"; +import { telemetryService } from "../../TelemetryLogger"; +import { Spinner } from "../common/Spinner"; import styles from "./index.module.scss"; const CaseRegistrationPage = () => { @@ -44,30 +47,26 @@ const CaseRegistrationPage = () => { operationNameText: state.formData.operationNameText || "", }); - const { data: areasData, error: areaDataError } = useQuery({ + const { data: areasData, isLoading: isAreaLoading } = useAuthedQuery({ queryKey: ["areas"], queryFn: getCaseAreasAndRegisteringUnits, retry: false, enabled: !state.apiData.areasAndRegisteringUnits, }); - const { data: witnessCareUnitsData, error: witnessCareUnitsError } = useQuery( - { - queryKey: ["witness-care-units"], - queryFn: getCaseAreasAndWitnessCareUnits, - retry: false, - enabled: !state.apiData.areasAndWitnessCareUnits, - }, - ); + const { data: witnessCareUnitsData } = useAuthedQuery({ + queryKey: ["witness-care-units"], + queryFn: getCaseAreasAndWitnessCareUnits, + retry: false, + enabled: !state.apiData.areasAndWitnessCareUnits, + }); - const { data: caseComplexitiesData, error: caseComplexitiesError } = useQuery( - { - queryKey: ["case-complexities"], - queryFn: () => getCaseComplexities(), - enabled: !state.apiData.caseComplexities, - retry: false, - }, - ); + const { data: caseComplexitiesData } = useAuthedQuery({ + queryKey: ["case-complexities"], + queryFn: () => getCaseComplexities(), + enabled: !state.apiData.caseComplexities, + retry: false, + }); const [formDataErrors, setFormDataErrors] = useState({}); @@ -161,18 +160,6 @@ const CaseRegistrationPage = () => { return isValid; }; - useEffect(() => { - if (areaDataError) throw areaDataError; - }, [areaDataError]); - - useEffect(() => { - if (witnessCareUnitsError) throw witnessCareUnitsError; - }, [witnessCareUnitsError]); - - useEffect(() => { - if (caseComplexitiesError) throw caseComplexitiesError; - }, [caseComplexitiesError]); - useEffect(() => { if (areasData && !state.apiData.areasAndRegisteringUnits) { dispatch({ @@ -275,6 +262,15 @@ const CaseRegistrationPage = () => { if (!validateFormData()) return; setDisableBtns(true); + if (!state.telemetryData.journeyId) { + const journeyId = uuidv4(); + dispatch({ + type: "SET_TELEMETRY_JOURNEY_ID", + payload: { journeyId }, + }); + telemetryService.trackEvent("JourneyStarted", [{ journeyId }]); + } + let nextRoute = "/case-registration/areas"; if (state.formData.navigation.fromCaseSummaryPage) { nextRoute = "/case-registration/case-summary"; @@ -336,6 +332,16 @@ const CaseRegistrationPage = () => { })); }; + if (isAreaLoading) { + return ( + +
+ +
Loading...
+
+
+ ); + } return (
diff --git a/src/ui-spa/src/components/common/Spinner.module.scss b/src/ui-spa/src/components/common/Spinner.module.scss new file mode 100644 index 000000000..1146a89ec --- /dev/null +++ b/src/ui-spa/src/components/common/Spinner.module.scss @@ -0,0 +1,17 @@ +.spinner { + border: 0.5rem solid #dee0e2; + border-radius: 50%; + border-top-color: #005ea5; + + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/ui-spa/src/components/common/Spinner.tsx b/src/ui-spa/src/components/common/Spinner.tsx new file mode 100644 index 000000000..66953656c --- /dev/null +++ b/src/ui-spa/src/components/common/Spinner.tsx @@ -0,0 +1,13 @@ +import classes from "./Spinner.module.scss"; + +type Props = { + diameterPx: number; +} & React.HTMLAttributes; + +export const Spinner: React.FC = ({ diameterPx, ...props }) => ( +
+); diff --git a/src/ui-spa/src/components/unauthorised/index.tsx b/src/ui-spa/src/components/unauthorised/index.tsx new file mode 100644 index 000000000..cde276e63 --- /dev/null +++ b/src/ui-spa/src/components/unauthorised/index.tsx @@ -0,0 +1,16 @@ +import PageContentWrapper from "../common/PageContentWrapper"; + +const UnAuthorisedPage = () => { + return ( + +

+ You cannot access this service +

+

+ Close this tab and open the homepage again from CMS Classic. +

+
+ ); +}; + +export default UnAuthorisedPage; diff --git a/src/ui-spa/src/mocks/handlers.ts b/src/ui-spa/src/mocks/handlers.ts index 55ed4a138..34bdb5d5e 100644 --- a/src/ui-spa/src/mocks/handlers.ts +++ b/src/ui-spa/src/mocks/handlers.ts @@ -134,6 +134,10 @@ export const setupHandlers = (baseUrl: string, apiMockSource: string) => { await delay(RESPONSE_DELAY); return HttpResponse.json(results); }), + http.post(`${baseUrl}/api/v1/telemetry`, async () => { + await delay(RESPONSE_DELAY); + return HttpResponse.json(true); + }), ]; }; diff --git a/src/ui-spa/src/schemas/requests/caseRegistration.ts b/src/ui-spa/src/schemas/requests/caseRegistration.ts index 1f26aff9b..6de25bb9d 100644 --- a/src/ui-spa/src/schemas/requests/caseRegistration.ts +++ b/src/ui-spa/src/schemas/requests/caseRegistration.ts @@ -7,6 +7,8 @@ export const caseRegistrationRequestDataSchema = z.object({ uniqueRef: z.string(), year: z.number(), }), + journeyId: z.string(), + areaOrDivisionText: z.string(), registeringAreaId: z.number(), registeringUnitId: z.number(), allocatedWcuId: z.number(), From 8e8cf741a6b8dcc99e70d56d606d8c1cf06b5ce1 Mon Sep 17 00:00:00 2001 From: kmshrajcps Date: Thu, 24 Sep 2026 13:09:20 +0100 Subject: [PATCH 2/7] test(e2e): assert unauthenticated users are sent to the unauthorised page (#251) Covers FCT2-22092 (#241) against the deployed SPA and real gateway API, where previously only the MSW integration suite asserted it. Two scenarios, each keeping the Entra SSO cookies so the SPA still signs in and only the gateway API rejects the request: - Scenario 23 removes the Cms-Auth-Values cookie. - Scenario 24 keeps the cookie but replaces its value with an invalid one. Both load the home page and assert the units API returns 401, the user lands on /unauthorised with the "You cannot access this service" heading and the CMS Classic relaunch text, and the Register a case form is never shown. Falsified by running a copy with the valid cookie left intact: it fails the status check (expected 401, received 200). --- .../e2e-tests/unauthorised-page.spec.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/ui-spa/e2e-tests/unauthorised-page.spec.ts diff --git a/src/ui-spa/e2e-tests/unauthorised-page.spec.ts b/src/ui-spa/e2e-tests/unauthorised-page.spec.ts new file mode 100644 index 000000000..290bd279b --- /dev/null +++ b/src/ui-spa/e2e-tests/unauthorised-page.spec.ts @@ -0,0 +1,49 @@ +import { expect, test, type Page } from "@playwright/test"; +import { API_BASE_URL } from "./config"; +import { expectStep } from "./utils/expectStep"; + +const CMS_AUTH_COOKIE = "Cms-Auth-Values"; + +const expectUnauthorisedPage = async (page: Page): Promise => { + const unitsResponse = page.waitForResponse( + (r) => r.url().includes("/api/v1/units") && r.request().method() === "GET", + { timeout: 60_000 }, + ); + await page.goto("/"); + expect((await unitsResponse).status()).toBe(401); + + await expectStep(page, "/unauthorised"); + await expect(page.locator("h1")).toHaveText("You cannot access this service"); + await expect( + page.getByText( + "Close this tab and open the homepage again from CMS Classic.", + ), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Register a case" }), + ).toHaveCount(0); +}; + +test("Scenario 23: a user without the CMS auth cookie is redirected to the unauthorised page", async ({ + page, + context, +}) => { + await context.clearCookies({ name: CMS_AUTH_COOKIE }); + + await expectUnauthorisedPage(page); +}); + +test("Scenario 24: a user with an invalid CMS auth cookie is redirected to the unauthorised page", async ({ + page, + context, +}) => { + const cmsCookie = (await context.cookies(`${API_BASE_URL}/api/`)).find( + (cookie) => cookie.name === CMS_AUTH_COOKIE, + ); + if (!cmsCookie) { + throw new Error(`${CMS_AUTH_COOKIE} missing from the saved storage state`); + } + await context.addCookies([{ ...cmsCookie, value: "invalid" }]); + + await expectUnauthorisedPage(page); +}); From 318ee6c99dff774c17dfa6daf478c1c48e8ac467 Mon Sep 17 00:00:00 2001 From: kmshrajcps Date: Thu, 24 Sep 2026 13:10:05 +0100 Subject: [PATCH 3/7] fix(e2e): recover from URN collisions instead of timing out (#253) The police force, unit and year in a generated URN are fixed, so the 5-digit reference is the only thing keeping it unique - a space of 100,000 shared with every case already registered in the environment. When the reference is already taken the page stays on case-details showing "URN already exists", nothing handles it, and the test times out waiting for the next screen. Rather than pre-checking (which would mean obtaining a gateway token outside the browser), read the boolean the page itself acts on: GET /api/v1/urns/{urn}/exists, called on every submit once client-side validation passes. CaseDetailsPage.saveAndContinueWithFreeUrn submits, reads that response, and on a collision swaps in a new reference and submits again - up to 5 attempts before failing with the URN it tried. Reading the response rather than the rendered error matters: the error summary from the previous attempt stays on screen, so a screen-based check would keep reporting a collision that had already been resolved. refreshUniqueReference updates the URN in place so the summary and confirmation assertions downstream check the URN the case was actually registered under. enterAreasAndCaseDetails covers 13 journeys; longPathValidation and the recovery URN in shortPathDuplicateUrn submit case-details directly and now use the same helper. The two journeys that want a duplicate on purpose keep asserting it, via the new expectDuplicateUrn option and the existing submitAndExpectExistingUrnError. Verified with a throwaway spec that registered a case and then re-used its URN: the journey recovered and submitted under the new URN, while the same journey with the retry opted out reproduced the stranding. Full suite: 24 passed. --- .../e2e-tests/journeys/longPathValidation.ts | 2 +- .../journeys/shortPathDuplicateUrn.ts | 11 +++-- src/ui-spa/e2e-tests/journeys/steps.ts | 24 +++++++--- src/ui-spa/e2e-tests/pages/caseDetailsPage.ts | 48 ++++++++++++++++--- src/ui-spa/e2e-tests/utils/generateUrn.ts | 34 ++++++++++--- 5 files changed, 96 insertions(+), 23 deletions(-) diff --git a/src/ui-spa/e2e-tests/journeys/longPathValidation.ts b/src/ui-spa/e2e-tests/journeys/longPathValidation.ts index e162409a5..abb9607e0 100644 --- a/src/ui-spa/e2e-tests/journeys/longPathValidation.ts +++ b/src/ui-spa/e2e-tests/journeys/longPathValidation.ts @@ -146,7 +146,7 @@ export async function completeLongPathValidation( await caseDetailsPage.enterWitnessCareUnit(WITNESS_CARE_UNIT); await caseDetailsPage.submitAndExpectExistingUrnError(existingUrn); await caseDetailsPage.enterUrn(urn); - await caseDetailsPage.saveAndContinue(); + await caseDetailsPage.saveAndContinueWithFreeUrn(urn); // Add suspect. const addSuspectPage = new AddSuspectPage(page); diff --git a/src/ui-spa/e2e-tests/journeys/shortPathDuplicateUrn.ts b/src/ui-spa/e2e-tests/journeys/shortPathDuplicateUrn.ts index 84fe2e180..745b93927 100644 --- a/src/ui-spa/e2e-tests/journeys/shortPathDuplicateUrn.ts +++ b/src/ui-spa/e2e-tests/journeys/shortPathDuplicateUrn.ts @@ -1,5 +1,5 @@ import { expect, type Page } from "@playwright/test"; -import { CaseDetailsPage } from "../../integration-tests/pages/caseDetailsPage"; +import { CaseDetailsPage } from "../pages/caseDetailsPage"; import { generateUniqueUrn } from "../utils/generateUrn"; import { expectStep } from "../utils/expectStep"; import { @@ -28,12 +28,17 @@ export async function completeShortPathDuplicateUrn( freeUrn = generateUniqueUrn(); } + // Registering this first case is what makes the URN "existing"; if the + // generated reference was already taken, enterAreasAndCaseDetails moves + // existingUrn on to a free one, which the second visit below then reuses. await startAtHomePage(page, { operationName, hasSuspect: false }); await enterAreasAndCaseDetails(page, existingUrn); await completeAssigneeAndSubmit(page, existingUrn, operationName); await startAtHomePage(page, { operationName, hasSuspect: false }); - await enterAreasAndCaseDetails(page, existingUrn); + await enterAreasAndCaseDetails(page, existingUrn, { + expectDuplicateUrn: true, + }); const detailsPage = new CaseDetailsPage(page); await expectStep(page, "/case-registration/case-details"); @@ -42,7 +47,7 @@ export async function completeShortPathDuplicateUrn( "URN already exists, please change reference text and try again", ); await detailsPage.enterUrnUniqueReference(freeUrn.uniqueReference); - await detailsPage.saveAndContinue(); + await detailsPage.saveAndContinueWithFreeUrn(freeUrn); await completeMonitoringAndAssignee(page); await verifySummaryAndSubmit(page, freeUrn, { diff --git a/src/ui-spa/e2e-tests/journeys/steps.ts b/src/ui-spa/e2e-tests/journeys/steps.ts index bb0ceefba..5e1f5cfb4 100644 --- a/src/ui-spa/e2e-tests/journeys/steps.ts +++ b/src/ui-spa/e2e-tests/journeys/steps.ts @@ -1,7 +1,7 @@ import { expect, type Page } from "@playwright/test"; import { CaseRegistrationHomePage } from "../../integration-tests/pages/caseRegistrationHomePage"; import { CaseAreasPage } from "../../integration-tests/pages/caseAreasPage"; -import { CaseDetailsPage } from "../../integration-tests/pages/caseDetailsPage"; +import { CaseDetailsPage } from "../pages/caseDetailsPage"; import { CaseMonitoringPage } from "../../integration-tests/pages/caseMonitoringPage"; import { CaseAssigneePage } from "../../integration-tests/pages/caseAssigneePage"; import { CaseRegistrationSummaryPage } from "../../integration-tests/pages/caseRegistrationSummaryPage"; @@ -51,9 +51,16 @@ export async function startAtHomePage( await homePage.saveAndContinue(); } +export interface CaseDetailsOptions { + // Set when the caller has deliberately picked an already-registered URN and + // wants to assert the error itself, instead of moving on to a free one. + expectDuplicateUrn?: boolean; +} + export async function enterAreasAndCaseDetails( page: Page, urn: UrnParts, + { expectDuplicateUrn = false }: CaseDetailsOptions = {}, ): Promise { const areasPage = new CaseAreasPage(page); await expectStep(page, "/case-registration/areas"); @@ -62,13 +69,18 @@ export async function enterAreasAndCaseDetails( const detailsPage = new CaseDetailsPage(page); await expectStep(page, "/case-registration/case-details"); - await detailsPage.enterUrnPoliceForce(urn.policeForce); - await detailsPage.enterUrnPoliceUnit(urn.policeUnit); - await detailsPage.enterUrnUniqueReference(urn.uniqueReference); - await detailsPage.enterUrnYearReference(urn.yearReference); + await detailsPage.enterUrn(urn); await detailsPage.enterRegisteringUnit(REGISTERING_UNIT); await detailsPage.enterWitnessCareUnit(WITNESS_CARE_UNIT); - await detailsPage.saveAndContinue(); + + if (expectDuplicateUrn) { + await detailsPage.saveAndContinue(); + return; + } + + // Replaces the URN in place if the generated reference is already taken, so + // the journey carries on with the URN the case is actually registered under. + await detailsPage.saveAndContinueWithFreeUrn(urn); } export function watchAssigneeLookups(page: Page) { diff --git a/src/ui-spa/e2e-tests/pages/caseDetailsPage.ts b/src/ui-spa/e2e-tests/pages/caseDetailsPage.ts index 47b52c605..5873e5ba1 100644 --- a/src/ui-spa/e2e-tests/pages/caseDetailsPage.ts +++ b/src/ui-spa/e2e-tests/pages/caseDetailsPage.ts @@ -1,10 +1,16 @@ import { type Page, expect } from "@playwright/test"; import { CaseDetailsPage as IntegrationCaseDetailsPage } from "../../integration-tests/pages/caseDetailsPage"; -import { type UrnParts } from "../utils/generateUrn"; +import { refreshUniqueReference, type UrnParts } from "../utils/generateUrn"; const EXISTING_URN_ERROR = "URN already exists, please change reference text and try again"; +// A collision only costs one extra submit, so a handful of attempts is far more +// than the odds need; the cap is here to fail loudly rather than spin. +const MAX_URN_ATTEMPTS = 5; + +const URN_EXISTS_ENDPOINT = /\/api\/v1\/urns\/.+\/exists/; + // Reuses the integration case-details page object (identical selectors) and adds // an e2e-only assertion for the backend duplicate-URN check, which the MSW suite // can't exercise (its /urns/:urn/exists handler always returns false). @@ -23,14 +29,44 @@ export class CaseDetailsPage extends IntegrationCaseDetailsPage { await this.enterUrnYearReference(urn.yearReference); } - // Assumes the registering unit and WCU are already valid (the backend URN - // existence check only runs once client-side validation passes). Enters an - // already-registered URN, submits, and asserts the real error rendered from - // GET /api/v1/urns/{urn}/exists returning true. + // Submits case details, and if the URN turns out to be taken, swaps in a new + // reference and submits again. + // + // The generated reference collides with an already-registered case often + // enough to flake the suite. Rather than guessing from the rendered error, we + // read the boolean the page itself acts on: GET /api/v1/urns/{urn}/exists, + // which the page calls on every submit once client-side validation passes. + // That avoids mistaking a stale error summary from the previous attempt for a + // fresh collision, and it settles as soon as the backend answers. + // + // Assumes the registering unit and WCU are already filled in, since the + // existence check only runs after client-side validation passes. + async saveAndContinueWithFreeUrn(urn: UrnParts): Promise { + for (let attempt = 1; attempt <= MAX_URN_ATTEMPTS; attempt++) { + const existsResponse = this.currentPage.waitForResponse( + (r) => URN_EXISTS_ENDPOINT.test(r.url()) && r.ok(), + { timeout: 30_000 }, + ); + await this.saveAndContinue(); + const urnExists = (await (await existsResponse).json()) as boolean; + + if (!urnExists) return urn; + + refreshUniqueReference(urn); + await this.enterUrnUniqueReference(urn.uniqueReference); + } + + throw new Error( + `URN was already registered on ${MAX_URN_ATTEMPTS} consecutive attempts; last tried ${urn.formatted}`, + ); + } + + // Enters an already-registered URN, submits, and asserts the real error + // rendered from GET /api/v1/urns/{urn}/exists returning true. async submitAndExpectExistingUrnError(existingUrn: UrnParts) { await this.enterUrn(existingUrn); const existsResponse = this.currentPage.waitForResponse( - (r) => /\/api\/v1\/urns\/.+\/exists/.test(r.url()) && r.ok(), + (r) => URN_EXISTS_ENDPOINT.test(r.url()) && r.ok(), { timeout: 30_000 }, ); await this.saveAndContinue(); diff --git a/src/ui-spa/e2e-tests/utils/generateUrn.ts b/src/ui-spa/e2e-tests/utils/generateUrn.ts index 233a3ee05..91304dd4b 100644 --- a/src/ui-spa/e2e-tests/utils/generateUrn.ts +++ b/src/ui-spa/e2e-tests/utils/generateUrn.ts @@ -8,20 +8,40 @@ export interface UrnParts { formatted: string; } +// The police force, unit and year are fixed, so the 5-digit reference is the +// only thing keeping a URN unique - a space of 100,000 shared with every case +// already registered in the environment. Collisions are therefore expected +// rather than rare, and are resolved on submit by +// CaseDetailsPage.saveAndContinueWithFreeUrn. +const nextUniqueReference = (): string => { + const workerIndex = Number(process.env.TEST_WORKER_INDEX ?? "0"); + return String( + (Date.now() + workerIndex * 13 + randomInt(100000)) % 100000, + ).padStart(5, "0"); +}; + +const formatUrn = (parts: Omit): string => + `${parts.policeForce}${parts.policeUnit}${parts.uniqueReference}${parts.yearReference}`; + export const generateUniqueUrn = ( policeForce = "12", policeUnit = "21", year = "26", ): UrnParts => { - const workerIndex = Number(process.env.TEST_WORKER_INDEX ?? "0"); - const uniqueReference = String( - (Date.now() + workerIndex * 13 + randomInt(100000)) % 100000, - ).padStart(5, "0"); - return { + const parts = { policeForce, policeUnit, - uniqueReference, + uniqueReference: nextUniqueReference(), yearReference: year, - formatted: `${policeForce}${policeUnit}${uniqueReference}${year}`, }; + return { ...parts, formatted: formatUrn(parts) }; +}; + +// Swaps in a new reference in place, so callers holding the object (summary and +// confirmation assertions, later steps) see the URN the case was registered +// under rather than the one that turned out to be taken. +export const refreshUniqueReference = (urn: UrnParts): UrnParts => { + urn.uniqueReference = nextUniqueReference(); + urn.formatted = formatUrn(urn); + return urn; }; From 62cfeb185ba0615e573396c420fa5e3fe5eb8812 Mon Sep 17 00:00:00 2001 From: kmshrajcps Date: Thu, 24 Sep 2026 13:12:18 +0100 Subject: [PATCH 4/7] test(e2e): assert the Add action is the first offence results column (#252) The column move itself shipped in #229; this covers it against the real /api/v1/offences response, where previously only the MSW integration suite asserted the order. Scenario 21 drives a suspect journey to the offence search page and checks the rendered table: one ordered comparison over the header cells covers both halves of the acceptance criteria - Actions is first, and it comes before CJS code, Description, Statute name and section and Effective dates. Header order alone would still pass with the Add link moved to another cell, so it also asserts the link is in the first column and the CJS code is the column that follows it. Both assertions were falsified before being kept: expecting CJS code first fails on the header comparison, and looking for the Add link in the second cell fails with the element not found. --- .../offence-results-actions-column.spec.ts | 26 +++++++++++++++++ .../pages/chargesOffenceSearchPage.ts | 28 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/ui-spa/e2e-tests/offence-results-actions-column.spec.ts diff --git a/src/ui-spa/e2e-tests/offence-results-actions-column.spec.ts b/src/ui-spa/e2e-tests/offence-results-actions-column.spec.ts new file mode 100644 index 000000000..fc32f0f97 --- /dev/null +++ b/src/ui-spa/e2e-tests/offence-results-actions-column.spec.ts @@ -0,0 +1,26 @@ +import { test } from "@playwright/test"; +import { ChargesOffenceSearchPage } from "./pages/chargesOffenceSearchPage"; +import { generateUniqueUrn } from "./utils/generateUrn"; +import { expectStep } from "./utils/expectStep"; +import { + startSingleSuspectUpToCharges, + chargeDates, +} from "./journeys/suspectChargeSteps"; + +const OFFENCE_CODE = process.env.E2E_OFFENCE_CODE ?? "TH68040"; + +test("Scenario 21: the Add action is the first column of the offence search results", async ({ + page, +}) => { + const urn = generateUniqueUrn(); + const { arrestDate } = chargeDates(); + + await startSingleSuspectUpToCharges(page, { urn, arrestDate }); + + const offenceSearchPage = new ChargesOffenceSearchPage(page); + await expectStep( + page, + "/case-registration/suspect-0/charge-0/charges-offence-search", + ); + await offenceSearchPage.searchAndVerifyActionsColumnIsFirst(OFFENCE_CODE); +}); diff --git a/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts b/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts index b02d23f3a..26f5fdc1f 100644 --- a/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts +++ b/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts @@ -3,6 +3,14 @@ import { ChargesOffenceSearchPagePage as IntegrationChargesOffenceSearchPage } f const REPEALED_TAG_PREFIX = "offence-repealed-warning-"; +const RESULTS_COLUMNS = [ + "Actions", + "CJS code", + "Description", + "Statute name and section", + "Effective dates", +]; + interface Offence { code: string; description: string; @@ -50,6 +58,26 @@ export class ChargesOffenceSearchPage extends IntegrationChargesOffenceSearchPag ).toBeVisible(); } + async searchAndVerifyActionsColumnIsFirst( + offenceCode: string, + ): Promise { + await this.submitSearch(offenceCode); + + const resultsTable = this.resultsWrapper().getByRole("table"); + await expect(resultsTable).toBeVisible(); + + await expect(resultsTable.locator("th")).toHaveText(RESULTS_COLUMNS); + + const firstRowCells = resultsTable + .locator("tbody tr") + .first() + .locator("td"); + await expect( + firstRowCells.first().getByRole("link", { name: "Add" }), + ).toBeVisible(); + await expect(firstRowCells.nth(1)).toHaveText(offenceCode); + } + async searchAndAddFirstOffence(offenceCode: string): Promise { await this.submitSearch(offenceCode); await expect( From 59a169e81f90bdebf037ac17e3bbd5add0d13b55 Mon Sep 17 00:00:00 2001 From: kmshrajcps Date: Fri, 25 Sep 2026 10:22:09 +0100 Subject: [PATCH 5/7] test(e2e): assert the case registration carries the journey id and area (#254) The CaseRegisteredEvent enrichment shipped in #234; this covers it against the deployed SPA and real gateway API. Scenario 25 completes a short-path registration while recording every POST /api/v1/telemetry, and asserts: - exactly one JourneyStarted event is sent, with a v4 UUID journeyId - every page view carries that same journeyId - the POST /api/v1/cases payload carries that same journeyId, so the backend CaseRegisteredEvent correlates with JourneyStarted - areaOrDivisionText is the area chosen on the areas page --- .../case-registered-journey-id.spec.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/ui-spa/e2e-tests/case-registered-journey-id.spec.ts diff --git a/src/ui-spa/e2e-tests/case-registered-journey-id.spec.ts b/src/ui-spa/e2e-tests/case-registered-journey-id.spec.ts new file mode 100644 index 000000000..bcbd2ebab --- /dev/null +++ b/src/ui-spa/e2e-tests/case-registered-journey-id.spec.ts @@ -0,0 +1,76 @@ +import { expect, test, type Request } from "@playwright/test"; +import { generateUniqueUrn } from "./utils/generateUrn"; +import { + AREA, + startAtHomePage, + enterAreasAndCaseDetails, + completeAssigneeAndSubmit, +} from "./journeys/steps"; + +const OPERATION_NAME = process.env.E2E_OPERATION_NAME ?? "thunderstruck"; +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +interface TelemetryPayload { + telemetryType: string; + properties: Record[]; +} + +const toProperties = (payload: TelemetryPayload): Record => + Object.assign({}, ...payload.properties); + +test("Scenario 25: the case registration carries the journey id of the JourneyStarted event and the chosen area", async ({ + page, +}) => { + const telemetry: TelemetryPayload[] = []; + page.on("request", (request: Request) => { + if ( + request.url().endsWith("/api/v1/telemetry") && + request.method() === "POST" + ) { + telemetry.push(JSON.parse(request.postData() ?? "null")); + } + }); + + const urn = generateUniqueUrn(); + await startAtHomePage(page, { + operationName: OPERATION_NAME, + hasSuspect: false, + }); + await enterAreasAndCaseDetails(page, urn); + + const casesRequest = page.waitForRequest( + (request) => + request.url().endsWith("/api/v1/cases") && request.method() === "POST", + { timeout: 120_000 }, + ); + await completeAssigneeAndSubmit(page, urn, OPERATION_NAME); + + const journeyStarted = telemetry + .map(toProperties) + .filter((properties) => properties.name === "JourneyStarted"); + expect( + journeyStarted, + "expected exactly one JourneyStarted event", + ).toHaveLength(1); + const journeyId = journeyStarted[0].journeyId; + expect(journeyId, "JourneyStarted journeyId is not a v4 UUID").toMatch(UUID); + + const pageViews = telemetry + .filter((payload) => payload.telemetryType === "PageView") + .map(toProperties); + expect(pageViews.length, "no page views were tracked").toBeGreaterThan(0); + for (const pageView of pageViews) { + expect(pageView.journeyId, `page view ${pageView.pageName}`).toBe( + journeyId, + ); + } + + const payload = JSON.parse((await casesRequest).postData() ?? "null") as { + journeyId?: string; + areaOrDivisionText?: string; + } | null; + expect(payload, "no POST /api/v1/cases payload was captured").not.toBeNull(); + expect(payload!.journeyId).toBe(journeyId); + expect(payload!.areaOrDivisionText).toBe(AREA); +}); From 53ac0309e7baaa26f9e11ef4e8744233bdaa4bc3 Mon Sep 17 00:00:00 2001 From: kmshrajcps Date: Mon, 28 Sep 2026 12:30:04 +0100 Subject: [PATCH 6/7] =?UTF-8?q?test(e2e):=20assert=20journey=20start,=20st?= =?UTF-8?q?ep=20page=20views=20and=20cancellation=20tel=E2=80=A6=20(#256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(e2e): assert journey start, step page views and cancellation telemetry The journey tracking shipped in #239; this covers it against the deployed SPA and real gateway API. Scenario 26 saves the Home screen, moves through Case Areas to Case Details, then cancels the registration, recording every POST /api/v1/telemetry. It asserts: - exactly one JourneyStarted event is sent, with a v4 UUID journeyId, and it is not sent again later in the journey - the page views are exactly Home, Case Areas, Case Details and Cancel Case Registration Confirmation, each with its step name, path and the same journeyId - JourneyCancelled carries that journeyId and cancelledFrom /case-registration/case-details, and the backend accepts it even though the page leaves the SPA straight after The Home page view is only sent once the user leaves Home, because that is when the journeyId is created, so it is asserted as the first view. Request order is not asserted: trackEvent waits for a token before sending, so JourneyStarted and the first page view can arrive either way. * test(e2e): fail clearly on bad telemetry bodies and ignore page view arrival order --- src/ui-spa/e2e-tests/journey-tracking.spec.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/ui-spa/e2e-tests/journey-tracking.spec.ts diff --git a/src/ui-spa/e2e-tests/journey-tracking.spec.ts b/src/ui-spa/e2e-tests/journey-tracking.spec.ts new file mode 100644 index 000000000..62eed757b --- /dev/null +++ b/src/ui-spa/e2e-tests/journey-tracking.spec.ts @@ -0,0 +1,122 @@ +import { expect, test, type Request } from "@playwright/test"; +import { CaseAreasPage } from "../integration-tests/pages/caseAreasPage"; +import { CancelCaseRegistrationConfirmationPage } from "../integration-tests/pages/cancelCaseRegistrationConfirmationPage"; +import { AREA, startAtHomePage } from "./journeys/steps"; +import { expectStep } from "./utils/expectStep"; + +const OPERATION_NAME = process.env.E2E_OPERATION_NAME ?? "thunderstruck"; +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +type Properties = Record; + +interface TelemetryCall { + telemetryType: string; + properties: Properties; +} + +const toCall = (request: Request): TelemetryCall => { + const postData = request.postData(); + expect(postData, "telemetry POST was sent without a body").toBeTruthy(); + const body = JSON.parse(postData!) as { + telemetryType?: string; + properties?: Properties[]; + } | null; + expect( + Array.isArray(body?.properties), + `telemetry POST has no properties array: ${postData}`, + ).toBe(true); + return { + telemetryType: body!.telemetryType ?? "", + properties: Object.assign({}, ...body!.properties!), + }; +}; + +test("Scenario 26: the journey starts past the first screen, tracks each step and logs the cancellation", async ({ + page, +}) => { + const requests: Request[] = []; + page.on("request", (request) => { + if ( + request.url().endsWith("/api/v1/telemetry") && + request.method() === "POST" + ) { + requests.push(request); + } + }); + const telemetry = () => requests.map(toCall); + const events = (name: string) => + telemetry().filter( + (call) => call.telemetryType === "Event" && call.properties.name === name, + ); + const pageViews = () => + telemetry().filter((call) => call.telemetryType === "PageView"); + + await startAtHomePage(page, { + operationName: OPERATION_NAME, + hasSuspect: false, + }); + await expectStep(page, "/case-registration/areas"); + + await expect.poll(() => events("JourneyStarted").length).toBe(1); + const journeyId = events("JourneyStarted")[0].properties.journeyId; + expect(journeyId, "JourneyStarted journeyId is not a v4 UUID").toMatch(UUID); + + const areasPage = new CaseAreasPage(page); + await areasPage.enterAreaOrDivision(AREA); + await areasPage.saveAndContinue(); + await expectStep(page, "/case-registration/case-details"); + + await page.getByRole("link", { name: "Cancel" }).click(); + await expectStep( + page, + "/case-registration/cancel-case-registration-confirmation", + ); + + const expectedPageViews = [ + { pageName: "Home", path: "/case-registration" }, + { pageName: "Case Areas", path: "/case-registration/areas" }, + { pageName: "Case Details", path: "/case-registration/case-details" }, + { + pageName: "Cancel Case Registration Confirmation", + path: "/case-registration/cancel-case-registration-confirmation", + }, + ]; + const byPath = (a: Properties, b: Properties) => + String(a.path).localeCompare(String(b.path)); + await expect.poll(() => pageViews().length).toBe(expectedPageViews.length); + expect( + pageViews() + .map(({ properties }) => properties) + .sort(byPath), + "page views missing, duplicated, or carrying the wrong journeyId", + ).toEqual( + expectedPageViews.map((view) => ({ ...view, journeyId })).sort(byPath), + ); + + const cancelPage = new CancelCaseRegistrationConfirmationPage(page); + await cancelPage.selectCancelCaseRegistrationYes(); + const cancelledResponse = page.waitForResponse( + (response) => + response.url().endsWith("/api/v1/telemetry") && + response.request().postData()?.includes("JourneyCancelled") === true, + ); + await cancelPage.continue(); + + await expect.poll(() => events("JourneyCancelled").length).toBe(1); + expect(events("JourneyCancelled")[0].properties).toEqual({ + name: "JourneyCancelled", + journeyId, + cancelledFrom: "/case-registration/case-details", + }); + const response = await cancelledResponse; + expect( + response.ok(), + `JourneyCancelled telemetry returned ${response.status()}`, + ).toBe(true); + + expect( + events("JourneyStarted"), + "the journey was started twice", + ).toHaveLength(1); +}); From 522d30bfe122f97d24b7d7e2760ab1d0cb35c921 Mon Sep 17 00:00:00 2001 From: Lilach Date: Mon, 28 Sep 2026 14:44:24 +0100 Subject: [PATCH 7/7] Subtask/FCT2-22142 add separate test flow for PRs from development to main (#258) --- .../backend/backend-dev-build-and-deploy.yml | 12 ------ .../backend/backend-pr-build-and-test.yml | 40 +++++++++++++++---- devops-pipelines/ui/ui-pr-build-and-test.yml | 34 +++++++++++++--- 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/devops-pipelines/backend/backend-dev-build-and-deploy.yml b/devops-pipelines/backend/backend-dev-build-and-deploy.yml index 1c47e39f9..ae917db62 100644 --- a/devops-pipelines/backend/backend-dev-build-and-deploy.yml +++ b/devops-pipelines/backend/backend-dev-build-and-deploy.yml @@ -38,15 +38,3 @@ stages: dependsOn: "Build" environment: "dev" buildArtifact: $(buildArtifact) - - - stage: Run_Postman_Tests_dev - displayName: "Dev - Run Postman Tests" - dependsOn: "Deploy_Backend_dev" # final stage of /templates/backend-deploy-stages.yml - pool: "CMRC PreProd Pool" - jobs: - - template: ../templates/postman-test-job.yml - parameters: - appName: "fa-cmrc-api-dev" - environment: "dev" - postmanCollectionPath: "$(System.DefaultWorkingDirectory)/src/backend/postman/RegisterCaseAPITests.postman_collection.json" - nodeVersion: "24.x" diff --git a/devops-pipelines/backend/backend-pr-build-and-test.yml b/devops-pipelines/backend/backend-pr-build-and-test.yml index 615d379f8..7c6d04819 100644 --- a/devops-pipelines/backend/backend-pr-build-and-test.yml +++ b/devops-pipelines/backend/backend-pr-build-and-test.yml @@ -11,25 +11,49 @@ pr: - src/tests/* variables: - buildConfiguration: 'Release' - dotNetVersion: '10.x' - testResultsDirectory: '$(Agent.TempDirectory)/TestResults' + - name: prSourceBranch + value: $[variables['System.PullRequest.SourceBranch']] + - name: prTargetBranch + value: $[variables['System.PullRequest.TargetBranch']] -pool: 'CMRC PreProd Pool' +pool: "CMRC PreProd Pool" stages: - stage: Build_And_Test_Backend - displayName: 'Build, Test & Package' + condition: not(and( + eq(variables.prSourceBranch, 'development' ), + eq(variables.prTargetBranch, 'main') + )) + displayName: "Build, Test & Package" + variables: + buildConfiguration: "Release" + dotNetVersion: "10.x" + testResultsDirectory: "$(Agent.TempDirectory)/TestResults" jobs: - job: Build_And_Test_Solution - displayName: 'Build & Test Solution' + displayName: "Build & Test Solution" steps: - template: ../templates/dotnet-build-steps.yml parameters: - projectPath: 'src/backend/Cps.CaseManagement.sln' - projectName: 'Backend Solution' + projectPath: "src/backend/Cps.CaseManagement.sln" + projectName: "Backend Solution" dotNetVersion: $(dotNetVersion) publishOutput: false runTests: true checkFormatting: true testResultsDirectory: $(testResultsDirectory) + + - stage: Run_Postman_Tests + condition: and( + eq(variables.prSourceBranch, 'development' ), + eq(variables.prTargetBranch, 'main' ) + ) + dependsOn: [] + displayName: "Run Postman Tests on Dev" + jobs: + - template: ../templates/postman-test-job.yml + parameters: + appName: "fa-cmrc-api-dev" + environment: "dev" + postmanCollectionPath: "$(System.DefaultWorkingDirectory)/src/backend/postman/RegisterCaseAPITests.postman_collection.json" + nodeVersion: "24.x" diff --git a/devops-pipelines/ui/ui-pr-build-and-test.yml b/devops-pipelines/ui/ui-pr-build-and-test.yml index fc95665fb..b84385e1a 100644 --- a/devops-pipelines/ui/ui-pr-build-and-test.yml +++ b/devops-pipelines/ui/ui-pr-build-and-test.yml @@ -12,16 +12,25 @@ pr: pool: 'CMRC PreProd Pool' variables: - - name: nodeVersion - value: '24.x' - - name: workingDir - value: src/ui-spa - - name: covDir - value: 'coverage' + - name: prSourceBranch + value: $[variables['System.PullRequest.SourceBranch']] + - name: prTargetBranch + value: $[variables['System.PullRequest.TargetBranch']] stages: - stage: Build_and_Test_UI + condition: not(and( + eq(variables.prSourceBranch, 'development' ), + eq(variables.prTargetBranch, 'main') + )) displayName: 'Build & Test UI SPA' + variables: + - name: nodeVersion + value: '24.x' + - name: workingDir + value: src/ui-spa + - name: covDir + value: 'coverage' jobs: - job: Build_and_Test displayName: 'Build & Test App' @@ -148,3 +157,16 @@ stages: inputs: summaryFileLocation: '$(workingDir)/$(covDir)/merged/Cobertura.xml' failIfCoverageEmpty: true + + # On PR development -> main, run the E2E tests in dev + - stage: Run_E2E_tests + condition: and( + eq(variables.prSourceBranch, 'development' ), + eq(variables.prTargetBranch, 'main' ) + ) + dependsOn: [] + displayName: "Run E2E Test on Dev" + jobs: + - template: ../templates/e2e-playwright-test-job.yml + parameters: + environment: "dev"