Skip to content

Commit

Permalink
New Karate mock for retrieve patient and patch patient functionality (#…
Browse files Browse the repository at this point in the history
…994)

* patch patient tests - successful patching scenarios

* starts adding tests for patch error scenarios

* more update scenarios implemented

* invalid address ID test

* adds more scnearios, moves some stubs around

* adds final two patch error scenarios to feature

* tidies up mock patch code

* makes all tests like the original tests, gets everything working

* fixes logic so that successful patch scenarios work

* ignore sandbox tests in parallel runs for the moment

* removes the get-patient-search.js file from the mock - not needed for this

* removes comments, adds lines, cleans up

* adds parallel runner for mock env; updates test for content headers - application/json should be allowed

* adds karate sandbox tests to pipeline
  • Loading branch information
Pete Loggie authored Mar 27, 2024
1 parent 70308b4 commit 0234490
Show file tree
Hide file tree
Showing 33 changed files with 2,674 additions and 18 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ keys/


karate-tests/target/
karate-tests/src/test/java/target/
8 changes: 7 additions & 1 deletion azure/azure-pr-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ extends:
- template: templates/pds-tests-karate.yml
depends_on:
- pytest_bdd_tests
- environment: internal-dev
stage_name: karate_sandbox_tests
post_deploy:
- template: templates/pds-tests-karate-sandbox.yml
depends_on:
- karate_tests
- environment: internal-dev
service_name: ${{ variables.service_name }}-asid-required
short_service_name: ${{ variables.short_service_name }}-asid
Expand All @@ -58,7 +64,7 @@ extends:
post_deploy:
- template: templates/pds-tests.yml
depends_on:
- karate_tests
- karate_sandbox_tests

- environment: internal-dev-sandbox
proxy_path: sandbox
Expand Down
39 changes: 39 additions & 0 deletions azure/templates/pds-tests-karate-sandbox.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
steps:

- template: "azure/components/aws-assume-role.yml@common"
parameters:
role: "auto-ops"
profile: "apm_ptl"

- template: "azure/components/get-aws-secrets-and-ssm-params.yml@common"
parameters:
config_ids:
- /ptl/azure-devops/env-internal-dev/test-app/internal-testing-internal-dev/CLIENT_ID
secret_ids:
- ptl/app-credentials/jwt_testing/non-prod/JWT_TESTING_API_KEY
- ptl/app-credentials/jwt_testing/non-prod/JWT_TESTING_WITH_ASID_API_KEY
- ptl/backends/ig3/INTERNAL_DEV_ASID

