diff --git a/.gitignore b/.gitignore index 22d16f4a..ead9f57f 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 69187f60..8dfa4a03 100644 --- a/devops-pipelines/backend/backend-build-and-deploy.yml +++ b/devops-pipelines/backend/backend-build-and-deploy.yml @@ -10,71 +10,78 @@ 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 - + - template: ../templates/backend-deploy-stages.yml parameters: - buildStageName: 'Build' - environment: 'dev' + buildStageName: "Build" + environment: "dev" requireValidation: false 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' + 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' + - 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" - template: ../templates/backend-deploy-stages.yml parameters: - buildStageName: 'Build' - environment: 'staging' + buildStageName: "Build" + environment: "staging" requireValidation: true notifyUsers: $(preprodNotifyUsers) approvers: $(preprodApprovers) deployToSlot: true - slotName: 'stg' + slotName: "stg" buildArtifact: $(buildArtifact) + - stage: Run_E2E_Tests + displayName: "Run E2E tests" + jobs: + - template: ../templates/e2e-playwright-test-job.yml + parameters: + environment: "staging" + - template: ../templates/backend-deploy-stages.yml parameters: - buildStageName: 'Build' - environment: 'prod' + buildStageName: "Build" + environment: "prod" requireValidation: true notifyUsers: $(prodNotifyUsers) approvers: $(prodApprovers) - agentPool: 'CMRC Prod Pool' - azureSubscription: 'Azure Pipeline: CMRC - Prod' + agentPool: "CMRC Prod Pool" + azureSubscription: "Azure Pipeline: CMRC - Prod" deployToSlot: true - slotName: 'stg' - buildArtifact: $(buildArtifact) \ No newline at end of file + slotName: "stg" + buildArtifact: $(buildArtifact) 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 00000000..522c71bb --- /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/ui/ui-build-and-deploy.yml b/devops-pipelines/ui/ui-build-and-deploy.yml index 91de3f55..f3a920bf 100644 --- a/devops-pipelines/ui/ui-build-and-deploy.yml +++ b/devops-pipelines/ui/ui-build-and-deploy.yml @@ -14,25 +14,32 @@ variables: stages: - template: ../templates/ui-build-and-deploy-stages.yml parameters: - environment: 'dev' + environment: "dev" requireValidation: false - template: ../templates/ui-build-and-deploy-stages.yml parameters: - environment: 'staging' + environment: "staging" requireValidation: true notifyUsers: $(preprodNotifyUsers) approvers: $(preprodApprovers) deployToSlot: true - slotName: 'stg' - + slotName: "stg" + + - stage: Run_E2E_Tests + displayName: "Run E2E tests" + jobs: + - template: ../templates/e2e-playwright-test-job.yml + parameters: + environment: "staging" + - template: ../templates/ui-build-and-deploy-stages.yml parameters: - environment: 'prod' + environment: "prod" requireValidation: true notifyUsers: $(prodNotifyUsers) approvers: $(prodApprovers) - agentPool: 'CMRC Prod Pool' - azureSubscription: 'Azure Pipeline: CMRC - Prod' + agentPool: "CMRC Prod Pool" + azureSubscription: "Azure Pipeline: CMRC - Prod" deployToSlot: true - slotName: 'stg' + slotName: "stg" diff --git a/src/backend/Cps.CaseManagement.Api/Constants/CaseRegistrationDefaults.cs b/src/backend/Cps.CaseManagement.Api/Constants/CaseRegistrationDefaults.cs index f5a6e5cd..1ccd3494 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 90313dc9..2831d465 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 4a361736..a44137d6 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 7fee3b48..265c4894 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 e4a07ac5..0fa6d6d1 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 0fb1a544..e193f95b 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 3bfd9ba4..bafa0456 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 9a06ab89..8a2a8554 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 9bc4f9b9..e026aff0 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 4f6ca17c..a9753757 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 4151744a..16585d21 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 4783dd92..375a12b2 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 00000000..62f2b104 --- /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 00000000..cb835b74 --- /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 adcd6d2b..7b77494e 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 00000000..4e037039 --- /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 c217899e..3601757c 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 00000000..639579e9 --- /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 25596d87..b02d23f3 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 e3f8a271..873f9635 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 00000000..ad79a4e9 --- /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 00000000..889e544c --- /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 00000000..26e43c66 --- /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 5ade92ea..496b583e 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 ea980993..e9e0d7e7 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 00000000..21028f1f --- /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 00000000..b5f3dd7e --- /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 00000000..41d54514 --- /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 00000000..0cdb4fb2 --- /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 00000000..69d73269 --- /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 f120994b..f9c6bc15 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 c376b064..e5e706a3 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 2c039823..181fef91 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 78e1b52f..8296ea50 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 80aab9dc..f6152a58 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 9d10e8ef..4b0a90c2 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 5cf1b80b..cb20b609 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 6c977621..761fc478 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 f0ac1c8d..371d91f5 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 c24f2b4b..f0e5030a 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 00000000..1146a89e --- /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 00000000..66953656 --- /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 00000000..cde276e6 --- /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 55ed4a13..34bdb5d5 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 1f26aff9..6de25bb9 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(),