From d495f7fbb3e36c0b3722e6f267bdc886ecdb21cb Mon Sep 17 00:00:00 2001 From: Rhys Bridges Date: Wed, 16 Sep 2026 13:54:27 +0100 Subject: [PATCH 1/7] tracking of events using journey id (#234) * tracking of events using journey id * pr comments --- .../Constants/CaseRegistrationDefaults.cs | 5 ++ .../Functions/RegisterCase.cs | 8 ++- .../TelemetryEvents/CaseRegisteredEvent.cs | 8 ++- .../CaseRegistrationRequestValidator.cs | 2 + .../Factories/MdsRequestFactory.cs | 37 ++++++++++- .../Entities/CaseRegistrationRequest.cs | 6 ++ .../Unit/Functions/RegisterCaseTests.cs | 39 +++++++++++- .../CaseRegisteredEventTests.cs | 24 ++++++- .../CaseRegistrationRequestTests.cs | 36 +++++++++++ .../Factories/MdsRequestFactoryTests.cs | 63 +++++++++++++++++++ 10 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 src/tests/Cps.CaseManagement.MdsClient.Tests/Factories/MdsRequestFactoryTests.cs 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.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.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); + } +} From c96d8961a20b0c13b5f7793d848300403c98397c Mon Sep 17 00:00:00 2001 From: Renjith Abby Date: Wed, 16 Sep 2026 15:45:14 +0100 Subject: [PATCH 2/7] 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 --- src/ui-spa/src/TelemetryLogger.test.ts | 93 +++++++++++++++++++++++++ src/ui-spa/src/TelemetryLogger.ts | 75 ++++++++++++++++++++ src/ui-spa/src/apis/gateway-api.test.ts | 77 +++++++++++++++++++- src/ui-spa/src/apis/gateway-api.ts | 26 +++++++ 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/ui-spa/src/TelemetryLogger.test.ts create mode 100644 src/ui-spa/src/TelemetryLogger.ts diff --git a/src/ui-spa/src/TelemetryLogger.test.ts b/src/ui-spa/src/TelemetryLogger.test.ts new file mode 100644 index 000000000..5d17d8433 --- /dev/null +++ b/src/ui-spa/src/TelemetryLogger.test.ts @@ -0,0 +1,93 @@ +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" } as any]); + + expect(logTelemetryEvent).toHaveBeenCalledTimes(1); + const sent = getSentPayload(); + + const expected = { + telemetryType: TelemetryType.PageView, + properties: [{ pageName: "HomePage" }, { foo: "bar" }], + }; + + 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..30737ccff --- /dev/null +++ b/src/ui-spa/src/TelemetryLogger.ts @@ -0,0 +1,75 @@ +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 }]; +}; +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, + ); + } +}; From 718166b381d662f65a682525f988cad630d9eff4 Mon Sep 17 00:00:00 2001 From: Lilach Date: Wed, 16 Sep 2026 16:04:37 +0100 Subject: [PATCH 3/7] Task/FCT2-19358 integrate e2e test suite into cicd (#237) --- .gitignore | 2 +- .../backend/backend-build-and-deploy.yml | 71 ++++++++++--------- .../templates/e2e-playwright-test-job.yml | 62 ++++++++++++++++ devops-pipelines/ui/ui-build-and-deploy.yml | 23 +++--- src/ui-spa/e2e-tests/config.ts | 6 +- src/ui-spa/e2e-tests/playwright.e2e.config.ts | 6 +- 6 files changed, 124 insertions(+), 46 deletions(-) create mode 100644 devops-pipelines/templates/e2e-playwright-test-job.yml 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..8dfa4a034 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 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/ui/ui-build-and-deploy.yml b/devops-pipelines/ui/ui-build-and-deploy.yml index 91de3f556..f3a920bf2 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/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/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: [ From 7ec37cd351ed30a4e4f2f1d3baf67404ddf3e69a Mon Sep 17 00:00:00 2001 From: Rhys Bridges Date: Fri, 18 Sep 2026 11:42:38 +0100 Subject: [PATCH 4/7] telemetry tracking properties (#240) * telemetry tracking properties * pr comment fixes --- .../Telemetry/AppInsightsTelemetryClient.cs | 24 ++- .../Telemetry/BaseUiTelemetryEvent.cs | 13 +- .../Telemetry/IAppInsightsTelemetryClient.cs | 4 +- .../Telemetry/TelemetryClient.cs | 12 +- .../AppInsightsTelemetryClientTests.cs | 87 +++++++++++ .../Telemetry/BaseUiTelemetryEventTests.cs | 59 ++++++++ .../Unit/Telemetry/TelemetryClientTests.cs | 140 +++++++++++++++++- 7 files changed, 323 insertions(+), 16 deletions(-) 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 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/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!); + } + } } From d916293b6cf6ead290324accc7dc0ba494be8eb8 Mon Sep 17 00:00:00 2001 From: Renjith Abby Date: Fri, 18 Sep 2026 15:00:27 +0100 Subject: [PATCH 5/7] 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 --- src/ui-spa/src/TelemetryLogger.test.ts | 7 +- src/ui-spa/src/TelemetryLogger.ts | 3 +- src/ui-spa/src/common/constants/pageTitles.ts | 38 ++++++ .../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 | 2 + .../index.tsx | 16 ++- .../case-summary-page/index.tsx | 1 + .../components/case-registration/index.tsx | 11 ++ src/ui-spa/src/mocks/handlers.ts | 4 + .../src/schemas/requests/caseRegistration.ts | 2 + 16 files changed, 287 insertions(+), 44 deletions(-) create mode 100644 src/ui-spa/src/common/constants/pageTitles.ts create mode 100644 src/ui-spa/src/common/hooks/usePageView.test.tsx create mode 100644 src/ui-spa/src/common/hooks/usePageView.tsx diff --git a/src/ui-spa/src/TelemetryLogger.test.ts b/src/ui-spa/src/TelemetryLogger.test.ts index 5d17d8433..889e544cf 100644 --- a/src/ui-spa/src/TelemetryLogger.test.ts +++ b/src/ui-spa/src/TelemetryLogger.test.ts @@ -37,14 +37,17 @@ describe("TelemetryService", () => { }); it("trackPageView sends a PageView payload with pageName and additional props", async () => { - await telemetryService.trackPageView("HomePage", [{ foo: "bar" } as any]); + 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" }], + properties: [{ pageName: "HomePage" }, { foo: "bar" }, { id: "123" }], }; expect(sent).toEqual(expected); diff --git a/src/ui-spa/src/TelemetryLogger.ts b/src/ui-spa/src/TelemetryLogger.ts index 30737ccff..26e43c663 100644 --- a/src/ui-spa/src/TelemetryLogger.ts +++ b/src/ui-spa/src/TelemetryLogger.ts @@ -15,13 +15,14 @@ export type TelemetryPayload = { 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 type TelemetryPageViewProps = Record[]; export class TelemetryService { async trackEvent( 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/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..21ac7c9cf 100644 --- a/src/ui-spa/src/components/AppRoutes.tsx +++ b/src/ui-spa/src/components/AppRoutes.tsx @@ -33,8 +33,10 @@ 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 usePageView from "../common/hooks/usePageView"; const AppRoutes = () => { + usePageView(); return ( } /> 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.tsx b/src/ui-spa/src/components/case-registration/index.tsx index c24f2b4b3..a0978ae4a 100644 --- a/src/ui-spa/src/components/case-registration/index.tsx +++ b/src/ui-spa/src/components/case-registration/index.tsx @@ -15,6 +15,8 @@ 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 styles from "./index.module.scss"; const CaseRegistrationPage = () => { @@ -275,6 +277,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"; 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 74cb0447f767232b36dfc1bff8d9e943c374debb Mon Sep 17 00:00:00 2001 From: Renjith Abby Date: Tue, 22 Sep 2026 17:34:19 +0100 Subject: [PATCH 6/7] story/FCT2-22092- Unauthorised page (#241) * handled 401 error on initial page load * added unit tests * fixed the pr comments * minor refactor --- .../unauthorised-page.spec.ts | 68 ++++++++++++ .../src/common/hooks/useAuthedQuery.test.tsx | 105 ++++++++++++++++++ .../src/common/hooks/useAuthedQuery.tsx | 29 +++++ src/ui-spa/src/components/AppRoutes.tsx | 2 + .../case-registration/index.module.scss | 10 ++ .../components/case-registration/index.tsx | 55 +++++---- .../src/components/common/Spinner.module.scss | 17 +++ src/ui-spa/src/components/common/Spinner.tsx | 13 +++ .../src/components/unauthorised/index.tsx | 16 +++ 9 files changed, 285 insertions(+), 30 deletions(-) create mode 100644 src/ui-spa/integration-tests/unauthorised-page.spec.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/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/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/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/components/AppRoutes.tsx b/src/ui-spa/src/components/AppRoutes.tsx index 21ac7c9cf..4b0a90c26 100644 --- a/src/ui-spa/src/components/AppRoutes.tsx +++ b/src/ui-spa/src/components/AppRoutes.tsx @@ -33,6 +33,7 @@ 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 = () => { @@ -167,6 +168,7 @@ const AppRoutes = () => { path="/case-registration/cancel-case-registration-confirmation" element={} /> + } /> } /> ); 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 a0978ae4a..f0e5030ad 100644 --- a/src/ui-spa/src/components/case-registration/index.tsx +++ b/src/ui-spa/src/components/case-registration/index.tsx @@ -8,7 +8,7 @@ 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"; @@ -17,6 +17,7 @@ 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 = () => { @@ -46,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({}); @@ -163,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({ @@ -347,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; From 3ace88ffb05a54e5488830afcf765fb74036263b Mon Sep 17 00:00:00 2001 From: kmshrajcps Date: Wed, 23 Sep 2026 10:37:11 +0100 Subject: [PATCH 7/7] 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. --- .../offence-results-repealed-tag.spec.ts | 26 +++++++++ .../pages/chargesOffenceSearchPage.ts | 58 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/ui-spa/e2e-tests/offence-results-repealed-tag.spec.ts 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); + } + } }