- bash: |
echo '##vso[task.setvariable variable=CLIENT_ID]$(CLIENT_ID)'
displayName: Expose common variables
- bash: |
export CLIENT_ID="$(CLIENT_ID)"
mvn clean test -Dtest=TestMockParallel
displayName: 'Run Karate Sandbox Tests'
workingDirectory: "$(Pipeline.Workspace)/s/$(SERVICE_NAME)/$(SERVICE_ARTIFACT_NAME)/karate-tests"
- task: PublishTestResults@2
displayName: 'Publish Karate sandbox test results'
condition: in(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues', 'Failed')
inputs:
testResultsFiles: $(Pipeline.Workspace)/s/$(SERVICE_NAME)/$(SERVICE_ARTIFACT_NAME)/karate-tests/target/karate-reports/*.xml
failTaskOnFailedTests: true

- task: PublishBuildArtifacts@1
displayName: 'Publish Karate Sandbox HTML test report as an artifact'
condition: in(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues', 'Failed')
inputs:
pathToPublish: $(Pipeline.Workspace)/s/$(SERVICE_NAME)/$(SERVICE_ARTIFACT_NAME)/karate-tests/target/karate-reports
artifactName: KarateSandboxHTMLReports
26 changes: 26 additions & 0 deletions karate-tests/src/test/java/mocks/MockRunner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package mocks;


import com.intuit.karate.http.HttpServer;
import com.intuit.karate.http.ServerConfig;
import com.intuit.karate.http.ServerContext;


public class MockRunner {

public static HttpServer start(String root, int port) {
ServerConfig config = new ServerConfig(root)
.useGlobalSession(true);
config.contextFactory(request -> {
ServerContext context = new ServerContext(config, request);
context.setApi(true);
request.setResourcePath("sandbox.js");
return context;
});
return HttpServer.config(config)
.http(port)
.corsEnabled(true)
.build();
}

}
32 changes: 32 additions & 0 deletions karate-tests/src/test/java/mocks/get-patient-retrieve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
Our patients "database" for the get by NHS Number requests :-)
*/
session.patients = session.patients || {
'9000000009': context.read('classpath:mocks/stubs/patientResponses/patient_9000000009.json'),
'9000000025': context.read('classpath:mocks/stubs/patientResponses/patient_9000000025.json'),
'9000000033': context.read('classpath:mocks/stubs/patientResponses/patient_9000000033.json'),
'9693632109': context.read('classpath:mocks/stubs/patientResponses/patient_9693632109.json')
}

/*
Handler for get patient by NHS Number
*/
if (request.pathMatches('/Patient/{nhsNumber}') && request.get) {
let valid = validateHeaders(request) && validateNHSNumber(request) ;

if (valid) {
const nhsNumber = request.pathParams.nhsNumber;
if (typeof session.patients[nhsNumber] == 'undefined') {
response.body = context.read('classpath:mocks/stubs/errorResponses/RESOURCE_NOT_FOUND.json');
response.headers = basicResponseHeaders(request)
response.status = 404
} else {
patient = session.patients[nhsNumber]
let responseHeaders = basicResponseHeaders(request)
responseHeaders['etag'] = `W/"${patient.meta.versionId}"`
response.body = patient;
response.headers = responseHeaders;
response.status = 200;
}
}
}
145 changes: 145 additions & 0 deletions karate-tests/src/test/java/mocks/patch-patient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
function buildResponseHeaders(request, patient) {
return {
'content-type': 'application/fhir+json',
'etag': `W/"${patient.meta.versionId}"`,
'x-request-id': request.header('x-request-id'),
'x-correlation-id': request.header('x-correlation-id')
};
}

/*
Diagnostics strings for error messages
*/
const NO_IF_MATCH_HEADER = "Invalid update with error - If-Match header must be supplied to update this resource";
const NO_PATCHES_PROVIDED = "Invalid update with error - No patches found";
const INVALID_RESOURCE_ID = "Invalid update with error - This resource has changed since you last read. Please re-read and try again with the new version number.";
const INVALID_PATCH = "Invalid patch: Operation `op` property is not one of operations defined in RFC-6902"

/*
Functions to handle error responses
*/
function invalidUpdateError(request, diagnostics) {
let body = context.read('classpath:mocks/stubs/errorResponses/INVALID_UPDATE.json');
body.issue[0].diagnostics = diagnostics;
response.headers = basicResponseHeaders(request);
response.body = body;
response.status = 400;
return false
}

function preconditionFailedError(request, diagnostics) {
let body = context.read('classpath:mocks/stubs/errorResponses/PRECONDITION_FAILED.json');
body.issue[0].diagnostics = diagnostics;
response.headers = basicResponseHeaders(request);
response.body = body;
response.status = 412;
return false
}

function unsupportedServiceError(request) {
let body = context.read('classpath:mocks/stubs/errorResponses/UNSUPPORTED_SERVICE.json');
response.headers = basicResponseHeaders(request);
response.body = body;
response.status = 400;
return false
}

/*
Validate the headers specific to patching a patient
*/
function validatePatchHeaders(request) {
var valid = true;
if (!request.header('if-match')) {
valid = preconditionFailedError(request, NO_IF_MATCH_HEADER)
}
if (valid && !request.header('content-type').startsWith('application/json')) {
valid = unsupportedServiceError(request)
}
return valid
}

/*
The main logic for patching a patient
*/
function patchPatient(originalPatient, request) {

if (!request.body.patches) {
return invalidUpdateError(request, NO_PATCHES_PROVIDED);
}
if (request.header('if-match') != `W/"${originalPatient.meta.versionId}"`) {
return preconditionFailedError(request, INVALID_RESOURCE_ID);
}

let updatedPatient = JSON.parse(JSON.stringify(originalPatient));
let updateErrors = [];

const validOperations = ['add', 'replace', 'remove', 'test']
for(let i = 0; i < request.body.patches.length; i++) {
let patch = request.body.patches[i];
if (!validOperations.includes(patch.op)) {
return invalidUpdateError(request, INVALID_PATCH)
}
if (patch.op == 'add' && patch.path === '/name/-') {
updatedPatient.name.push(patch.value);
}
if (patch.op == 'replace' && patch.path === '/name/0/given/0') {
updatedPatient.name[0].given[0] = patch.value;
}
if (patch.op == 'replace' && patch.path === '/gender') {
updatedPatient.gender = patch.value;
}
if (patch.op == 'remove' && patch.path === '/name/0/suffix/0') {
updatedPatient.name[0].suffix.splice(0, 1);
}

// these specific error scenarios for update errors should be reviewed
if (patch.op == 'replace' && patch.path === "/address/0/line/0" && patch.value === "2 Whitehall Quay") {
updateErrors.push("Invalid update with error - no id or url found for path with root /address/0");
} else if (patch.op == 'replace' && patch.path.startsWith("/address/0/") && !originalPatient.hasOwnProperty('address')) {
updateErrors.push("Invalid update with error - Invalid patch - index '0' is out of bounds");
} else if (patch.op == 'replace' && patch.path === "/address/0/id" && patch.value === "456") {
updateErrors.push("Invalid update with error - no 'address' resources with object id 456");
} else if (patch.op == 'replace' && patch.path === "/address/0/line") {
updateErrors.push("Invalid update with error - Invalid patch - can't replace non-existent object 'line'");
} else if (patch.op == 'replace' && patch.path === "/address/0/id" && patch.value === "123456") {
updateErrors.push("Invalid update with error - no 'address' resources with object id 123456");
}
}

// why is it that for this specific scenario (Invalid patch - attempt to replace non-existent object),
// we have to pick the last error message, when for all the others we pick the first error message?
const rogueErrors = [
"Invalid update with error - no 'address' resources with object id 456",
"Invalid update with error - Invalid patch - can't replace non-existent object 'line'"
]

if (updateErrors.length > 0) {
if (updateErrors.every(item => rogueErrors.includes(item)) && rogueErrors.every(item => updateErrors.includes(item))) {
return invalidUpdateError(request, updateErrors[1])
} else {
return invalidUpdateError(request, updateErrors[0])
}
} else {
updatedPatient.meta.versionId = (parseInt(updatedPatient.meta.versionId) + 1);
return updatedPatient;
}
}

/*
Handler for patch patient
*/
if (request.pathMatches('/Patient/{nhsNumber}') && request.patch) {
const valid = validateNHSNumber(request) && validatePatchHeaders(request) && validateHeaders(request)
if (valid) {
const nhsNumber = request.pathParams.nhsNumber;
const originalPatient = session.patients[nhsNumber];
let updatedPatient = patchPatient(originalPatient, request);
if (updatedPatient) {
// this line is commented out because the existing tests assume a stateless mock
// session.patients[nhsNumber] = updatedPatient;
response.headers = buildResponseHeaders(request, updatedPatient);
response.body = updatedPatient;
response.status = 200;
}
}
}
Loading

0 comments on commit 0234490

Please sign in to comment.