Skip to content
Merged
12 changes: 0 additions & 12 deletions devops-pipelines/backend/backend-dev-build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,3 @@ stages:
dependsOn: "Build"
environment: "dev"
buildArtifact: $(buildArtifact)

- stage: Run_Postman_Tests_dev
displayName: "Dev - Run Postman Tests"
dependsOn: "Deploy_Backend_dev" # final stage of /templates/backend-deploy-stages.yml
pool: "CMRC PreProd Pool"
jobs:
- template: ../templates/postman-test-job.yml
parameters:
appName: "fa-cmrc-api-dev"
environment: "dev"
postmanCollectionPath: "$(System.DefaultWorkingDirectory)/src/backend/postman/RegisterCaseAPITests.postman_collection.json"
nodeVersion: "24.x"
40 changes: 32 additions & 8 deletions devops-pipelines/backend/backend-pr-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,49 @@ pr:
- src/tests/*

variables:
buildConfiguration: 'Release'
dotNetVersion: '10.x'
testResultsDirectory: '$(Agent.TempDirectory)/TestResults'
- name: prSourceBranch
value: $[variables['System.PullRequest.SourceBranch']]
- name: prTargetBranch
value: $[variables['System.PullRequest.TargetBranch']]

pool: 'CMRC PreProd Pool'
pool: "CMRC PreProd Pool"

stages:
- stage: Build_And_Test_Backend
displayName: 'Build, Test & Package'
condition: not(and(
eq(variables.prSourceBranch, 'development' ),
eq(variables.prTargetBranch, 'main')
))
displayName: "Build, Test & Package"
variables:
buildConfiguration: "Release"
dotNetVersion: "10.x"
testResultsDirectory: "$(Agent.TempDirectory)/TestResults"
jobs:
- job: Build_And_Test_Solution
displayName: 'Build & Test Solution'
displayName: "Build & Test Solution"
steps:
- template: ../templates/dotnet-build-steps.yml
parameters:
projectPath: 'src/backend/Cps.CaseManagement.sln'
projectName: 'Backend Solution'
projectPath: "src/backend/Cps.CaseManagement.sln"
projectName: "Backend Solution"
dotNetVersion: $(dotNetVersion)
publishOutput: false
runTests: true
checkFormatting: true
testResultsDirectory: $(testResultsDirectory)

- stage: Run_Postman_Tests
condition: and(
eq(variables.prSourceBranch, 'development' ),
eq(variables.prTargetBranch, 'main' )
)
dependsOn: []
displayName: "Run Postman Tests on Dev"
jobs:
- template: ../templates/postman-test-job.yml
parameters:
appName: "fa-cmrc-api-dev"
environment: "dev"
postmanCollectionPath: "$(System.DefaultWorkingDirectory)/src/backend/postman/RegisterCaseAPITests.postman_collection.json"
nodeVersion: "24.x"
34 changes: 28 additions & 6 deletions devops-pipelines/ui/ui-pr-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,25 @@ pr:
pool: 'CMRC PreProd Pool'

variables:
- name: nodeVersion
value: '24.x'
- name: workingDir
value: src/ui-spa
- name: covDir
value: 'coverage'
- name: prSourceBranch
value: $[variables['System.PullRequest.SourceBranch']]
- name: prTargetBranch
value: $[variables['System.PullRequest.TargetBranch']]

stages:
- stage: Build_and_Test_UI
condition: not(and(
eq(variables.prSourceBranch, 'development' ),
eq(variables.prTargetBranch, 'main')
))
displayName: 'Build & Test UI SPA'
variables:
- name: nodeVersion
value: '24.x'
- name: workingDir
value: src/ui-spa
- name: covDir
value: 'coverage'
jobs:
- job: Build_and_Test
displayName: 'Build & Test App'
Expand Down Expand Up @@ -148,3 +157,16 @@ stages:
inputs:
summaryFileLocation: '$(workingDir)/$(covDir)/merged/Cobertura.xml'
failIfCoverageEmpty: true

# On PR development -> main, run the E2E tests in dev
- stage: Run_E2E_tests
condition: and(
eq(variables.prSourceBranch, 'development' ),
eq(variables.prTargetBranch, 'main' )
)
dependsOn: []
displayName: "Run E2E Test on Dev"
jobs:
- template: ../templates/e2e-playwright-test-job.yml
parameters:
environment: "dev"
76 changes: 76 additions & 0 deletions src/ui-spa/e2e-tests/case-registered-journey-id.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { expect, test, type Request } from "@playwright/test";
import { generateUniqueUrn } from "./utils/generateUrn";
import {
AREA,
startAtHomePage,
enterAreasAndCaseDetails,
completeAssigneeAndSubmit,
} from "./journeys/steps";

const OPERATION_NAME = process.env.E2E_OPERATION_NAME ?? "thunderstruck";
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;

interface TelemetryPayload {
telemetryType: string;
properties: Record<string, unknown>[];
}

const toProperties = (payload: TelemetryPayload): Record<string, unknown> =>
Object.assign({}, ...payload.properties);

test("Scenario 25: the case registration carries the journey id of the JourneyStarted event and the chosen area", async ({
page,
}) => {
const telemetry: TelemetryPayload[] = [];
page.on("request", (request: Request) => {
if (
request.url().endsWith("/api/v1/telemetry") &&
request.method() === "POST"
) {
telemetry.push(JSON.parse(request.postData() ?? "null"));
}
});

const urn = generateUniqueUrn();
await startAtHomePage(page, {
operationName: OPERATION_NAME,
hasSuspect: false,
});
await enterAreasAndCaseDetails(page, urn);

const casesRequest = page.waitForRequest(
(request) =>
request.url().endsWith("/api/v1/cases") && request.method() === "POST",
{ timeout: 120_000 },
);
await completeAssigneeAndSubmit(page, urn, OPERATION_NAME);

const journeyStarted = telemetry
.map(toProperties)
.filter((properties) => properties.name === "JourneyStarted");
expect(
journeyStarted,
"expected exactly one JourneyStarted event",
).toHaveLength(1);
const journeyId = journeyStarted[0].journeyId;
expect(journeyId, "JourneyStarted journeyId is not a v4 UUID").toMatch(UUID);

const pageViews = telemetry
.filter((payload) => payload.telemetryType === "PageView")
.map(toProperties);
expect(pageViews.length, "no page views were tracked").toBeGreaterThan(0);
for (const pageView of pageViews) {
expect(pageView.journeyId, `page view ${pageView.pageName}`).toBe(
journeyId,
);
}

const payload = JSON.parse((await casesRequest).postData() ?? "null") as {
journeyId?: string;
areaOrDivisionText?: string;
} | null;
expect(payload, "no POST /api/v1/cases payload was captured").not.toBeNull();
expect(payload!.journeyId).toBe(journeyId);
expect(payload!.areaOrDivisionText).toBe(AREA);
});
122 changes: 122 additions & 0 deletions src/ui-spa/e2e-tests/journey-tracking.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { expect, test, type Request } from "@playwright/test";
import { CaseAreasPage } from "../integration-tests/pages/caseAreasPage";
import { CancelCaseRegistrationConfirmationPage } from "../integration-tests/pages/cancelCaseRegistrationConfirmationPage";
import { AREA, startAtHomePage } from "./journeys/steps";
import { expectStep } from "./utils/expectStep";

const OPERATION_NAME = process.env.E2E_OPERATION_NAME ?? "thunderstruck";
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;

type Properties = Record<string, unknown>;

interface TelemetryCall {
telemetryType: string;
properties: Properties;
}

const toCall = (request: Request): TelemetryCall => {
const postData = request.postData();
expect(postData, "telemetry POST was sent without a body").toBeTruthy();
const body = JSON.parse(postData!) as {
telemetryType?: string;
properties?: Properties[];
} | null;
expect(
Array.isArray(body?.properties),
`telemetry POST has no properties array: ${postData}`,
).toBe(true);
return {
telemetryType: body!.telemetryType ?? "",
properties: Object.assign({}, ...body!.properties!),
};
};

test("Scenario 26: the journey starts past the first screen, tracks each step and logs the cancellation", async ({
page,
}) => {
const requests: Request[] = [];
page.on("request", (request) => {
if (
request.url().endsWith("/api/v1/telemetry") &&
request.method() === "POST"
) {
requests.push(request);
}
});
const telemetry = () => requests.map(toCall);
const events = (name: string) =>
telemetry().filter(
(call) => call.telemetryType === "Event" && call.properties.name === name,
);
const pageViews = () =>
telemetry().filter((call) => call.telemetryType === "PageView");

await startAtHomePage(page, {
operationName: OPERATION_NAME,
hasSuspect: false,
});
await expectStep(page, "/case-registration/areas");

await expect.poll(() => events("JourneyStarted").length).toBe(1);
const journeyId = events("JourneyStarted")[0].properties.journeyId;
expect(journeyId, "JourneyStarted journeyId is not a v4 UUID").toMatch(UUID);

const areasPage = new CaseAreasPage(page);
await areasPage.enterAreaOrDivision(AREA);
await areasPage.saveAndContinue();
await expectStep(page, "/case-registration/case-details");

await page.getByRole("link", { name: "Cancel" }).click();
await expectStep(
page,
"/case-registration/cancel-case-registration-confirmation",
);

const expectedPageViews = [
{ pageName: "Home", path: "/case-registration" },
{ pageName: "Case Areas", path: "/case-registration/areas" },
{ pageName: "Case Details", path: "/case-registration/case-details" },
{
pageName: "Cancel Case Registration Confirmation",
path: "/case-registration/cancel-case-registration-confirmation",
},
];
const byPath = (a: Properties, b: Properties) =>
String(a.path).localeCompare(String(b.path));
await expect.poll(() => pageViews().length).toBe(expectedPageViews.length);
expect(
pageViews()
.map(({ properties }) => properties)
.sort(byPath),
"page views missing, duplicated, or carrying the wrong journeyId",
).toEqual(
expectedPageViews.map((view) => ({ ...view, journeyId })).sort(byPath),
);

const cancelPage = new CancelCaseRegistrationConfirmationPage(page);
await cancelPage.selectCancelCaseRegistrationYes();
const cancelledResponse = page.waitForResponse(
(response) =>
response.url().endsWith("/api/v1/telemetry") &&
response.request().postData()?.includes("JourneyCancelled") === true,
);
await cancelPage.continue();

await expect.poll(() => events("JourneyCancelled").length).toBe(1);
expect(events("JourneyCancelled")[0].properties).toEqual({
name: "JourneyCancelled",
journeyId,
cancelledFrom: "/case-registration/case-details",
});
const response = await cancelledResponse;
expect(
response.ok(),
`JourneyCancelled telemetry returned ${response.status()}`,
).toBe(true);

expect(
events("JourneyStarted"),
"the journey was started twice",
).toHaveLength(1);
});
2 changes: 1 addition & 1 deletion src/ui-spa/e2e-tests/journeys/longPathValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export async function completeLongPathValidation(
await caseDetailsPage.enterWitnessCareUnit(WITNESS_CARE_UNIT);
await caseDetailsPage.submitAndExpectExistingUrnError(existingUrn);
await caseDetailsPage.enterUrn(urn);
await caseDetailsPage.saveAndContinue();
await caseDetailsPage.saveAndContinueWithFreeUrn(urn);

// Add suspect.
const addSuspectPage = new AddSuspectPage(page);
Expand Down
11 changes: 8 additions & 3 deletions src/ui-spa/e2e-tests/journeys/shortPathDuplicateUrn.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { CaseDetailsPage } from "../../integration-tests/pages/caseDetailsPage";
import { CaseDetailsPage } from "../pages/caseDetailsPage";
import { generateUniqueUrn } from "../utils/generateUrn";
import { expectStep } from "../utils/expectStep";
import {
Expand Down Expand Up @@ -28,12 +28,17 @@ export async function completeShortPathDuplicateUrn(
freeUrn = generateUniqueUrn();
}

// Registering this first case is what makes the URN "existing"; if the
// generated reference was already taken, enterAreasAndCaseDetails moves
// existingUrn on to a free one, which the second visit below then reuses.
await startAtHomePage(page, { operationName, hasSuspect: false });
await enterAreasAndCaseDetails(page, existingUrn);
await completeAssigneeAndSubmit(page, existingUrn, operationName);

await startAtHomePage(page, { operationName, hasSuspect: false });
await enterAreasAndCaseDetails(page, existingUrn);
await enterAreasAndCaseDetails(page, existingUrn, {
expectDuplicateUrn: true,
});

const detailsPage = new CaseDetailsPage(page);
await expectStep(page, "/case-registration/case-details");
Expand All @@ -42,7 +47,7 @@ export async function completeShortPathDuplicateUrn(
"URN already exists, please change reference text and try again",
);
await detailsPage.enterUrnUniqueReference(freeUrn.uniqueReference);
await detailsPage.saveAndContinue();
await detailsPage.saveAndContinueWithFreeUrn(freeUrn);

await completeMonitoringAndAssignee(page);
await verifySummaryAndSubmit(page, freeUrn, {
Expand Down
Loading
Loading