diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ccfbeb7c..2dcf360c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,7 +18,8 @@ "ms-python.vscode-python-envs", "ms-python.mypy-type-checker", "sonarsource.sonarlint-vscode", - "alexkrechik.cucumberautocomplete" + "alexkrechik.cucumberautocomplete", + "streetsidesoftware.code-spell-checker" ], "extensions.ignoreRecommendations": true, "settings": { diff --git a/.tool-versions b/.tool-versions index 253dc21c..fcbb5c74 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,6 +1,6 @@ # This file is for you! Please, updated to the versions agreed by your team. -terraform 1.14.0 +terraform 1.14.5 pre-commit 3.6.0 gitleaks 8.18.4 @@ -15,7 +15,7 @@ gitleaks 8.18.4 # docker/ghcr.io/make-ops-tools/gocloc latest@sha256:6888e62e9ae693c4ebcfed9f1d86c70fd083868acb8815fe44b561b9a73b5032 # SEE: https://github.com/make-ops-tools/gocloc/pkgs/container/gocloc # docker/ghcr.io/nhs-england-tools/github-runner-image 20230909-321fd1e-rt@sha256:ce4fd6035dc450a50d3cbafb4986d60e77cb49a71ab60a053bb1b9518139a646 # SEE: https://github.com/nhs-england-tools/github-runner-image/pkgs/container/github-runner-image # docker/hadolint/hadolint 2.12.0-alpine@sha256:7dba9a9f1a0350f6d021fb2f6f88900998a4fb0aaf8e4330aa8c38544f04db42 # SEE: https://hub.docker.com/r/hadolint/hadolint/tags -# docker/hashicorp/terraform 1.12.2@sha256:b3d13c9037d2bd858fe10060999aa7ca56d30daafe067d7715b29b3d4f5b162f # SEE: https://hub.docker.com/r/hashicorp/terraform/tags +# docker/hashicorp/terraform 1.14.5@sha256:96d2bc440714bf2b2f2998ac730fd4612f30746df43fca6f0892b2e2035b11bc # SEE: https://hub.docker.com/r/hashicorp/terraform/tags # docker/koalaman/shellcheck latest@sha256:e40388688bae0fcffdddb7e4dea49b900c18933b452add0930654b2dea3e7d5c # SEE: https://hub.docker.com/r/koalaman/shellcheck/tags # docker/mstruebing/editorconfig-checker 2.7.1@sha256:dd3ca9ea50ef4518efe9be018d669ef9cf937f6bb5cfe2ef84ff2a620b5ddc24 # SEE: https://hub.docker.com/r/mstruebing/editorconfig-checker/tags # docker/sonarsource/sonar-scanner-cli 10.0@sha256:0bc49076468d2955948867620b2d98d67f0d59c0fd4a5ef1f0afc55cf86f2079 # SEE: https://hub.docker.com/r/sonarsource/sonar-scanner-cli/tags diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..c66de352 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Flask", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/infrastructure/images/gateway-api/resources/build/gateway-api/gateway_api/app.py", + "envFile": "${workspaceFolder}/.env", + "jinja": true, + "console": "integratedTerminal" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c5f1eea..4fe7dfbe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,5 +60,13 @@ "projectKey": "NHSDigital_clinical-data-gateway-api" }, // Disabling automatic port forwarding as the devcontainer should already have access to any required ports. - "remote.autoForwardPorts": false + "remote.autoForwardPorts": false, + + // Code spell checker configuration + "cSpell.language": "en-GB", + "cSpell.words": [ + "usefixtures", + "fhir", + "asid" + ] } diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index b9c73434..ef29b2c1 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -247,3 +247,28 @@ paths: diagnostics: type: string example: "Internal server error" + + '502': + description: Received an error response from a downstream server + content: + application/fhir+json: + schema: + type: object + properties: + resourceType: + type: string + example: "OperationOutcome" + issue: + type: array + items: + type: object + properties: + severity: + type: string + example: "error" + code: + type: string + example: "exception" + diagnostics: + type: string + example: "PDS FHIR API request failed: Bad Gateway" diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 88b054f5..ab1aa9fa 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "anyio" @@ -51,18 +51,6 @@ files = [ {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] -[[package]] -name = "backoff" -version = "2.2.1" -description = "Function decoration for backoff and retry" -optional = false -python-versions = ">=3.7,<4.0" -groups = ["dev"] -files = [ - {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, - {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, -] - [[package]] name = "blinker" version = "1.9.0" @@ -628,14 +616,14 @@ zoneinfo = ["tzdata (>=2025.2) ; sys_platform == \"win32\" or sys_platform == \" [[package]] name = "hypothesis-graphql" -version = "0.11.1" +version = "0.12.0" description = "Hypothesis strategies for GraphQL queries" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "hypothesis_graphql-0.11.1-py3-none-any.whl", hash = "sha256:a6968f703bcdc31fbe1b26be69185aa2c824eb3b478057a66aa85967c81cadca"}, - {file = "hypothesis_graphql-0.11.1.tar.gz", hash = "sha256:bd49ab6804a3f488ecab2e39c20dba6dfc2101525c6742f5831cfa9eff95285a"}, + {file = "hypothesis_graphql-0.12.0-py3-none-any.whl", hash = "sha256:d200d3d4320e772248075f13c656f4b1de01e7f0f5e7d9fd6fea7da759b325f3"}, + {file = "hypothesis_graphql-0.12.0.tar.gz", hash = "sha256:15f5f69b6e0b9ad889f59d340e091d7d481471373eb6a8a8591d126aa56e7700"}, ] [package.dependencies] @@ -765,7 +753,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format\""} idna = {version = "*", optional = true, markers = "extra == \"format\""} isoduration = {version = "*", optional = true, markers = "extra == \"format\""} jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format\""} -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format\""} rfc3987 = {version = "*", optional = true, markers = "extra == \"format\""} @@ -777,6 +765,48 @@ webcolors = {version = ">=1.11", optional = true, markers = "extra == \"format\" format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] +[[package]] +name = "jsonschema-rs" +version = "0.42.1" +description = "A high-performance JSON Schema validator for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "jsonschema_rs-0.42.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7d4c2cf89fb1f49399be7f0e601526f189497f4f7bbefc4fac5f4447ca52609c"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:40d53eea48a17876d6802405edc6e0367f07260545713ac6d727054bce8c425f"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de86325cf5e0d1c35ec14e60ffe2ce4547c8385802ea69ac11540616b822cb2"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c664bd3ffb1cfd70d2b8b8b9587782184a81a8467a70bcc6c71a84cf573ecdf3"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d7dc31fa2b644205271ac1071aec005f88565b135ad1f983b8c1de2589266e1e"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d5602f07fe69f108f7dd9d2d05d940a2498517f07a230da2efb8f36bf06e0703"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:005cd79783a4980ad68d21f7a25c913778dc6a0fe8e3d3c76132eabb7a40287a"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-win32.whl", hash = "sha256:6817e5c1fcb10d80b4dda38cd106850c7e3e9dc06d5afe93668b9c99744723c9"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-win_amd64.whl", hash = "sha256:b508dd9a114352bf8fc20d8e6d01563fbb18f3f877d11e0ecbbb43c556ec4174"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:4849b65048e4fd53991424a827f8369d0b6c7ad1d9ff05bb854afa685131b954"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98ca39207afab8782149810b789c717d5a0bb7bbb6330bd537827eeecbbeccbb"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a363739a254c3990cbea18a7350de3bc06cdf02307ee1db60ff86fd13e1ff58"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:28b235ab6263f96ed2448f645291d446e4433c7fa6cc255bff2dcffd2a2b65a9"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f50e0baabb3d6d1b250ad776ade46c6bf3599c681e961f686a6a50e925322d64"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6abf63a5523e13257a0e3a8cf58d15a46e00fcaaafc6f92c34c9109575529"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e20154d59e843b36e2e7d6b7415954ae3374e23d97a2ad11670a75fe0884c246"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2fa26381601a32439ff46c7588476a0fc4d2a0b97a58da756196fd747b99bf01"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0d76660cd5b143e342c5d98bf9690258171dbe1beba7cdc6354bc736eb286f7e"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52afd2b1ebf7360a0e9ec40b23cda167b92d776ed8f14f1a6c78a0fc3070453d"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:1a7869a0c81a12bf7875235e0d4b95b68392655373c1f443fbbacd0e7cc9d289"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cdbed80ae956fff192bf32b5be902f07a46788b12a038c8093928c1e80035a73"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b6e795ee51b807eea75df5b43a4380f950c6ff3feb586e01b2423b4d31093881"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-win_amd64.whl", hash = "sha256:50bda44a74ddae8bdd1e35785d738c39847b54fcaf4e803f4b51ace095d94a51"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5844afa812db3a61b8994ec562f7a13a62208ec7f9806c34784fc22c945ac87c"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad9a5bfc34394d812f88d3e4b320236fbb9b66b34c88f9ec13f9143f97d562a6"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dce9ddf7084bc7d2fbd1bab4a81d69f99413d001971b56ca26403bafd6da5432"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3cf915dc8eeb304c045fb85e34a97d6d3f563aa75e13d8eb2ab6ebb145b3adc4"}, + {file = "jsonschema_rs-0.42.1.tar.gz", hash = "sha256:4144cd351d39ce457f2c7d45111e7225eb5ed1791e0226dec5b9099d78651e32"}, +] + +[package.extras] +bench = ["fastjsonschema (>=2.20.0)", "jsonschema (>=4.23.0)", "pytest-benchmark (>=4.0.0)"] +tests = ["flask (>=2.2.5)", "hypothesis (>=6.79.4)", "pytest (>=7.4.4)"] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -1482,20 +1512,20 @@ docs = ["furo (>=2022.3.4,<2023.0.0)", "myst-parser (>=0.17)", "sphinx (>=4.3.0, [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" @@ -1582,6 +1612,24 @@ pytest = ">=7.0.0" [package.extras] test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "tox (>=3.24.5)"] +[[package]] +name = "pytest-mock" +version = "3.15.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "pytest-subtests" version = "0.14.2" @@ -1922,43 +1970,44 @@ files = [ [[package]] name = "schemathesis" -version = "4.4.1" +version = "4.10.2" description = "Property-based testing framework for Open API and GraphQL based apps" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "schemathesis-4.4.1-py3-none-any.whl", hash = "sha256:6b68170cef21b001cc43a244ed5aaaf62b5d6826984c8ae09a495047d47cf065"}, - {file = "schemathesis-4.4.1.tar.gz", hash = "sha256:e248edf5e6d5a47babf70133593064104ceddf45019e2bd89b1da5150eec4c61"}, + {file = "schemathesis-4.10.2-py3-none-any.whl", hash = "sha256:47a1f32a81dd237dbeb1da4374e48dd4402813c4c75fa23091a4f0986a8616be"}, + {file = "schemathesis-4.10.2.tar.gz", hash = "sha256:ad69508a9dd1a5b6fd6f4891abe86a9fc5f3f0d7a1133353359aadfd9522ac1f"}, ] [package.dependencies] -backoff = ">=2.1.2,<3.0" click = ">=8.0,<9" colorama = ">=0.4,<1.0" harfile = ">=0.4.0,<1.0" httpx = ">=0.22.0,<1.0" hypothesis = ">=6.108.0,<7" -hypothesis-graphql = ">=0.11.1,<1" +hypothesis-graphql = ">=0.12.0,<1" hypothesis-jsonschema = ">=0.23.1,<0.24" jsonschema = {version = ">=4.18.0,<5.0", extras = ["format"]} +jsonschema-rs = ">=0.41.0" junit-xml = ">=1.9,<2.0" pyrate-limiter = ">=3.0,<4.0" -pytest = ">=8,<9" -pytest-subtests = ">=0.11,<0.15.0" +pytest = ">=8,<10" +pytest-subtests = ">=0.11,<0.16.0" pyyaml = ">=5.1,<7.0" requests = ">=2.22,<3" rich = ">=13.9.4" starlette-testclient = ">=0.4.1,<1" +tenacity = ">=9.1.2,<10.0" typing-extensions = ">=4.12.2" werkzeug = ">=0.16.0,<4" [package.extras] bench = ["pytest-codspeed (==4.2.0)"] cov = ["coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "coverage-enable-subprocess", "coverage[toml] (>=5.3)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "mkdocs-material", "mkdocstrings[python]", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-codspeed (==4.2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=2,<5.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] +dev = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "coverage-enable-subprocess", "coverage[toml] (>=5.3)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "mkdocs-material", "mkdocstrings[python]", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-codspeed (==4.2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] docs = ["mkdocs-material", "mkdocstrings[python]"] -tests = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=2,<5.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] +tests = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] [[package]] name = "six" @@ -2030,6 +2079,22 @@ files = [ requests = "*" starlette = ">=0.20.1" +[[package]] +name = "tenacity" +version = "9.1.4" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, + {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "types-click" version = "7.1.8" @@ -2360,4 +2425,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "a452bd22e2386a3ff58b4c7a5ac2cb571de9e3d49a4fbc161ffd3aafa2a7bf44" +content-hash = "6e4b608d881e6c840cd58d0522f2064ad1aa6fbc4eda74b06be61fd77b9e1eb7" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index 748ebd4f..a841d21e 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -55,6 +55,7 @@ dev = [ "schemathesis>=4.4.1", "types-requests (>=2.32.4.20250913,<3.0.0.0)", "types-pyyaml (>=6.0.12.20250915,<7.0.0.0)", + "pytest-mock (>=3.15.1,<4.0.0)", ] [tool.mypy] diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index 4ad915ee..f7b15f5c 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -1,6 +1,7 @@ """FHIR data types and resources.""" from fhir.bundle import Bundle, BundleEntry +from fhir.general_practitioner import GeneralPractitioner from fhir.human_name import HumanName from fhir.identifier import Identifier from fhir.operation_outcome import OperationOutcome, OperationOutcomeIssue @@ -17,4 +18,5 @@ "Parameter", "Parameters", "Patient", + "GeneralPractitioner", ] diff --git a/gateway-api/src/fhir/general_practitioner.py b/gateway-api/src/fhir/general_practitioner.py new file mode 100644 index 00000000..6589fffe --- /dev/null +++ b/gateway-api/src/fhir/general_practitioner.py @@ -0,0 +1,21 @@ +"""FHIR GeneralPractitioner type.""" + +from typing import TypedDict + +from fhir.period import Period + + +class GeneralPractitionerIdentifier(TypedDict): + """Identifier for GeneralPractitioner with optional period.""" + + system: str + value: str + period: Period + + +class GeneralPractitioner(TypedDict): + """FHIR GeneralPractitioner reference.""" + + id: str + type: str + identifier: GeneralPractitionerIdentifier diff --git a/gateway-api/src/fhir/human_name.py b/gateway-api/src/fhir/human_name.py index 2a73deb0..6b284c88 100644 --- a/gateway-api/src/fhir/human_name.py +++ b/gateway-api/src/fhir/human_name.py @@ -2,8 +2,11 @@ from typing import TypedDict +from fhir.period import Period + class HumanName(TypedDict): use: str family: str given: list[str] + period: Period diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py index 33d0ce41..453a6f2a 100644 --- a/gateway-api/src/fhir/patient.py +++ b/gateway-api/src/fhir/patient.py @@ -1,7 +1,8 @@ """FHIR Patient resource.""" -from typing import TypedDict +from typing import NotRequired, TypedDict +from fhir.general_practitioner import GeneralPractitioner from fhir.human_name import HumanName from fhir.identifier import Identifier @@ -13,3 +14,4 @@ class Patient(TypedDict): name: list[HumanName] gender: str birthDate: str + generalPractitioner: NotRequired[list[GeneralPractitioner]] diff --git a/gateway-api/src/fhir/period.py b/gateway-api/src/fhir/period.py new file mode 100644 index 00000000..6ac40b4f --- /dev/null +++ b/gateway-api/src/fhir/period.py @@ -0,0 +1,10 @@ +"""FHIR Period type.""" + +from typing import NotRequired, TypedDict + + +class Period(TypedDict, total=False): + """FHIR Period type.""" + + start: str + end: NotRequired[str] diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 265601e5..881c32fa 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -4,10 +4,10 @@ from flask import Flask, request from flask.wrappers import Response +from gateway_api.common.error import BaseError from gateway_api.controller import Controller from gateway_api.get_structured_record import ( GetStructuredRecordRequest, - RequestValidationError, ) app = Flask(__name__) @@ -38,27 +38,16 @@ def get_app_port() -> int: def get_structured_record() -> Response: try: get_structured_record_request = GetStructuredRecordRequest(request) - except RequestValidationError as e: - response = Response( - response=str(e), - status=400, - content_type="text/plain", - ) - return response - except Exception as e: - response = Response( - response=f"Internal Server Error: {e}", - status=500, - content_type="text/plain", - ) - return response - - try: controller = Controller() flask_response = controller.run(request=get_structured_record_request) get_structured_record_request.set_response_from_flaskresponse(flask_response) - except Exception as e: - get_structured_record_request.set_negative_response(str(e)) + except BaseError as e: + e.log() + return e.build_response() + except Exception: + error = BaseError() + error.log() + return error.build_response() return get_structured_record_request.build_response() diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py new file mode 100644 index 00000000..a2779848 --- /dev/null +++ b/gateway-api/src/gateway_api/common/error.py @@ -0,0 +1,101 @@ +import json +from dataclasses import dataclass +from enum import StrEnum +from http.client import BAD_GATEWAY, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND +from typing import TYPE_CHECKING + +from flask import Response + +if TYPE_CHECKING: + from fhir.operation_outcome import OperationOutcome + + +class ErrorCode(StrEnum): + INVALID = "invalid" + EXCEPTION = "exception" + + +@dataclass +class BaseError(Exception): + _message = "Internal Server Error" + status_code: int = INTERNAL_SERVER_ERROR + severity: str = "error" + error_code: ErrorCode = ErrorCode.EXCEPTION + + def __init__(self, **additional_details: str): + self.additional_details = additional_details + super().__init__(self) + + def build_response(self) -> Response: + operation_outcome: OperationOutcome = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": self.severity, + "code": self.error_code, + "diagnostics": self.message, + } + ], + } + response = Response( + response=json.dumps(operation_outcome), + status=self.status_code, + content_type="application/fhir+json", + ) + return response + + def log(self) -> None: + print(self) # TODO: Use traceback.print_exec() + + @property + def message(self) -> str: + return self._message.format(**self.additional_details) + + def __str__(self) -> str: + return self.message + + +class InvalidRequestJSON(BaseError): + _message = "Invalid JSON body sent in request" + error_code = ErrorCode.INVALID + status_code = BAD_REQUEST + + +class MissingOrEmptyHeader(BaseError): + _message = 'Missing or empty required header "{header}"' + status_code = BAD_REQUEST + + +class NoCurrentProvider(BaseError): + _message = "PDS patient {nhs_number} did not contain a current provider ODS code" + status_code = NOT_FOUND + + +class NoOrganisationFound(BaseError): + _message = "No SDS org found for {org_type} ODS code {ods_code}" + status_code = NOT_FOUND + + +class NoAsidFound(BaseError): + _message = ( + "SDS result for {org_type} ODS code {ods_code} did not contain a current ASID" + ) + status_code = NOT_FOUND + + +class NoCurrentEndpoint(BaseError): + _message = ( + "SDS result for provider ODS code {provider_ods} did not contain " + "a current endpoint" + ) + status_code = NOT_FOUND + + +class PdsRequestFailed(BaseError): + _message = "PDS FHIR API request failed: {error_reason}" + status_code = BAD_GATEWAY + + +class ProviderRequestFailed(BaseError): + _message = "Provider request failed: {error_reason}" + status_code = BAD_GATEWAY diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 05307c86..02008d55 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -1,7 +1,7 @@ """Pytest configuration and shared fixtures for gateway API tests.""" import pytest -from fhir.parameters import Parameters +from fhir import Bundle, Parameters, Patient @pytest.fixture @@ -18,3 +18,90 @@ def valid_simple_request_payload() -> Parameters: }, ], } + + +@pytest.fixture +def valid_simple_response_payload() -> Bundle: + return { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-02-05T22:45:42.766330+00:00", + "entry": [ + { + "fullUrl": "https://example.com/Patient/9999999999", + "resource": { + "name": [ + { + "family": "Alice", + "given": ["Johnson"], + "use": "Ally", + "period": {"start": "2020-01-01"}, + } + ], + "gender": "female", + "birthDate": "1990-05-15", + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + {"value": "9999999999", "system": "urn:nhs:numbers"} + ], + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01", "end": "9999-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + ], + }, + } + ], + } + + +@pytest.fixture +def valid_headers() -> dict[str, str]: + return { + "Ssp-TraceID": "test-trace-id", + "ODS-from": "test-ods", + "Content-type": "application/fhir+json", + } + + +@pytest.fixture +def happy_path_pds_response_body() -> Patient: + return { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [{"value": "9999999999", "system": "urn:nhs:numbers"}], + "name": [ + { + "family": "Johnson", + "given": ["Alice"], + "use": "Ally", + "period": {"start": "2020-01-01", "end": "9999-12-31"}, + } + ], + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01", "end": "9999-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + ], + "gender": "female", + "birthDate": "1990-05-15", + } + + +@pytest.fixture +def auth_token() -> str: + return "AUTH_TOKEN123" diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 4a17d08c..44be9502 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -2,110 +2,22 @@ Controller layer for orchestrating calls to external services """ -from __future__ import annotations - -import json -from typing import TYPE_CHECKING - -from gateway_api.provider_request import GpProviderClient - -if TYPE_CHECKING: - from gateway_api.get_structured_record.request import GetStructuredRecordRequest - -__all__ = ["json"] # Make mypy happy in tests - -from dataclasses import dataclass - from gateway_api.common.common import FlaskResponse -from gateway_api.pds_search import PdsClient, PdsSearchResults - - -@dataclass -class RequestError(Exception): - """ - Raised (and handled) when there is a problem with the incoming request. - - Instances of this exception are caught by controller entry points and converted - into an appropriate :class:`FlaskResponse`. - - :param status_code: HTTP status code that should be returned. - :param message: Human-readable error message. - """ - - status_code: int - message: str - - def __str__(self) -> str: - """ - Coercing this exception to a string returns the error message. - - :returns: The error message. - """ - return self.message - - -@dataclass -class SdsSearchResults: - """ - Stub SDS search results dataclass. - - Replace this with the real one once it's implemented. - - :param asid: Accredited System ID. - :param endpoint: Endpoint URL associated with the organisation, if applicable. - """ - - asid: str - endpoint: str | None - - -class SdsClient: - """ - Stub SDS client for obtaining ASID from ODS code. - - Replace this with the real one once it's implemented. - """ - - SANDBOX_URL = "https://example.invalid/sds" - - def __init__( - self, - auth_token: str, - base_url: str = SANDBOX_URL, - timeout: int = 10, - ) -> None: - """ - Create an SDS client. - - :param auth_token: Authentication token to present to SDS. - :param base_url: Base URL for SDS. - :param timeout: Timeout in seconds for SDS calls. - """ - self.auth_token = auth_token - self.base_url = base_url - self.timeout = timeout - - def get_org_details(self, ods_code: str) -> SdsSearchResults | None: - """ - Retrieve SDS org details for a given ODS code. - - This is a placeholder implementation that always returns an ASID and endpoint. - - :param ods_code: ODS code to look up. - :returns: SDS search results or ``None`` if not found. - """ - # Placeholder implementation - return SdsSearchResults( - asid=f"asid_{ods_code}", endpoint="https://example-provider.org/endpoint" - ) +from gateway_api.common.error import ( + NoAsidFound, + NoCurrentEndpoint, + NoCurrentProvider, + NoOrganisationFound, +) +from gateway_api.get_structured_record.request import GetStructuredRecordRequest +from gateway_api.pds import PdsClient, PdsSearchResults +from gateway_api.provider import GpProviderClient +from gateway_api.sds import SdsClient, SdsSearchResults class Controller: """ Orchestrates calls to PDS -> SDS -> GP provider. - - Entry point: - - ``call_gp_provider(request_body_json, headers, auth_token) -> FlaskResponse`` """ gp_provider_client: GpProviderClient | None @@ -114,20 +26,13 @@ def __init__( self, pds_base_url: str = PdsClient.SANDBOX_URL, sds_base_url: str = "https://example.invalid/sds", - nhsd_session_urid: str | None = None, timeout: int = 10, ) -> None: """ Create a controller instance. - - :param pds_base_url: Base URL for PDS client. - :param sds_base_url: Base URL for SDS client. - :param nhsd_session_urid: Session URID for NHS Digital session handling. - :param timeout: Timeout in seconds for downstream calls. """ self.pds_base_url = pds_base_url self.sds_base_url = sds_base_url - self.nhsd_session_urid = nhsd_session_urid self.timeout = timeout self.gp_provider_client = None @@ -143,26 +48,14 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. 3) Call SDS using consumer ODS to obtain consumer ASID. 4) Call GP provider to obtain patient records. - - :param request: A GetStructuredRecordRequest instance. - :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the - outcome. """ auth_token = self.get_auth_token() - try: - provider_ods = self._get_pds_details( - auth_token, request.ods_from.strip(), request.nhs_number - ) - except RequestError as err: - return FlaskResponse(status_code=err.status_code, data=str(err)) + provider_ods = self._get_pds_details(auth_token, request.nhs_number) - try: - consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( - auth_token, request.ods_from.strip(), provider_ods - ) - except RequestError as err: - return FlaskResponse(status_code=err.status_code, data=str(err)) + consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( + auth_token, request.ods_from.strip(), provider_ods + ) # Call GP provider with correct parameters self.gp_provider_client = GpProviderClient( @@ -176,13 +69,10 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: body=request.request_body, ) - # If we get a None from the GP provider, that means that either the service did - # not respond or we didn't make the request to the service in the first place. - # Therefore a None is a 502, any real response just pass straight back. return FlaskResponse( - status_code=response.status_code if response is not None else 502, - data=response.text if response is not None else "GP provider service error", - headers=dict(response.headers) if response is not None else None, + status_code=response.status_code, + data=response.text, + headers=dict(response.headers), ) def get_auth_token(self) -> str: @@ -191,56 +81,27 @@ def get_auth_token(self) -> str: This is a placeholder implementation. Replace with actual logic to obtain the auth token as needed. - - :returns: Authorization token as a string. """ - # Placeholder implementation - return "PLACEHOLDER_AUTH_TOKEN" + return "AUTH_TOKEN123" - def _get_pds_details( - self, auth_token: str, consumer_ods: str, nhs_number: str - ) -> str: + def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: """ Call PDS to find the provider ODS code (GP ODS code) for a patient. - - :param auth_token: Authorization token to use for PDS. - :param consumer_ods: Consumer organisation ODS code (from request headers). - :param nhs_number: NHS number - :returns: Provider ODS code (GP ODS code). - :raises RequestError: If the patient cannot be found or has no provider ODS code """ # PDS: find patient and extract GP ODS code (provider ODS) pds = PdsClient( auth_token=auth_token, - end_user_org_ods=consumer_ods, base_url=self.pds_base_url, - nhsd_session_urid=self.nhsd_session_urid, timeout=self.timeout, ignore_dates=True, ) - pds_result: PdsSearchResults | None = pds.search_patient_by_nhs_number( - nhs_number - ) - - if pds_result is None: - raise RequestError( - status_code=404, - message=f"No PDS patient found for NHS number {nhs_number}", - ) + pds_result: PdsSearchResults = pds.search_patient_by_nhs_number(nhs_number) - if pds_result.gp_ods_code: - provider_ods_code = pds_result.gp_ods_code - else: - raise RequestError( - status_code=404, - message=( - f"PDS patient {nhs_number} did not contain a current " - "provider ODS code" - ), - ) + if not pds_result.gp_ods_code: + raise NoCurrentProvider(nhs_number=nhs_number) - return provider_ods_code + return pds_result.gp_ods_code def _get_sds_details( self, auth_token: str, consumer_ods: str, provider_ods: str @@ -251,12 +112,6 @@ def _get_sds_details( This method performs two SDS lookups: - provider details (ASID + endpoint) - consumer details (ASID) - - :param auth_token: Authorization token to use for SDS. - :param consumer_ods: Consumer organisation ODS code (from request headers). - :param provider_ods: Provider organisation ODS code (from PDS). - :returns: Tuple of (consumer_asid, provider_asid, provider_endpoint). - :raises RequestError: If SDS data is missing or incomplete for provider/consumer """ # SDS: Get provider details (ASID + endpoint) for provider ODS sds = SdsClient( @@ -267,47 +122,23 @@ def _get_sds_details( provider_details: SdsSearchResults | None = sds.get_org_details(provider_ods) if provider_details is None: - raise RequestError( - status_code=404, - message=f"No SDS org found for provider ODS code {provider_ods}", - ) + raise NoOrganisationFound(org_type="provider", ods_code=provider_ods) provider_asid = (provider_details.asid or "").strip() if not provider_asid: - raise RequestError( - status_code=404, - message=( - f"SDS result for provider ODS code {provider_ods} did not contain " - "a current ASID" - ), - ) + raise NoAsidFound(org_type="provider", ods_code=provider_ods) provider_endpoint = (provider_details.endpoint or "").strip() if not provider_endpoint: - raise RequestError( - status_code=404, - message=( - f"SDS result for provider ODS code {provider_ods} did not contain " - "a current endpoint" - ), - ) + raise NoCurrentEndpoint(provider_ods=provider_ods) # SDS: Get consumer details (ASID) for consumer ODS consumer_details: SdsSearchResults | None = sds.get_org_details(consumer_ods) if consumer_details is None: - raise RequestError( - status_code=404, - message=f"No SDS org found for consumer ODS code {consumer_ods}", - ) + raise NoOrganisationFound(org_type="consumer", ods_code=consumer_ods) consumer_asid = (consumer_details.asid or "").strip() if not consumer_asid: - raise RequestError( - status_code=404, - message=( - f"SDS result for consumer ODS code {consumer_ods} did not contain " - "a current ASID" - ), - ) + raise NoAsidFound(org_type="consumer", ods_code=consumer_ods) return consumer_asid, provider_asid, provider_endpoint diff --git a/gateway-api/src/gateway_api/get_structured_record/__init__.py b/gateway-api/src/gateway_api/get_structured_record/__init__.py index 56dd174d..456f2a4e 100644 --- a/gateway-api/src/gateway_api/get_structured_record/__init__.py +++ b/gateway-api/src/gateway_api/get_structured_record/__init__.py @@ -1,8 +1,5 @@ """Get Structured Record module.""" -from gateway_api.get_structured_record.request import ( - GetStructuredRecordRequest, - RequestValidationError, -) +from gateway_api.get_structured_record.request import GetStructuredRecordRequest -__all__ = ["RequestValidationError", "GetStructuredRecordRequest"] +__all__ = ["GetStructuredRecordRequest"] diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index c4279272..8b7edb64 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -4,17 +4,15 @@ from fhir import OperationOutcome, Parameters from fhir.operation_outcome import OperationOutcomeIssue from flask.wrappers import Request, Response +from werkzeug.exceptions import BadRequest from gateway_api.common.common import FlaskResponse +from gateway_api.common.error import InvalidRequestJSON, MissingOrEmptyHeader if TYPE_CHECKING: from fhir.bundle import Bundle -class RequestValidationError(Exception): - """Exception raised for errors in the request validation.""" - - class GetStructuredRecordRequest: INTERACTION_ID: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" RESOURCE: str = "patient" @@ -23,11 +21,14 @@ class GetStructuredRecordRequest: def __init__(self, request: Request) -> None: self._http_request = request self._headers = request.headers - self._request_body: Parameters = request.get_json() + try: + self._request_body: Parameters = request.get_json() + except BadRequest as error: + raise InvalidRequestJSON() from error + self._response_body: Bundle | OperationOutcome | None = None self._status_code: int | None = None - # Validate required headers self._validate_headers() @property @@ -50,19 +51,14 @@ def request_body(self) -> str: return json.dumps(self._request_body) def _validate_headers(self) -> None: - """Validate required headers are present and non-empty. - - :raises RequestValidationError: If required headers are missing or empty. - """ + """Validate required headers are present and non-empty.""" trace_id = self._headers.get("Ssp-TraceID", "").strip() if not trace_id: - raise RequestValidationError( - 'Missing or empty required header "Ssp-TraceID"' - ) + raise MissingOrEmptyHeader(header="Ssp-TraceID") ods_from = self._headers.get("ODS-from", "").strip() if not ods_from: - raise RequestValidationError('Missing or empty required header "ODS-from"') + raise MissingOrEmptyHeader(header="ODS-from") def build_response(self) -> Response: return Response( diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py index 6fa5f9a2..c6565953 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_request.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -7,7 +7,7 @@ from werkzeug.test import EnvironBuilder from gateway_api.common.common import FlaskResponse -from gateway_api.get_structured_record import RequestValidationError +from gateway_api.common.error import BaseError from gateway_api.get_structured_record.request import GetStructuredRecordRequest if TYPE_CHECKING: @@ -80,7 +80,7 @@ def test_raises_value_error_when_ods_from_header_is_missing( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - RequestValidationError, match='Missing or empty required header "ODS-from"' + BaseError, match='Missing or empty required header "ODS-from"' ): GetStructuredRecordRequest(request=mock_request) @@ -97,7 +97,7 @@ def test_raises_value_error_when_ods_from_header_is_whitespace( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - RequestValidationError, match='Missing or empty required header "ODS-from"' + BaseError, match='Missing or empty required header "ODS-from"' ): GetStructuredRecordRequest(request=mock_request) @@ -111,7 +111,7 @@ def test_raises_value_error_when_trace_id_header_is_missing( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - RequestValidationError, + BaseError, match='Missing or empty required header "Ssp-TraceID"', ): GetStructuredRecordRequest(request=mock_request) @@ -129,7 +129,7 @@ def test_raises_value_error_when_trace_id_header_is_whitespace( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - RequestValidationError, + BaseError, match='Missing or empty required header "Ssp-TraceID"', ): GetStructuredRecordRequest(request=mock_request) diff --git a/gateway-api/src/gateway_api/pds/__init__.py b/gateway-api/src/gateway_api/pds/__init__.py new file mode 100644 index 00000000..7c687699 --- /dev/null +++ b/gateway-api/src/gateway_api/pds/__init__.py @@ -0,0 +1,9 @@ +"""PDS (Personal Demographics Service) client and data structures.""" + +from gateway_api.pds.client import PdsClient +from gateway_api.pds.search_results import PdsSearchResults + +__all__ = [ + "PdsClient", + "PdsSearchResults", +] diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py new file mode 100644 index 00000000..65d00c70 --- /dev/null +++ b/gateway-api/src/gateway_api/pds/client.py @@ -0,0 +1,264 @@ +""" +PDS (Personal Demographics Service) FHIR R4 patient lookup client. + +Contracts enforced by the helper functions: + +* ``Patient.name[]`` records passed to :func:`find_current_name_record` must contain:: + + record["period"]["start"] + record["period"]["end"] + +* ``Patient.generalPractitioner[]`` records passed to :func:`find_current_record` must + contain:: + + record["identifier"]["period"]["start"] + record["identifier"]["period"]["end"] + +If required keys are missing, a ``KeyError`` is raised intentionally. This is treated as +malformed upstream data (or malformed test fixtures) and should be corrected at source. +""" + +import os +import uuid +from datetime import date, datetime, timezone +from typing import cast + +import requests +from fhir import Bundle, BundleEntry, GeneralPractitioner, HumanName, Patient + +from gateway_api.common.error import PdsRequestFailed +from gateway_api.pds.search_results import PdsSearchResults + +# TODO: Once stub servers/containers made for PDS, SDS and provider +# we should remove the STUB_PDS environment variable and just +# use the stub client +STUB_PDS = os.environ.get("STUB_PDS", "false").lower() == "true" +if not STUB_PDS: + post = requests.post +else: + from stubs.pds.stub import PdsFhirApiStub + + pds = PdsFhirApiStub() + post = pds.post # type: ignore + + +class PdsClient: + """ + Simple client for PDS FHIR R4 patient retrieval. + + The client currently supports one operation: + + * :meth:`search_patient_by_nhs_number` - calls ``GET /Patient/{nhs_number}`` + + This method returns a :class:`PdsSearchResults` instance when a patient can be + extracted, otherwise ``None``. + + **Usage example**:: + + pds = PdsClient( + auth_token="YOUR_ACCESS_TOKEN", + base_url="https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4", + ) + + result = pds.search_patient_by_nhs_number(9000000009) + + if result: + print(result) + """ + + # URLs for different PDS environments. Requires authentication to use live. + SANDBOX_URL = "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4" + INT_URL = "https://int.api.service.nhs.uk/personal-demographics/FHIR/R4" + PROD_URL = "https://api.service.nhs.uk/personal-demographics/FHIR/R4" + + def __init__( + self, + auth_token: str, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ignore_dates: bool = False, + ) -> None: + self.auth_token = auth_token + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.ignore_dates = ignore_dates + + def _build_headers( + self, + request_id: str | None = None, + correlation_id: str | None = None, + ) -> dict[str, str]: + """ + Build mandatory and optional headers for a PDS request. + """ + headers = { + "X-Request-ID": request_id or str(uuid.uuid4()), + "Accept": "application/fhir+json", + "Authorization": f"Bearer {self.auth_token}", + } + + if correlation_id: + headers["X-Correlation-ID"] = correlation_id + + return headers + + def search_patient_by_nhs_number( + self, + nhs_number: str, + request_id: str | None = None, + correlation_id: str | None = None, + timeout: int | None = None, + ) -> PdsSearchResults: + """ + Retrieve a patient by NHS number. + + Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient + resource on success, then extracts a single :class:`PdsSearchResults`. + """ + headers = self._build_headers( + request_id=request_id, + correlation_id=correlation_id, + ) + + url = f"{self.base_url}/Patient/{nhs_number}" + + # This normally calls requests.get, but if STUB_PDS is set it uses the stub. + response = post( + url, # TODO: URL points to sandbox env even when STUB_PDS + # is true, should we change this to point to the stub instead? + headers=headers, + params={}, + timeout=timeout or self.timeout, + ) + + try: + response.raise_for_status() + except requests.HTTPError as err: + raise PdsRequestFailed(error_reason=err.response.reason) from err + + body = response.json() + return self._extract_single_search_result(body) + + # --------------- internal helpers for result extraction ----------------- + + def _get_gp_ods_code( + self, general_practitioners: list[GeneralPractitioner] + ) -> str | None: + """ + Extract the current GP ODS code from ``Patient.generalPractitioner``. + + This function implements the business rule: + + * If the list is empty, return ``None``. + * If the list is non-empty and no record is current, return ``None``. + * If exactly one record is current, return its ``identifier.value``. + + In future this may change to return the most recent record if none is current. + """ + if len(general_practitioners) == 0: + return None + + gp = self.find_current_gp(general_practitioners) + if gp is None: + return None + + ods_code = gp["identifier"]["value"] + + return None if ods_code == "None" else ods_code + + def _extract_single_search_result(self, body: Patient | Bundle) -> PdsSearchResults: + """ + Extract a single :class:`PdsSearchResults` from a Patient response. + + This helper accepts either: + * a single FHIR Patient resource (as returned by ``GET /Patient/{id}``), or + * a FHIR Bundle containing Patient entries (as typically returned by searches). + + For Bundle inputs, the code assumes either zero matches (empty entry list) or a + single match; if multiple entries are present, the first entry is used. + """ + # Accept either: + # 1) Patient (GET /Patient/{id}) + # 2) Bundle with Patient in entry[0].resource (search endpoints) + if str(body.get("resourceType", "")) == "Patient": + patient = cast("Patient", body) + else: + entries = cast("list[BundleEntry]", body.get("entry", [])) + if not entries: + raise RuntimeError("PDS response contains no patient entries") + + # Use the first patient entry. Search by NHS number is unique. Search by + # demographics for an application is allowed to return max one entry from + # PDS. Search by a human can return more, but presumably we count as an + # application. + # See MaxResults parameter in the PDS OpenAPI spec. + entry = entries[0] + patient = cast("Patient", entry.get("resource", {})) + + nhs_number = str(patient.get("id", "")).strip() + if not nhs_number: + raise RuntimeError("PDS patient resource missing NHS number") + + current_name = self.find_current_name_record(patient["name"]) + + if current_name is not None: + given_names = " ".join(current_name.get("given", [])).strip() + family_name = current_name.get("family", "") + else: + given_names = "" + family_name = "" + + # Extract GP ODS code if a current GP record exists. + gp_ods_code = self._get_gp_ods_code(patient.get("generalPractitioner", [])) + + return PdsSearchResults( + given_names=given_names, + family_name=family_name, + nhs_number=nhs_number, + gp_ods_code=gp_ods_code, + ) + + def find_current_gp( + self, + general_practitioners: list[GeneralPractitioner], + today: date | None = None, + ) -> GeneralPractitioner | None: + if today is None: + today = datetime.now(timezone.utc).date() + + if self.ignore_dates: + if len(general_practitioners) > 0: + return general_practitioners[-1] + else: + return None + + for record in general_practitioners: + period = record["identifier"]["period"] + start = date.fromisoformat(period["start"]) + # TODO: period is not required to have end + end = date.fromisoformat(period["end"]) + if start <= today <= end: + return record + + return None + + def find_current_name_record( + self, names: list[HumanName], today: date | None = None + ) -> HumanName | None: + if today is None: + today = datetime.now(timezone.utc).date() + + if self.ignore_dates: + if len(names) > 0: + return names[-1] + else: + return None + + for name in names: + period = cast("dict[str, str]", name["period"]) + start = date.fromisoformat(period["start"]) + end = date.fromisoformat(period["end"]) + if start <= today <= end: + return name + + return None diff --git a/gateway-api/src/gateway_api/pds/search_results.py b/gateway-api/src/gateway_api/pds/search_results.py new file mode 100644 index 00000000..331a476d --- /dev/null +++ b/gateway-api/src/gateway_api/pds/search_results.py @@ -0,0 +1,18 @@ +"""PDS search result data structures.""" + +from dataclasses import dataclass + + +@dataclass +class PdsSearchResults: + """ + A single extracted patient record. + + Only a small subset of the PDS Patient fields are currently required by this + gateway. More will be added in later phases. + """ + + given_names: str + family_name: str + nhs_number: str + gp_ods_code: str | None diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py new file mode 100644 index 00000000..e8c5469b --- /dev/null +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -0,0 +1,395 @@ +""" +Unit tests for :mod:`gateway_api.pds_search`. +""" + +from dataclasses import dataclass +from datetime import date +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +import pytest +import requests +from fhir import OperationOutcome, Patient +from pytest_mock import MockerFixture +from requests.structures import CaseInsensitiveDict + +from gateway_api.common.error import PdsRequestFailed +from gateway_api.pds.client import PdsClient + +if TYPE_CHECKING: + from fhir import GeneralPractitioner, HumanName + + +@dataclass +class FakeResponse: + """ + Minimal substitute for :class:`requests.Response` used by tests. + + Only the methods accessed by :class:`gateway_api.pds_search.PdsClient` are + implemented. + """ + + status_code: int + headers: dict[str, str] | CaseInsensitiveDict[str] + _json: dict[str, Any] | Patient | OperationOutcome + reason: str = "" + + def json(self) -> dict[str, Any] | Patient | OperationOutcome: + return self._json + + def raise_for_status(self) -> None: + if self.status_code != 200: + err = requests.HTTPError(f"{self.status_code} Error") + # requests attaches a Response to HTTPError.response; the client expects it + err.response = self + raise err + + +def test_search_patient_by_nhs_number_happy_path( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, +) -> None: + happy_path_response = FakeResponse( + status_code=200, headers={}, _json=happy_path_pds_response_body + ) + mocker.patch("gateway_api.pds.client.post", return_value=happy_path_response) + + client = PdsClient(auth_token) + result = client.search_patient_by_nhs_number("9999999999") + + assert result is not None + assert result.nhs_number == "9999999999" + assert result.family_name == "Johnson" + assert result.given_names == "Alice" + assert result.gp_ods_code == "A12345" + + +def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, +) -> None: + gp_less_response_body = happy_path_pds_response_body.copy() + del gp_less_response_body["generalPractitioner"] + gp_less_response = FakeResponse( + status_code=200, headers={}, _json=gp_less_response_body + ) + mocker.patch("gateway_api.pds.client.post", return_value=gp_less_response) + + client = PdsClient(auth_token) + result = client.search_patient_by_nhs_number("9999999999") + + assert result is not None + assert result.nhs_number == "9999999999" + assert result.family_name == "Johnson" + assert result.given_names == "Alice" + assert result.gp_ods_code is None + + +def test_search_patient_by_nhs_number_sends_expected_headers( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, +) -> None: + happy_path_response = FakeResponse( + status_code=200, headers={}, _json=happy_path_pds_response_body + ) + mocked_post = mocker.patch( + "gateway_api.pds.client.post", return_value=happy_path_response + ) + + request_id = str(uuid4()) + correlation_id = "corr-123" + + client = PdsClient(auth_token) + _ = client.search_patient_by_nhs_number( + "9000000009", + request_id=request_id, + correlation_id=correlation_id, + ) + + expected_headers = { + "Authorization": f"Bearer {auth_token}", + "Accept": "application/fhir+json", + "X-Request-ID": request_id, + "X-Correlation-ID": correlation_id, + } + + assert mocked_post.call_args.kwargs["headers"] == expected_headers + + +def test_search_patient_by_nhs_number_generates_request_id( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, +) -> None: + happy_path_response = FakeResponse( + status_code=200, headers={}, _json=happy_path_pds_response_body + ) + mocked_post = mocker.patch( + "gateway_api.pds.client.post", return_value=happy_path_response + ) + + client = PdsClient(auth_token) + + _ = client.search_patient_by_nhs_number("9000000009") + + try: + _ = UUID(mocked_post.call_args.kwargs["headers"]["X-Request-ID"], version=4) + except ValueError: + pytest.fail("X-Request-ID is not a valid UUID4") + + +def test_search_patient_by_nhs_number_not_found_raises_error( + auth_token: str, + mocker: MockerFixture, +) -> None: + not_found_response = FakeResponse( + status_code=404, + headers={}, + _json={"resourceType": "OperationOutcome", "issue": []}, + reason="Not Found", + ) + mocker.patch("gateway_api.pds.client.post", return_value=not_found_response) + pds = PdsClient(auth_token) + + with pytest.raises( + PdsRequestFailed, match="PDS FHIR API request failed: Not Found" + ): + pds.search_patient_by_nhs_number("9900000001") + + +def test_search_patient_by_nhs_number_finds_current_gp_ods_code_when_pds_returns_two( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, +) -> None: + old_gp: GeneralPractitioner = { + "id": "1", + "type": "Organization", + "identifier": { + "value": "OLDGP", + "period": {"start": "2010-01-01", "end": "2012-01-01"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + current_gp: GeneralPractitioner = { + "id": "2", + "type": "Organization", + "identifier": { + "value": "CURRGP", + "period": {"start": "2020-01-01", "end": "9999-01-01"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + pds_response_body_with_two_gps = happy_path_pds_response_body.copy() + pds_response_body_with_two_gps["generalPractitioner"] = [old_gp, current_gp] + pds_response_with_two_gps = FakeResponse( + status_code=200, headers={}, _json=pds_response_body_with_two_gps + ) + mocker.patch("gateway_api.pds.client.post", return_value=pds_response_with_two_gps) + + client = PdsClient(auth_token) + + result = client.search_patient_by_nhs_number("9999999999") + assert result is not None + assert result.nhs_number == "9999999999" + assert result.family_name == "Johnson" + assert result.given_names == "Alice" + assert result.gp_ods_code == "CURRGP" + + +def test_find_current_gp_with_today_override() -> None: + """ + Verify that ``find_current_gp`` honours an explicit ``today`` value. + """ + pds = PdsClient("test-token", "A12345") + pds_ignore_dates = PdsClient("test-token", "A12345", ignore_dates=True) + + records: list[GeneralPractitioner] = [ + { + "id": "1234", + "type": "Organization", + "identifier": { + "value": "a", + "period": {"start": "2020-01-01", "end": "2020-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + }, + { + "id": "abcd", + "type": "Organization", + "identifier": { + "value": "b", + "period": {"start": "2021-01-01", "end": "2021-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + }, + ] + + assert pds.find_current_gp(records, today=date(2020, 6, 1)) == records[0] + assert pds.find_current_gp(records, today=date(2021, 6, 1)) == records[1] + assert pds.find_current_gp(records, today=date(2019, 6, 1)) is None + assert pds_ignore_dates.find_current_gp(records, today=date(2019, 6, 1)) is not None + + +def test_find_current_name_record_no_current_name() -> None: + """ + Verify that ``find_current_name_record`` returns ``None`` when no current name + exists. + """ + pds = PdsClient("test-token", "A12345") + pds_ignore_date = PdsClient("test-token", "A12345", ignore_dates=True) + + records: list[HumanName] = [ + { + "use": "official", + "family": "Doe", + "given": ["John"], + "period": {"start": "2000-01-01", "end": "2010-12-31"}, + }, + { + "use": "official", + "family": "Smith", + "given": ["John"], + "period": {"start": "2011-01-01", "end": "2020-12-31"}, + }, + ] + + assert pds.find_current_name_record(records) is None + assert pds_ignore_date.find_current_name_record(records) is not None + + +def test_extract_single_search_result_invalid_body_raises_runtime_error() -> None: + """ + Verify that ``PdsClient._extract_single_search_result`` raises ``RuntimeError`` when + mandatory patient content is missing. + + This test asserts that a ``RuntimeError`` is raised when: + + * The body is a bundle containing no entries (``entry`` is empty). + * The body is a patient resource with no NHS number (missing/blank ``id``). + * The body is a patient resource with an NHS number, + but the patient has no *current* + """ + client = PdsClient( + auth_token="test-token", # noqa: S106 (test token hardcoded) + base_url="https://example.test/personal-demographics/FHIR/R4", + ) + + # 1) Bundle contains no entries. + bundle_no_entries: Any = {"resourceType": "Bundle", "entry": []} + with pytest.raises(RuntimeError): + client._extract_single_search_result(bundle_no_entries) # noqa SLF001 (testing private method) + + # 2) Patient has no NHS number (Patient.id missing/blank). + patient_missing_nhs_number: Any = { + "resourceType": "Patient", + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"}, + } + ], + "generalPractitioner": [], + } + with pytest.raises(RuntimeError): + client._extract_single_search_result(patient_missing_nhs_number) # noqa SLF001 (testing private method) + + # 3) Bundle entry exists with NHS number, but no current name record. + bundle_no_current_name: Any = { + "resourceType": "Bundle", + "entry": [ + { + "resource": { + "resourceType": "Patient", + "id": "9000000009", + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "1900-12-31"}, + } + ], + "generalPractitioner": [], + } + } + ], + } + + # No current name record is tolerated by PdsClient; names are returned as empty. + result = client._extract_single_search_result(bundle_no_current_name) # noqa SLF001 (testing private method) + assert result is not None + assert result.nhs_number == "9000000009" + assert result.given_names == "" + assert result.family_name == "" + + +def test_find_current_name_record_ignore_dates_returns_last_or_none() -> None: + """ + If ignore_dates=True: + * returns the last name record even if none are current + * returns None when the list is empty + """ + pds_ignore = PdsClient("test-token", "A12345", ignore_dates=True) + + records: list[HumanName] = [ + { + "use": "official", + "family": "Old", + "given": ["First"], + "period": {"start": "1900-01-01", "end": "1900-12-31"}, + }, + { + "use": "official", + "family": "Newer", + "given": ["Second"], + "period": {"start": "1901-01-01", "end": "1901-12-31"}, + }, + ] + + # Pick a date that is not covered by any record; ignore_dates should still pick last + chosen = pds_ignore.find_current_name_record(records, today=date(2026, 1, 1)) + assert chosen == records[-1] + + assert pds_ignore.find_current_name_record([]) is None + + +def test_find_current_gp_ignore_dates_returns_last_or_none() -> None: + """ + If ignore_dates=True: + * returns the last GP record even if none are current + * returns None when the list is empty + """ + pds_ignore = PdsClient("test-token", "A12345", ignore_dates=True) + + records: list[GeneralPractitioner] = [ + { + "id": "abcd", + "type": "Organization", + "identifier": { + "value": "GP-OLD", + "period": {"start": "1900-01-01", "end": "1900-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + }, + { + "id": "1234", + "type": "Organization", + "identifier": { + "value": "GP-NEWER", + "period": {"start": "1901-01-01", "end": "1901-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + }, + ] + + # Pick a date that is not covered by any record; ignore_dates should still pick last + chosen = pds_ignore.find_current_gp(records, today=date(2026, 1, 1)) + assert chosen == records[-1] + + assert pds_ignore.find_current_gp([]) is None diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py deleted file mode 100644 index b21b6ecf..00000000 --- a/gateway-api/src/gateway_api/pds_search.py +++ /dev/null @@ -1,407 +0,0 @@ -""" -PDS (Personal Demographics Service) FHIR R4 patient lookup client. - -Contracts enforced by the helper functions: - -* ``Patient.name[]`` records passed to :func:`find_current_name_record` must contain:: - - record["period"]["start"] - record["period"]["end"] - -* ``Patient.generalPractitioner[]`` records passed to :func:`find_current_record` must - contain:: - - record["identifier"]["period"]["start"] - record["identifier"]["period"]["end"] - -If required keys are missing, a ``KeyError`` is raised intentionally. This is treated as -malformed upstream data (or malformed test fixtures) and should be corrected at source. -""" - -from __future__ import annotations - -import uuid -from collections.abc import Callable -from dataclasses import dataclass -from datetime import date, datetime, timezone -from typing import cast - -import requests -from stubs.stub_pds import PdsFhirApiStub - -# Recursive JSON-like structure typing used for parsed FHIR bodies. -type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] -type ResultStructureDict = dict[str, ResultStructure] -type ResultList = list[ResultStructureDict] - -# Type for stub get method -type GetCallable = Callable[..., requests.Response] - - -class ExternalServiceError(Exception): - """ - Raised when the downstream PDS request fails. - - This module catches :class:`requests.HTTPError` thrown by - ``response.raise_for_status()`` and re-raises it as ``ExternalServiceError`` so - callers are not coupled to ``requests`` exception types. - """ - - -@dataclass -class PdsSearchResults: - """ - A single extracted patient record. - - Only a small subset of the PDS Patient fields are currently required by this - gateway. More will be added in later phases. - - :param given_names: Given names from the *current* ``Patient.name`` record, - concatenated with spaces. - :param family_name: Family name from the *current* ``Patient.name`` record. - :param nhs_number: NHS number (``Patient.id``). - :param gp_ods_code: The ODS code of the *current* GP, extracted from - ``Patient.generalPractitioner[].identifier.value`` if a current GP record exists - otherwise ``None``. - """ - - given_names: str - family_name: str - nhs_number: str - gp_ods_code: str | None - - -class PdsClient: - """ - Simple client for PDS FHIR R4 patient retrieval. - - The client currently supports one operation: - - * :meth:`search_patient_by_nhs_number` - calls ``GET /Patient/{nhs_number}`` - - This method returns a :class:`PdsSearchResults` instance when a patient can be - extracted, otherwise ``None``. - - **Usage example**:: - - pds = PdsClient( - auth_token="YOUR_ACCESS_TOKEN", - end_user_org_ods="A12345", - base_url="https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4", - ) - - result = pds.search_patient_by_nhs_number(9000000009) - - if result: - print(result) - """ - - # URLs for different PDS environments. Requires authentication to use live. - SANDBOX_URL = "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4" - INT_URL = "https://int.api.service.nhs.uk/personal-demographics/FHIR/R4" - PROD_URL = "https://api.service.nhs.uk/personal-demographics/FHIR/R4" - - def __init__( - self, - auth_token: str, - end_user_org_ods: str, - base_url: str = SANDBOX_URL, - nhsd_session_urid: str | None = None, - timeout: int = 10, - ignore_dates: bool = False, - ) -> None: - """ - Create a PDS client. - - :param auth_token: OAuth2 bearer token (without the ``"Bearer "`` prefix). - :param end_user_org_ods: NHSD End User Organisation ODS code. - :param base_url: Base URL for the PDS API (one of :attr:`SANDBOX_URL`, - :attr:`INT_URL`, :attr:`PROD_URL`). Trailing slashes are stripped. - :param nhsd_session_urid: Optional ``NHSD-Session-URID`` header value. - :param timeout: Default timeout in seconds for HTTP calls. - :param ignore_dates: If ``True`` just get the most recent name or GP record, - ignoring the date ranges. - """ - self.auth_token = auth_token - self.end_user_org_ods = end_user_org_ods - self.base_url = base_url.rstrip("/") - self.nhsd_session_urid = nhsd_session_urid - self.timeout = timeout - self.ignore_dates = ignore_dates - self.stub = PdsFhirApiStub() - - # TODO: Put this back to using the environment variable - # if os.environ.get("STUB_PDS", None): - self.get_method: GetCallable = self.stub.get - # else: - # self.get_method: GetCallable = requests.get - - def _build_headers( - self, - request_id: str | None = None, - correlation_id: str | None = None, - ) -> dict[str, str]: - """ - Build mandatory and optional headers for a PDS request. - - :param request_id: Optional ``X-Request-ID``. If not supplied a new UUID is - generated. - :param correlation_id: Optional ``X-Correlation-ID`` for cross-system tracing. - :return: Dictionary of HTTP headers for the outbound request. - """ - headers = { - "X-Request-ID": request_id or str(uuid.uuid4()), - "NHSD-End-User-Organisation-ODS": self.end_user_org_ods, - "Accept": "application/fhir+json", - } - - # Trying to pass an auth token to the sandbox makes PDS unhappy - if self.base_url != self.SANDBOX_URL: - headers["Authorization"] = f"Bearer {self.auth_token}" - - # NHSD-Session-URID is required in some flows; include only if configured. - if self.nhsd_session_urid: - headers["NHSD-Session-URID"] = self.nhsd_session_urid - - # Correlation ID is used to track the same request across multiple systems. - # Can be safely omitted, mirrored back in response if included. - if correlation_id: - headers["X-Correlation-ID"] = correlation_id - - return headers - - def search_patient_by_nhs_number( - self, - nhs_number: str, - request_id: str | None = None, - correlation_id: str | None = None, - timeout: int | None = None, - ) -> PdsSearchResults | None: - """ - Retrieve a patient by NHS number. - - Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient - resource on success, then extracts a single :class:`PdsSearchResults`. - - :param nhs_number: NHS number to search for. - :param request_id: Optional request ID to reuse for retries; if not supplied a - UUID is generated. - :param correlation_id: Optional correlation ID for tracing. - :param timeout: Optional per-call timeout in seconds. If not provided, - :attr:`timeout` is used. - :return: A :class:`PdsSearchResults` instance if a patient can be extracted, - otherwise ``None``. - :raises ExternalServiceError: If the HTTP request returns an error status and - ``raise_for_status()`` raises :class:`requests.HTTPError`. - """ - headers = self._build_headers( - request_id=request_id, - correlation_id=correlation_id, - ) - - url = f"{self.base_url}/Patient/{nhs_number}" - - # This normally calls requests.get, but if STUB_PDS is set it uses the stub. - response = self.get_method( - url, - headers=headers, - params={}, - timeout=timeout or self.timeout, - ) - - try: - # In production, failures surface here (4xx/5xx -> HTTPError). - response.raise_for_status() - except requests.HTTPError as err: - raise ExternalServiceError( - f"PDS request failed: {err.response.reason}" - ) from err - - body = response.json() - return self._extract_single_search_result(body) - - # --------------- internal helpers for result extraction ----------------- - - def _get_gp_ods_code(self, general_practitioners: ResultList) -> str | None: - """ - Extract the current GP ODS code from ``Patient.generalPractitioner``. - - This function implements the business rule: - - * If the list is empty, return ``None``. - * If the list is non-empty and no record is current, return ``None``. - * If exactly one record is current, return its ``identifier.value``. - - In future this may change to return the most recent record if none is current. - - :param general_practitioners: List of ``generalPractitioner`` records from a - Patient resource. - :return: ODS code string if a current record exists, otherwise ``None``. - :raises KeyError: If a record is missing required ``identifier.period`` fields. - """ - if len(general_practitioners) == 0: - return None - - gp = self.find_current_gp(general_practitioners) - if gp is None: - return None - - identifier = cast("ResultStructureDict", gp.get("identifier", {})) - ods_code = str(identifier.get("value", None)) - - # Avoid returning the literal string "None" if identifier.value is absent. - return None if ods_code == "None" else ods_code - - def _extract_single_search_result( - self, body: ResultStructureDict - ) -> PdsSearchResults | None: - """ - Extract a single :class:`PdsSearchResults` from a Patient response. - - This helper accepts either: - * a single FHIR Patient resource (as returned by ``GET /Patient/{id}``), or - * a FHIR Bundle containing Patient entries (as typically returned by searches). - - For Bundle inputs, the code assumes either zero matches (empty entry list) or a - single match; if multiple entries are present, the first entry is used. - :param body: Parsed JSON body containing either a Patient resource or a Bundle - whose first entry contains a Patient resource under ``resource``. - :return: A populated :class:`PdsSearchResults` if extraction succeeds, otherwise - ``None``. - """ - # Accept either: - # 1) Patient (GET /Patient/{id}) - # 2) Bundle with Patient in entry[0].resource (search endpoints) - if str(body.get("resourceType", "")) == "Patient": - patient = body - else: - entries: ResultList = cast("ResultList", body.get("entry", [])) - if not entries: - raise RuntimeError("PDS response contains no patient entries") - - # Use the first patient entry. Search by NHS number is unique. Search by - # demographics for an application is allowed to return max one entry from - # PDS. Search by a human can return more, but presumably we count as an - # application. - # See MaxResults parameter in the PDS OpenAPI spec. - entry = entries[0] - patient = cast("ResultStructureDict", entry.get("resource", {})) - - nhs_number = str(patient.get("id", "")).strip() - if not nhs_number: - raise RuntimeError("PDS patient resource missing NHS number") - - # Select current name record and extract names. - names = cast("ResultList", patient.get("name", [])) - current_name = self.find_current_name_record(names) - - if current_name is not None: - given_names_list = cast("list[str]", current_name.get("given", [])) - family_name = str(current_name.get("family", "")) or "" - given_names_str = " ".join(given_names_list).strip() - else: - given_names_str = "" - family_name = "" - - # Extract GP ODS code if a current GP record exists. - gp_list = cast("ResultList", patient.get("generalPractitioner", [])) - gp_ods_code = self._get_gp_ods_code(gp_list) - - return PdsSearchResults( - given_names=given_names_str, - family_name=family_name, - nhs_number=nhs_number, - gp_ods_code=gp_ods_code, - ) - - def find_current_gp( - self, records: ResultList, today: date | None = None - ) -> ResultStructureDict | None: - """ - Select the current record from a ``generalPractitioner`` list. - - A record is "current" if its ``identifier.period`` covers ``today`` (inclusive): - - ``start <= today <= end`` - - Or else if self.ignore_dates is True, the last record in the list is returned. - - The list may be in any of the following states: - - * empty - * contains one or more records, none current - * contains one or more records, exactly one current - - :param records: List of ``generalPractitioner`` records. - :param today: Optional override date, intended for deterministic tests. - If not supplied, the current UTC date is used. - :return: The first record whose ``identifier.period`` covers ``today``, or - ``None`` if no record is current. - :raises KeyError: If required keys are missing for a record being evaluated. - :raises ValueError: If ``start`` or ``end`` are not valid ISO date strings. - """ - if today is None: - today = datetime.now(timezone.utc).date() - - if self.ignore_dates: - if len(records) > 0: - return records[-1] - else: - return None - - for record in records: - identifier = cast("ResultStructureDict", record["identifier"]) - periods = cast("dict[str, str]", identifier["period"]) - start_str = periods["start"] - end_str = periods["end"] - - start = date.fromisoformat(start_str) - end = date.fromisoformat(end_str) - - if start <= today <= end: - return record - - return None - - def find_current_name_record( - self, records: ResultList, today: date | None = None - ) -> ResultStructureDict | None: - """ - Select the current record from a ``Patient.name`` list. - - A record is "current" if its ``period`` covers ``today`` (inclusive): - - ``start <= today <= end`` - - Or else if self.ignore_dates is True, the last record in the list is returned. - - :param records: List of ``Patient.name`` records. - :param today: Optional override date, intended for deterministic tests. - If not supplied, the current UTC date is used. - :return: The first name record whose ``period`` covers ``today``, or ``None`` if - no record is current. - :raises KeyError: If required keys (``period.start`` / ``period.end``) are - missing. - :raises ValueError: If ``start`` or ``end`` are not valid ISO date strings. - """ - if today is None: - today = datetime.now(timezone.utc).date() - - if self.ignore_dates: - if len(records) > 0: - return records[-1] - else: - return None - - for record in records: - periods = cast("dict[str, str]", record["period"]) - start_str = periods["start"] - end_str = periods["end"] - - start = date.fromisoformat(start_str) - end = date.fromisoformat(end_str) - - if start <= today <= end: - return record - - return None diff --git a/gateway-api/src/gateway_api/provider/__init__.py b/gateway-api/src/gateway_api/provider/__init__.py new file mode 100644 index 00000000..1cc394f9 --- /dev/null +++ b/gateway-api/src/gateway_api/provider/__init__.py @@ -0,0 +1,7 @@ +"""Provider client for fetching structured patient records from GP systems.""" + +from gateway_api.provider.client import GpProviderClient + +__all__ = [ + "GpProviderClient", +] diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider/client.py similarity index 68% rename from gateway-api/src/gateway_api/provider_request.py rename to gateway-api/src/gateway_api/provider/client.py index a628dbcf..62f68043 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -22,11 +22,24 @@ The response from the provider FHIR API. """ -from collections.abc import Callable +import os from urllib.parse import urljoin -from requests import HTTPError, Response, post -from stubs.stub_provider import stub_post +from requests import HTTPError, Response + +from gateway_api.common.error import ProviderRequestFailed + +# TODO: Once stub servers/containers made for PDS, SDS and provider +# we should remove the STUB_PROVIDER environment variable and just +# use the stub client +STUB_PROVIDER = os.environ.get("STUB_PROVIDER", "false").lower() == "true" +if not STUB_PROVIDER: + from requests import post +else: + from stubs.provider.stub import GpProviderStub + + provider_stub = GpProviderStub() + post = provider_stub.post # type: ignore ARS_INTERACTION_ID = ( "urn:nhs:names:services:gpconnect:structured" @@ -37,20 +50,6 @@ ARS_FHIR_OPERATION = "$gpc.getstructuredrecord" TIMEOUT: int | None = None # None used for quicker dev, adjust as needed -# TODO: Put the environment variable check back in -# if os.environ.get("STUB_PROVIDER", None): -if True: # NOSONAR S5797 (Yes, I know it's always true, this is temporary) - # Direct all requests to the stub provider for steel threading in dev. - # Replace with `from requests import post` for real requests. - PostCallable = Callable[..., Response] - post: PostCallable = stub_post # type: ignore[no-redef] - - -class ExternalServiceError(Exception): - """ - Exception raised when the downstream GPProvider FHIR API request fails. - """ - class GpProviderClient: """ @@ -82,14 +81,6 @@ def __init__( def _build_headers(self, trace_id: str) -> dict[str, str]: """ Build the headers required for the GPProvider FHIR API request. - - Args: - trace_id (str): A unique identifier for the request. - - Returns: - dict[str, str]: A dictionary containing the headers for the request, - including content type, interaction ID, and ASIDs for the provider - and consumer. """ return { "Content-Type": "application/fhir+json", @@ -107,16 +98,6 @@ def access_structured_record( ) -> Response: """ Fetch a structured patient record from the GPProvider FHIR API. - - Args: - trace_id (str): A unique identifier for the request, passed in the headers. - body (str): The request body in FHIR format. - - Returns: - Response: The response from the GPProvider FHIR API. - - Raises: - ExternalServiceError: If the API request fails with an HTTP error. """ headers = self._build_headers(trace_id) @@ -134,8 +115,6 @@ def access_structured_record( try: response.raise_for_status() except HTTPError as err: - raise ExternalServiceError( - f"GPProvider FHIR API request failed:{err.response.reason}" - ) from err + raise ProviderRequestFailed(error_reason=err.response.reason) from err return response diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/provider/test_client.py similarity index 84% rename from gateway-api/src/gateway_api/test_provider_request.py rename to gateway-api/src/gateway_api/provider/test_client.py index 6441490a..c7368c6c 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -6,15 +6,17 @@ """ +import json from typing import Any import pytest +from fhir import Parameters from requests import Response from requests.structures import CaseInsensitiveDict -from stubs.stub_provider import GpProviderStub +from stubs.provider.stub import GpProviderStub -from gateway_api import provider_request -from gateway_api.provider_request import ExternalServiceError, GpProviderClient +from gateway_api.common.error import ProviderRequestFailed +from gateway_api.provider import GpProviderClient, client ars_interactionId = ( "urn:nhs:names:services:gpconnect:structured" @@ -37,9 +39,6 @@ def mock_request_post( This fixture intercepts calls to `requests.post` and routes them to the stub provider. It also captures the most recent request details, such as headers, body, and URL, for verification in tests. - - Returns: - dict[str, Any]: A dictionary containing the captured request details. """ capture: dict[str, Any] = {} @@ -60,12 +59,13 @@ def _fake_post( trace_id=headers.get("Ssp-TraceID", "dummy-trace-id"), body=data ) - monkeypatch.setattr(provider_request, "post", _fake_post) + monkeypatch.setattr(client, "post", _fake_post) return capture def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( mock_request_post: dict[str, Any], + valid_simple_request_payload: Parameters, ) -> None: """ Test that the `access_structured_record` method constructs the correct URL @@ -85,7 +85,9 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos consumer_asid=consumer_asid, ) - result = client.access_structured_record(trace_id, "body") + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) captured_url = mock_request_post.get("url", provider_endpoint) @@ -98,6 +100,7 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( mock_request_post: dict[str, Any], + valid_simple_request_payload: Parameters, ) -> None: """ Test that the `access_structured_record` method includes the correct headers @@ -126,7 +129,9 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 "Ssp-InteractionID": ars_interactionId, } - result = client.access_structured_record(trace_id, "body") + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) captured_headers = mock_request_post["headers"] @@ -136,6 +141,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 def test_valid_gpprovider_access_structured_record_with_correct_body_200( mock_request_post: dict[str, Any], + valid_simple_request_payload: Parameters, ) -> None: """ Test that the `access_structured_record` method includes the correct body @@ -149,7 +155,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( provider_endpoint = "https://test.com" trace_id = "some_uuid_value" - request_body = "some_FHIR_request_params" + request_body = json.dumps(valid_simple_request_payload) client = GpProviderClient( provider_endpoint=provider_endpoint, @@ -168,6 +174,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( def test_valid_gpprovider_access_structured_record_returns_stub_response_200( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) stub: GpProviderStub, + valid_simple_request_payload: Parameters, ) -> None: """ Test that the `access_structured_record` method returns the same response @@ -187,9 +194,13 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( consumer_asid=consumer_asid, ) - expected_response = stub.access_record_structured(trace_id, "body") + expected_response = stub.access_record_structured( + trace_id, json.dumps(valid_simple_request_payload) + ) - result = client.access_structured_record(trace_id, "body") + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) assert result.status_code == 200 assert result.content == expected_response.content @@ -199,7 +210,7 @@ def test_access_structured_record_raises_external_service_error( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) ) -> None: """ - Test that the `access_structured_record` method raises an `ExternalServiceError` + Test that the `access_structured_record` method raises an `SdsRequestFailed` when the GPProvider FHIR API request fails with an HTTP error. """ provider_asid = "200000001154" @@ -214,7 +225,7 @@ def test_access_structured_record_raises_external_service_error( ) with pytest.raises( - ExternalServiceError, - match="GPProvider FHIR API request failed:Bad Request", + ProviderRequestFailed, + match="Provider request failed: Bad Request", ): client.access_structured_record(trace_id, "body") diff --git a/gateway-api/src/gateway_api/sds/__init__.py b/gateway-api/src/gateway_api/sds/__init__.py new file mode 100644 index 00000000..8f6e5ec0 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/__init__.py @@ -0,0 +1,7 @@ +from gateway_api.sds.client import SdsClient +from gateway_api.sds.search_results import SdsSearchResults + +__all__ = [ + "SdsClient", + "SdsSearchResults", +] diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py new file mode 100644 index 00000000..e2c16224 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/client.py @@ -0,0 +1,35 @@ +from gateway_api.sds.search_results import SdsSearchResults + + +class SdsClient: + """ + Stub SDS client for obtaining ASID from ODS code. + + Replace this with the real one once it's implemented. + """ + + SANDBOX_URL = "https://example.invalid/sds" + + def __init__( + self, + auth_token: str, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ) -> None: + """ + Create an SDS client. + """ + self.auth_token = auth_token + self.base_url = base_url + self.timeout = timeout + + def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + """ + Retrieve SDS org details for a given ODS code. + + This is a placeholder implementation that always returns an ASID and endpoint. + """ + # Placeholder implementation + return SdsSearchResults( + asid=f"asid_{ods_code}", endpoint="https://example-provider.org/endpoint" + ) diff --git a/gateway-api/src/gateway_api/sds/search_results.py b/gateway-api/src/gateway_api/sds/search_results.py new file mode 100644 index 00000000..55dd3287 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/search_results.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + + +@dataclass +class SdsSearchResults: + """ + Stub SDS search results dataclass. + + Replace this with the real one once it's implemented. + """ + + asid: str + endpoint: str | None diff --git a/gateway-api/src/gateway_api/sds/test_client.py b/gateway-api/src/gateway_api/sds/test_client.py new file mode 100644 index 00000000..26105420 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/test_client.py @@ -0,0 +1,11 @@ +from gateway_api.sds.client import SdsClient + + +def test_sds_client(auth_token: str) -> None: + """Test that the SDS client returns the expected ASID and endpoint.""" + sds_client = SdsClient( + auth_token=auth_token, base_url="https://example.invalid/sds" + ) + result = sds_client.get_org_details("test_ods_code") + assert result is not None + assert result.asid == "asid_test_ods_code" diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index fdf77815..50f13051 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -3,21 +3,21 @@ import json import os from collections.abc import Generator +from copy import copy from typing import TYPE_CHECKING import pytest +from fhir.bundle import Bundle +from fhir.parameters import Parameters from flask import Flask from flask.testing import FlaskClient +from pytest_mock import MockerFixture from gateway_api.app import app, get_app_host, get_app_port -from gateway_api.controller import Controller -from gateway_api.get_structured_record.request import GetStructuredRecordRequest +from gateway_api.common.common import FlaskResponse if TYPE_CHECKING: - from fhir.parameters import Parameters - -if TYPE_CHECKING: - from fhir.parameters import Parameters + from fhir.operation_outcome import OperationOutcome @pytest.fixture @@ -54,30 +54,41 @@ def test_get_app_port_raises_runtime_error_if_port_not_set(self) -> None: class TestGetStructuredRecord: - def test_get_structured_record_returns_200_with_bundle( + @pytest.mark.usefixtures("mock_positive_return_value_from_controller_run") + def test_valid_get_structured_record_request_returns_bundle( self, - client: FlaskClient[Flask], - monkeypatch: pytest.MonkeyPatch, - valid_simple_request_payload: "Parameters", + get_structured_record_response: Flask, ) -> None: - """Test that successful controller response is returned correctly.""" - from datetime import datetime, timezone - from typing import Any - - from gateway_api.common.common import FlaskResponse - - # Mock the controller to return a successful FlaskResponse with a Bundle - mock_bundle_data: Any = { + expected_body = { "resourceType": "Bundle", "id": "example-patient-bundle", "type": "collection", - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": "2026-02-05T22:45:42.766330+00:00", "entry": [ { - "fullUrl": "http://example.com/Patient/9999999999", + "fullUrl": "https://example.com/Patient/9999999999", "resource": { + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": { + "start": "2020-01-01", + "end": "9999-12-31", + }, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + ], "name": [ - {"family": "Alice", "given": ["Johnson"], "use": "Ally"} + { + "family": "Alice", + "given": ["Johnson"], + "use": "Ally", + "period": {"start": "2020-01-01"}, + } ], "gender": "female", "birthDate": "1990-05-15", @@ -91,115 +102,192 @@ def test_get_structured_record_returns_200_with_bundle( ], } - def mock_run( - self: Controller, # noqa: ARG001 - request: GetStructuredRecordRequest, # noqa: ARG001 - ) -> FlaskResponse: - return FlaskResponse( - status_code=200, - data=json.dumps(mock_bundle_data), - headers={"Content-Type": "application/fhir+json"}, - ) - - monkeypatch.setattr( - "gateway_api.controller.Controller.run", - mock_run, - ) + actual_body = get_structured_record_response.get_json() + assert actual_body == expected_body - response = client.post( - "/patient/$gpc.getstructuredrecord", - json=valid_simple_request_payload, - headers={ - "Ssp-TraceID": "test-trace-id", - "ODS-from": "test-ods", - }, + @pytest.mark.usefixtures("mock_positive_return_value_from_controller_run") + def test_valid_get_structured_record_request_returns_200( + self, + get_structured_record_response: Flask, + ) -> None: + assert get_structured_record_response.status_code == 200 + + @pytest.mark.usefixtures("mock_raise_error_from_controller_run") + def test_get_structured_record_returns_500_when_an_uncaught_exception_is_raised( + self, + get_structured_record_response: Flask, + ) -> None: + actual_status_code = get_structured_record_response.status_code + assert actual_status_code == 500 + + @staticmethod + @pytest.fixture + def missing_headers( + request: pytest.FixtureRequest, valid_headers: dict[str, str] + ) -> dict[str, str]: + invalid_headers = copy(valid_headers) + del invalid_headers[request.param] + return invalid_headers + + @pytest.mark.parametrize( + "missing_headers", + ["ODS-from", "Ssp-TraceID"], + indirect=True, + ) + @pytest.mark.usefixtures("missing_headers") + def test_get_structured_record_returns_400_when_required_header_missing( + self, + get_structured_record_response_from_missing_header: Flask, + ) -> None: + assert get_structured_record_response_from_missing_header.status_code == 400 + + @pytest.mark.parametrize( + "missing_headers", + ["ODS-from", "Ssp-TraceID"], + indirect=True, + ) + @pytest.mark.usefixtures("missing_headers") + def test_get_structured_record_returns_fhir_content_when_missing_header( + self, + get_structured_record_response_from_missing_header: Flask, + ) -> None: + assert ( + "application/fhir+json" + in get_structured_record_response_from_missing_header.content_type ) - assert response.status_code == 200 - data = response.get_json() - assert isinstance(data, dict) - assert data.get("resourceType") == "Bundle" - assert data.get("id") == "example-patient-bundle" - assert data.get("type") == "collection" - assert "entry" in data - assert isinstance(data["entry"], list) - assert len(data["entry"]) > 0 - assert data["entry"][0]["resource"]["resourceType"] == "Patient" - assert data["entry"][0]["resource"]["id"] == "9999999999" - assert data["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" - - def test_get_structured_record_handles_exception( + @pytest.mark.parametrize( + ("missing_headers", "expected_message"), + [ + pytest.param( + "ODS-from", + 'Missing or empty required header "ODS-from"', + ), + pytest.param( + "Ssp-TraceID", + 'Missing or empty required header "Ssp-TraceID"', + ), + ], + indirect=["missing_headers"], + ) + def test_get_structured_record_returns_operation_outcome_when_missing_header( self, - client: FlaskClient[Flask], - monkeypatch: pytest.MonkeyPatch, - valid_simple_request_payload: "Parameters", + get_structured_record_response_from_missing_header: Flask, + expected_message: str, ) -> None: - """ - Test that exceptions during controller execution are caught and return 500. - """ - - # This is mocking the run method of the Controller - # and therefore self is a Controller - def mock_run_with_exception( - self: Controller, # noqa: ARG001 - request: GetStructuredRecordRequest, # noqa: ARG001 - ) -> None: - raise ValueError("Test exception") - - monkeypatch.setattr( - "gateway_api.controller.Controller.run", - mock_run_with_exception, + expected_body: OperationOutcome = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": expected_message, + } + ], + } + assert ( + expected_body + == get_structured_record_response_from_missing_header.get_json() ) + def test_get_structured_record_returns_400_when_invalid_json_sent( + self, get_structured_record_response_using_invalid_json_body: Flask + ) -> None: + assert get_structured_record_response_using_invalid_json_body.status_code == 400 + + def test_get_structured_record_returns_content_type_fhir_json_for_invalid_json_sent( + self, get_structured_record_response_using_invalid_json_body: Flask + ) -> None: + assert ( + "application/fhir+json" + in get_structured_record_response_using_invalid_json_body.content_type + ) + + def test_get_structured_record_returns_internal_server_error_when_invalid_json_sent( + self, get_structured_record_response_using_invalid_json_body: Flask + ) -> None: + expected: OperationOutcome = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": "Invalid JSON body sent in request", + } + ], + } + actual = get_structured_record_response_using_invalid_json_body.get_json() + assert actual == expected + + @staticmethod + @pytest.fixture + def get_structured_record_response( + client: FlaskClient[Flask], + valid_headers: dict[str, str], + valid_simple_request_payload: Parameters, + ) -> Flask: response = client.post( "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload, - headers={ - "Ssp-TraceID": "test-trace-id", - "ODS-from": "test-ods", - }, + headers=valid_headers, ) - assert response.status_code == 500 + return response - def test_get_structured_record_handles_request_validation_error( - self, + @staticmethod + @pytest.fixture + def get_structured_record_response_from_missing_header( client: FlaskClient[Flask], - valid_simple_request_payload: "Parameters", - ) -> None: - """Test that RequestValidationError returns 400 with error message.""" - # Create a request missing the required ODS-from header + missing_headers: dict[str, str], + valid_simple_request_payload: Parameters, + ) -> Flask: + response = client.post( "/patient/$gpc.getstructuredrecord", - json=valid_simple_request_payload, - headers={ - "Ssp-TraceID": "test-trace-id", - # Missing "ODS-from" header to trigger RequestValidationError - }, + data=json.dumps(valid_simple_request_payload), + headers=missing_headers, ) + return response - assert response.status_code == 400 - assert "text/plain" in response.content_type - assert b'Missing or empty required header "ODS-from"' in response.data - - def test_get_structured_record_handles_unexpected_exception_during_init( - self, + @staticmethod + @pytest.fixture + def get_structured_record_response_using_invalid_json_body( client: FlaskClient[Flask], - ) -> None: - """Test that unexpected exceptions during request init return 500.""" - # Send invalid JSON to trigger an exception during request processing + valid_headers: dict[str, str], + ) -> Flask: + invalid_json = "invalid json data" + response = client.post( "/patient/$gpc.getstructuredrecord", - data="invalid json data", - headers={ - "Ssp-TraceID": "test-trace-id", - "ODS-from": "test-ods", - "Content-Type": "application/fhir+json", - }, + data=invalid_json, + headers=valid_headers, + ) + return response + + @staticmethod + @pytest.fixture + def mock_positive_return_value_from_controller_run( + mocker: MockerFixture, + valid_headers: dict[str, str], + valid_simple_response_payload: Bundle, + ) -> None: + postive_response = FlaskResponse( + status_code=200, + data=json.dumps(valid_simple_response_payload), + headers=valid_headers, + ) + mocker.patch( + "gateway_api.controller.Controller.run", return_value=postive_response ) - assert response.status_code == 500 - assert "text/plain" in response.content_type - assert b"Internal Server Error:" in response.data + @staticmethod + @pytest.fixture + def mock_raise_error_from_controller_run( + mocker: MockerFixture, + ) -> None: + internal_error = ValueError("Test exception") + mocker.patch( + "gateway_api.controller.Controller.run", side_effect=internal_error + ) class TestHealthCheck: diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 3fc3ded4..776f2912 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -1,666 +1,211 @@ -""" -Unit tests for :mod:`gateway_api.controller`. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from types import SimpleNamespace -from typing import TYPE_CHECKING, Any +"""Unit tests for :mod:`gateway_api.controller`.""" import pytest -from flask import request as flask_request -from requests import Response - -import gateway_api.controller as controller_module -from gateway_api.app import app -from gateway_api.controller import ( - Controller, - SdsSearchResults, -) -from gateway_api.get_structured_record.request import GetStructuredRecordRequest - -if TYPE_CHECKING: - from collections.abc import Generator - - from gateway_api.common.common import json_str +from pytest_mock import MockerFixture +from gateway_api.common.error import ( + NoAsidFound, + NoCurrentEndpoint, + NoCurrentProvider, + NoOrganisationFound, +) +from gateway_api.controller import Controller +from gateway_api.pds import PdsSearchResults +from gateway_api.sds import SdsSearchResults -# ----------------------------- -# Fake downstream dependencies -# ----------------------------- -def _make_pds_result(gp_ods_code: str | None) -> Any: - """ - Construct a minimal PDS-result-like object for tests. - - The controller only relies on the ``gp_ods_code`` attribute. - - :param gp_ods_code: Provider ODS code to expose on the result. - :returns: An object with a ``gp_ods_code`` attribute. - """ - return SimpleNamespace(gp_ods_code=gp_ods_code) - - -class FakePdsClient: - """ - Test double for :class:`gateway_api.pds_search.PdsClient`. - - The controller instantiates this class and calls ``search_patient_by_nhs_number``. - Tests configure the returned patient details using ``set_patient_details``. - """ - - last_init: dict[str, Any] | None = None - - def __init__(self, **kwargs: Any) -> None: - FakePdsClient.last_init = dict(kwargs) - self._patient_details: Any | None = None - def set_patient_details(self, value: Any) -> None: - self._patient_details = value - - def search_patient_by_nhs_number( - self, - nhs_number: int, # noqa: ARG002 (unused in fake) - ) -> Any | None: - return self._patient_details - - -class FakeSdsClient: - """ - Test double for :class:`gateway_api.controller.SdsClient`. - - Tests configure per-ODS results using ``set_org_details`` and the controller - retrieves them via ``get_org_details``. - """ - - last_init: dict[str, Any] | None = None - - def __init__( - self, - auth_token: str | None = None, - base_url: str = "test_url", - timeout: int = 10, - ) -> None: - FakeSdsClient.last_init = { - "auth_token": auth_token, - "base_url": base_url, - "timeout": timeout, - } - self.auth_token = auth_token - self.base_url = base_url - self.timeout = timeout - self._org_details_by_ods: dict[str, SdsSearchResults | None] = {} - - def set_org_details( - self, ods_code: str, org_details: SdsSearchResults | None - ) -> None: - self._org_details_by_ods[ods_code] = org_details - - def get_org_details(self, ods_code: str) -> SdsSearchResults | None: - return self._org_details_by_ods.get(ods_code) - - -class FakeGpProviderClient: - """ - Test double for :class:`gateway_api.controller.GpProviderClient`. - - The controller instantiates this class and calls ``access_structured_record``. - Tests configure the returned HTTP response using class-level attributes. - """ - - last_init: dict[str, str] | None = None - last_call: dict[str, str] | None = None - - # Configure per-test. - return_none: bool = False - response_status_code: int = 200 - response_body: bytes = b"ok" - response_headers: dict[str, str] = {"Content-Type": "application/fhir+json"} - - def __init__( - self, provider_endpoint: str, provider_asid: str, consumer_asid: str - ) -> None: - FakeGpProviderClient.last_init = { - "provider_endpoint": provider_endpoint, - "provider_asid": provider_asid, - "consumer_asid": consumer_asid, - } - - def access_structured_record( - self, - trace_id: str, - body: json_str, - ) -> Response | None: - FakeGpProviderClient.last_call = {"trace_id": trace_id, "body": body} - - if FakeGpProviderClient.return_none: - return None - - resp = Response() - resp.status_code = FakeGpProviderClient.response_status_code - resp._content = FakeGpProviderClient.response_body # noqa: SLF001 - resp.encoding = "utf-8" - resp.headers.update(FakeGpProviderClient.response_headers) - resp.url = "https://example.invalid/fake" - return resp - - -@dataclass -class SdsSetup: - """ - Helper dataclass to hold SDS setup data for tests. - """ - - ods_code: str - search_results: SdsSearchResults - - -class sds_factory: - """ - Factory to create a :class:`FakeSdsClient` pre-configured with up to two - organisations. - """ - - def __init__( - self, - org1: SdsSetup | None = None, - org2: SdsSetup | None = None, - ) -> None: - self.org1 = org1 - self.org2 = org2 - - def __call__(self, **kwargs: Any) -> FakeSdsClient: - self.inst = FakeSdsClient(**kwargs) - if self.org1 is not None: - self.inst.set_org_details( - self.org1.ods_code, - SdsSearchResults( - asid=self.org1.search_results.asid, - endpoint=self.org1.search_results.endpoint, - ), - ) - - if self.org2 is not None: - self.inst.set_org_details( - self.org2.ods_code, - SdsSearchResults( - asid=self.org2.search_results.asid, - endpoint=self.org2.search_results.endpoint, - ), - ) - return self.inst - - -class pds_factory: - """ - Factory to create a :class:`FakePdsClient` pre-configured with patient details. - """ - - def __init__(self, ods_code: str | None) -> None: - self.ods_code = ods_code - - def __call__(self, **kwargs: Any) -> FakePdsClient: - self.inst = FakePdsClient(**kwargs) - self.inst.set_patient_details(_make_pds_result(self.ods_code)) - return self.inst - - -@pytest.fixture -def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: - """ - Patch controller dependencies to use test fakes. - """ - monkeypatch.setattr(controller_module, "PdsClient", FakePdsClient) - monkeypatch.setattr(controller_module, "SdsClient", FakeSdsClient) - monkeypatch.setattr(controller_module, "GpProviderClient", FakeGpProviderClient) - - -@pytest.fixture -def controller() -> Controller: - """ - Construct a controller instance configured for unit tests. - """ - return Controller( - pds_base_url="https://pds.example", - sds_base_url="https://sds.example", - nhsd_session_urid="session-123", - timeout=3, +def test_get_pds_details_returns_provider_ods_code_for_happy_path( + mocker: MockerFixture, + auth_token: str, +) -> None: + nhs_number = "9000000009" + pds_search_result = PdsSearchResults( + given_names="Jane", + family_name="Smith", + nhs_number=nhs_number, + gp_ods_code="A12345", ) + mocker.patch( + "gateway_api.pds.PdsClient.search_patient_by_nhs_number", + return_value=pds_search_result, + ) + controller = Controller(pds_base_url="https://example.test/pds", timeout=7) + actual = controller._get_pds_details(auth_token, nhs_number) # noqa: SLF001 -@pytest.fixture -def gp_provider_returns_none() -> Generator[None, None, None]: - """ - Configure FakeGpProviderClient to return None and reset after the test. - """ - FakeGpProviderClient.return_none = True - yield - FakeGpProviderClient.return_none = False - - -@pytest.fixture -def get_structured_record_request( - request: pytest.FixtureRequest, -) -> GetStructuredRecordRequest: - # Pass two dicts to this fixture that give dicts to add to - # header and body respectively. - header_update, body_update = request.param - - headers = { - "Ssp-TraceID": "3d7f2a6e-0f4e-4af3-9b7b-2a3d5f6a7b8c", - "ODS-from": "CONSUMER", - } - - headers.update(header_update) - - body = { - "resourceType": "Parameters", - "parameter": [ - { - "valueIdentifier": { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - }, - } - ], - } - - body.update(body_update) - - with app.test_request_context( - path="/patient/$gpc.getstructuredrecord", - method="POST", - headers=headers, - json=body, - ): - return GetStructuredRecordRequest(flask_request) - - -# ----------------------------- -# Unit tests -# ----------------------------- + assert actual == "A12345" -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_200_on_success( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_pds_details_raises_no_current_provider_when_ods_code_missing_in_pds( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - On successful end-to-end call, the controller should return 200 with - expected body/headers. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + nhs_number = "9000000009" + pds_search_result_without_ods_code = PdsSearchResults( + given_names="Jane", + family_name="Smith", + nhs_number=nhs_number, + gp_ods_code=None, ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + mocker.patch( + "gateway_api.pds.PdsClient.search_patient_by_nhs_number", + return_value=pds_search_result_without_ods_code, ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - FakeGpProviderClient.response_status_code = 200 - FakeGpProviderClient.response_body = b'{"resourceType":"Bundle"}' - FakeGpProviderClient.response_headers = { - "Content-Type": "application/fhir+json", - "X-Downstream": "gp-provider", - } - - r = controller.run(get_structured_record_request) - - # Check that response from GP provider was passed through. - assert r.status_code == 200 - assert r.data == FakeGpProviderClient.response_body.decode("utf-8") - assert r.headers == FakeGpProviderClient.response_headers - - # Check that GP provider was initialised correctly - assert FakeGpProviderClient.last_init == { - "provider_endpoint": "https://provider.example/ep", - "provider_asid": "asid_PROV", - "consumer_asid": "asid_CONS", - } - - # Check that we passed the trace ID and body to the provider - assert FakeGpProviderClient.last_call == { - "trace_id": get_structured_record_request.trace_id, - "body": get_structured_record_request.request_body, - } - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_pds_patient_not_found( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If PDS returns no patient record, the controller should return 404. - """ - # FakePdsClient defaults to returning None => RequestError => 404 - r = controller.run(get_structured_record_request) - assert r.status_code == 404 - assert "No PDS patient found for NHS number" in (r.data or "") + controller = Controller() - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_gp_ods_code_missing( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If PDS returns a patient without a provider (GP) ODS code, return 404. - """ - pds = pds_factory(ods_code="") - monkeypatch.setattr(controller_module, "PdsClient", pds) - - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert "did not contain a current provider ODS code" in (r.data or "") + with pytest.raises( + NoCurrentProvider, + match="PDS patient 9000000009 did not contain a current provider ODS code", + ): + _ = controller._get_pds_details(auth_token, nhs_number) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_returns_none_for_provider( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_sds_details_returns_consumer_and_provider_deatils_for_happy_path( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - If SDS returns no provider org details, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds = sds_factory() - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) + provider_ods = "ProviderODS" + provider_sds_results = SdsSearchResults( + asid="ProviderASID", endpoint="https://example.provider.org/endpoint" + ) + consumer_ods = "ConsumerODS" + consumer_sds_results = SdsSearchResults( + asid="ConsumerASID", endpoint="https://example.consumer.org/endpoint" + ) + sds_results = [provider_sds_results, consumer_sds_results] + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=sds_results, + ) - r = controller.run(get_structured_record_request) + controller = Controller() - assert r.status_code == 404 - assert r.data == "No SDS org found for provider ODS code PROVIDER" + expected = ("ConsumerASID", "ProviderASID", "https://example.provider.org/endpoint") + actual = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 + assert actual == expected -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_sds_details_raises_no_organisation_found_when_sds_returns_none( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - If provider ASID is blank/whitespace, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid=" ", endpoint="https://provider.example/ep" - ), + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + return_value=None, ) - sds = sds_factory(org1=sds_org1) - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) + controller = Controller() - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert "did not contain a current ASID" in (r.data or "") + with pytest.raises( + NoOrganisationFound, + match="No SDS org found for provider ODS code ProviderODS", + ): + _ = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_502_when_gp_provider_returns_none( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, - gp_provider_returns_none: None, # NOQA ARG001 (Fixture handling setup/teardown) +def test_get_sds_details_raises_no_asid_found_when_sds_returns_empty_asid( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - If GP provider returns no response object, the controller should return 502. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + blank_asid_sds_result = SdsSearchResults( + asid=" ", endpoint="https://example.provider.org/endpoint" ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + return_value=blank_asid_sds_result, ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) + controller = Controller() - r = controller.run(get_structured_record_request) - - assert r.status_code == 502 - assert r.data == "GP provider service error" - assert r.headers is None - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_constructs_pds_client_with_expected_kwargs( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - Validate that the controller constructs the PDS client with expected kwargs. - """ - _ = controller.run(get_structured_record_request) # will stop at PDS None => 404 - - assert FakePdsClient.last_init is not None - assert FakePdsClient.last_init["auth_token"] == "PLACEHOLDER_AUTH_TOKEN" # noqa: S105 - assert FakePdsClient.last_init["end_user_org_ods"] == "CONSUMER" - assert FakePdsClient.last_init["base_url"] == "https://pds.example" - assert FakePdsClient.last_init["nhsd_session_urid"] == "session-123" - assert FakePdsClient.last_init["timeout"] == 3 - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {"parameter": [{"valueIdentifier": {"value": "1234567890"}}]})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_404_message_includes_nhs_number_from_request_body( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If PDS returns no patient record, error message should include NHS number parsed - from the FHIR Parameters request body. - """ - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert r.data == "No PDS patient found for NHS number 1234567890" + with pytest.raises( + NoAsidFound, + match=( + "SDS result for provider ODS code ProviderODS did not contain " + "a current ASID" + ), + ): + _ = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_provider_endpoint_blank( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_sds_details_raises_no_current_endpoint_when_sds_returns_empty_endpoint( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - If provider endpoint is blank/whitespace, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults(asid="asid_PROV", endpoint=" "), + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + blank_endpoint_sds_result = SdsSearchResults(asid="ProviderASID", endpoint=" ") + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + return_value=blank_endpoint_sds_result, ) - sds = sds_factory(org1=sds_org1) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - r = controller.run(get_structured_record_request) - assert r.status_code == 404 - assert "did not contain a current endpoint" in (r.data or "") + controller = Controller() - -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If SDS returns no consumer org details, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" + with pytest.raises( + NoCurrentEndpoint, + match=( + "SDS result for provider ODS code ProviderODS did " + "not contain a current endpoint" ), - ) - sds = sds_factory(org1=sds_org1) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert r.data == "No SDS org found for consumer ODS code CONSUMER" + ): + _ = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_sds_details_raises_no_org_found_when_sds_returns_none_for_consumer( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - If consumer ASID is blank/whitespace, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + + happy_path_provider_sds_result = SdsSearchResults( + asid="ProviderASID", endpoint="https://example.provider.org/endpoint" ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid=" ", endpoint=None), + none_result_for_consumer = None + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=[happy_path_provider_sds_result, none_result_for_consumer], ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) + controller = Controller() - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert "did not contain a current ASID" in (r.data or "") + with pytest.raises( + NoOrganisationFound, + match="No SDS org found for consumer ODS code ConsumerODS", + ): + _ = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_passthroughs_non_200_gp_provider_response( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_sds_details_raises_no_asid_found_when_sds_returns_empty_consumer_asid( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - Validate that non-200 responses from GP provider are passed through. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + + happy_path_provider_sds_result = SdsSearchResults( + asid="ProviderASID", endpoint="https://example.provider.org/endpoint" ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + consumer_asid_blank_sds_result = SdsSearchResults( + asid=" ", endpoint="https://example.consumer.org/endpoint" + ) + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=[happy_path_provider_sds_result, consumer_asid_blank_sds_result], ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - FakeGpProviderClient.response_status_code = 404 - FakeGpProviderClient.response_body = b"Not Found" - FakeGpProviderClient.response_headers = { - "Content-Type": "text/plain", - "X-Downstream": "gp-provider", - } - r = controller.run(get_structured_record_request) + controller = Controller() - assert r.status_code == 404 - assert r.data == "Not Found" - assert r.headers is not None - assert r.headers.get("Content-Type") == "text/plain" - assert r.headers.get("X-Downstream") == "gp-provider" + with pytest.raises( + NoAsidFound, + match=( + "SDS result for consumer ODS code ConsumerODS did not contain " + "a current ASID" + ), + ): + _ = controller._get_sds_details(auth_token, consumer_ods, provider_ods) # noqa: SLF001 diff --git a/gateway-api/src/gateway_api/test_pds_search.py b/gateway-api/src/gateway_api/test_pds_search.py deleted file mode 100644 index a433c9a1..00000000 --- a/gateway-api/src/gateway_api/test_pds_search.py +++ /dev/null @@ -1,637 +0,0 @@ -""" -Unit tests for :mod:`gateway_api.pds_search`. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from datetime import date -from typing import TYPE_CHECKING, Any, cast -from uuid import uuid4 - -import pytest -import requests -from stubs.stub_pds import PdsFhirApiStub - -if TYPE_CHECKING: - from requests.structures import CaseInsensitiveDict - -from gateway_api.pds_search import ( - ExternalServiceError, - PdsClient, - ResultList, -) - - -@dataclass -class FakeResponse: - """ - Minimal substitute for :class:`requests.Response` used by tests. - - Only the methods accessed by :class:`gateway_api.pds_search.PdsClient` are - implemented. - - :param status_code: HTTP status code. - :param headers: Response headers (dict or CaseInsensitiveDict). - :param _json: Parsed JSON body returned by :meth:`json`. - """ - - status_code: int - headers: dict[str, str] | CaseInsensitiveDict[str] - _json: dict[str, Any] - reason: str = "" - - def json(self) -> dict[str, Any]: - """ - Return the response JSON body. - - :return: Parsed JSON body. - """ - return self._json - - def raise_for_status(self) -> None: - """ - Emulate :meth:`requests.Response.raise_for_status`. - - :return: ``None``. - :raises requests.HTTPError: If the response status is not 200. - """ - if self.status_code != 200: - err = requests.HTTPError(f"{self.status_code} Error") - # requests attaches a Response to HTTPError.response; the client expects it - err.response = self - raise err - - -@pytest.fixture -def stub() -> PdsFhirApiStub: - """ - Create a stub backend instance. - - :return: A :class:`stubs.stub_pds.PdsFhirApiStub` with strict header validation - enabled. - """ - # Strict header validation helps ensure PdsClient sends X-Request-ID correctly. - return PdsFhirApiStub(strict_headers=True) - - -@pytest.fixture -def mock_requests_get( - monkeypatch: pytest.MonkeyPatch, stub: PdsFhirApiStub -) -> dict[str, Any]: - """ - Patch ``PdsFhirApiStub`` so the PdsClient uses the test stub fixture. - - The fixture returns a "capture" dict recording the most recent request information. - This is used by header-related tests. - - :param monkeypatch: Pytest monkeypatch fixture. - :param stub: Stub backend used to serve GET requests. - :return: A capture dictionary containing the last call details - (url/headers/params/timeout). - """ - capture: dict[str, Any] = {} - - # Wrap the stub's get method to capture call parameters - original_stub_get = stub.get - - def _capturing_get( - url: str, - headers: dict[str, str] | None = None, - params: Any = None, - timeout: Any = None, - ) -> requests.Response: - """ - Wrapper around stub.get that captures parameters. - - :param url: URL passed by the client. - :param headers: Headers passed by the client. - :param params: Query parameters. - :param timeout: Timeout. - :return: Response from the stub. - """ - headers = headers or {} - capture["url"] = url - capture["headers"] = dict(headers) - capture["params"] = params - capture["timeout"] = timeout - - return original_stub_get(url, headers, params, timeout) - - stub.get = _capturing_get # type: ignore[method-assign] - - # Monkeypatch PdsFhirApiStub so PdsClient uses our test stub - import gateway_api.pds_search as pds_module - - monkeypatch.setattr( - pds_module, - "PdsFhirApiStub", - lambda *args, **kwargs: stub, # NOQA ARG005 (maintain signature) - ) - - return capture - - -def _insert_basic_patient( - stub: PdsFhirApiStub, - nhs_number: str, - family: str, - given: list[str], - general_practitioner: list[dict[str, Any]] | None = None, -) -> None: - """ - Insert a basic Patient record into the stub. - - :param stub: Stub backend to insert into. - :param nhs_number: NHS number (10-digit string). - :param family: Family name for the Patient.name record. - :param given: Given names for the Patient.name record. - :param general_practitioner: Optional list stored under - ``Patient.generalPractitioner``. - :return: ``None``. - """ - stub.upsert_patient( - nhs_number=nhs_number, - patient={ - "resourceType": "Patient", - "name": [ - { - "use": "official", - "family": family, - "given": given, - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "generalPractitioner": general_practitioner or [], - }, - version_id=1, - ) - - -def test_search_patient_by_nhs_number_get_patient_success( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) -) -> None: - """ - Verify ``GET /Patient/{nhs_number}`` returns 200 and demographics are extracted. - - This test explicitly inserts the patient into the stub and asserts that the client - returns a populated :class:`gateway_api.pds_search.PdsSearchResults`. - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture - (ensures patching is active). - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000009", - family="Smith", - given=["Jane"], - general_practitioner=[], - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 (test token hardcoded) - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - nhsd_session_urid="test-urid", - ) - - result = client.search_patient_by_nhs_number("9000000009") - - assert result is not None - assert result.nhs_number == "9000000009" - assert result.family_name == "Smith" - assert result.given_names == "Jane" - assert result.gp_ods_code is None - - -def test_search_patient_by_nhs_number_no_current_gp_returns_gp_ods_code_none( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) -) -> None: - """ - Verify that ``gp_ods_code`` is ``None`` when no GP record is current. - - The generalPractitioner list may be: - * empty - * non-empty with no current record - * non-empty with exactly one current record - - This test covers the "non-empty, none current" case by - inserting only a historical GP record. - - :param monkeypatch: Pytest monkeypatch fixture. - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture. - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000018", - family="Taylor", - given=["Ben"], - general_practitioner=[ - { - "id": "1", - "type": "Organization", - "identifier": { - "value": "OLDGP", - "period": {"start": "2010-01-01", "end": "2012-01-01"}, - }, - } - ], - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 (test token hardcoded) - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - result = client.search_patient_by_nhs_number("9000000018") - - assert result is not None - assert result.nhs_number == "9000000018" - assert result.family_name == "Taylor" - assert result.given_names == "Ben" - assert result.gp_ods_code is None - - -def test_search_patient_by_nhs_number_sends_expected_headers( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], -) -> None: - """ - Verify that the client sends the expected headers to PDS. - - Asserts that the request contains: - * Authorization header - * NHSD-End-User-Organisation-ODS header - * Accept header - * caller-provided X-Request-ID and X-Correlation-ID headers - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture capturing outbound - headers. - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000009", - family="Smith", - given=["Jane"], - general_practitioner=[], - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - req_id = str(uuid4()) - corr_id = "corr-123" - - result = client.search_patient_by_nhs_number( - "9000000009", - request_id=req_id, - correlation_id=corr_id, - ) - assert result is not None - - headers = mock_requests_get["headers"] - assert headers["Authorization"] == "Bearer test-token" - assert headers["NHSD-End-User-Organisation-ODS"] == "A12345" - assert headers["Accept"] == "application/fhir+json" - assert headers["X-Request-ID"] == req_id - assert headers["X-Correlation-ID"] == corr_id - - -def test_search_patient_by_nhs_number_generates_request_id( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], -) -> None: - """ - Verify that the client generates an X-Request-ID when not provided. - - The stub is in strict mode, so a missing or invalid X-Request-ID would cause a 400. - This test confirms a request ID is present and looks UUID-like. - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture capturing outbound - headers. - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000009", - family="Smith", - given=["Jane"], - general_practitioner=[], - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - result = client.search_patient_by_nhs_number("9000000009") - assert result is not None - - headers = mock_requests_get["headers"] - assert "X-Request-ID" in headers - assert isinstance(headers["X-Request-ID"], str) - assert len(headers["X-Request-ID"]) >= 32 - - -def test_search_patient_by_nhs_number_not_found_raises_error( - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) -) -> None: - """ - Verify that a 404 response results in :class:`ExternalServiceError`. - - The stub returns a 404 OperationOutcome for unknown NHS numbers. The client calls - ``raise_for_status()``, which raises ``requests.HTTPError``; the client wraps that - into :class:`ExternalServiceError`. - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture. - :return: ``None``. - """ - pds = PdsClient( - auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - with pytest.raises(ExternalServiceError): - pds.search_patient_by_nhs_number("9900000001") - - -def test_search_patient_by_nhs_number_extracts_current_gp_ods_code( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) -) -> None: - """ - Verify that a current GP record is selected and its ODS code returned. - - The test inserts a patient with two GP records: - * one historical (not current) - * one current (period covers today) - - :param monkeypatch: Pytest monkeypatch fixture. - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture. - :return: ``None``. - """ - stub.upsert_patient( - nhs_number="9000000017", - patient={ - "resourceType": "Patient", - "name": [ - { - "use": "official", - "family": "Taylor", - "given": ["Ben", "A."], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "generalPractitioner": [ - # Old - { - "id": "1", - "type": "Organization", - "identifier": { - "value": "OLDGP", - "period": {"start": "2010-01-01", "end": "2012-01-01"}, - }, - }, - # Current - { - "id": "2", - "type": "Organization", - "identifier": { - "value": "CURRGP", - "period": {"start": "2020-01-01", "end": "9999-01-01"}, - }, - }, - ], - }, - version_id=1, - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - result = client.search_patient_by_nhs_number("9000000017") - assert result is not None - assert result.nhs_number == "9000000017" - assert result.family_name == "Taylor" - assert result.given_names == "Ben A." - assert result.gp_ods_code == "CURRGP" - - -def test_find_current_gp_with_today_override() -> None: - """ - Verify that ``find_current_gp`` honours an explicit ``today`` value. - - :return: ``None``. - """ - pds = PdsClient("test-token", "A12345") - pds_ignore_dates = PdsClient("test-token", "A12345", ignore_dates=True) - - records = cast( - "ResultList", - [ - { - "identifier": { - "value": "a", - "period": {"start": "2020-01-01", "end": "2020-12-31"}, - } - }, - { - "identifier": { - "value": "b", - "period": {"start": "2021-01-01", "end": "2021-12-31"}, - } - }, - ], - ) - - assert pds.find_current_gp(records, today=date(2020, 6, 1)) == records[0] - assert pds.find_current_gp(records, today=date(2021, 6, 1)) == records[1] - assert pds.find_current_gp(records, today=date(2019, 6, 1)) is None - assert pds_ignore_dates.find_current_gp(records, today=date(2019, 6, 1)) is not None - - -def test_find_current_name_record_no_current_name() -> None: - """ - Verify that ``find_current_name_record`` returns ``None`` when no current name - exists. - - :return: ``None``. - """ - pds = PdsClient("test-token", "A12345") - pds_ignore_date = PdsClient("test-token", "A12345", ignore_dates=True) - - records = cast( - "ResultList", - [ - { - "use": "official", - "family": "Doe", - "given": ["John"], - "period": {"start": "2000-01-01", "end": "2010-12-31"}, - }, - { - "use": "official", - "family": "Smith", - "given": ["John"], - "period": {"start": "2011-01-01", "end": "2020-12-31"}, - }, - ], - ) - - assert pds.find_current_name_record(records) is None - assert pds_ignore_date.find_current_name_record(records) is not None - - -def test_extract_single_search_result_invalid_body_raises_runtime_error() -> None: - """ - Verify that ``PdsClient._extract_single_search_result`` raises ``RuntimeError`` when - mandatory patient content is missing. - - This test asserts that a ``RuntimeError`` is raised when: - - * The body is a bundle containing no entries (``entry`` is empty). - * The body is a patient resource with no NHS number (missing/blank ``id``). - * The body is a patient resource with an NHS number, - but the patient has no *current* - """ - client = PdsClient( - auth_token="test-token", # noqa: S106 (test token hardcoded) - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - # 1) Bundle contains no entries. - bundle_no_entries: Any = {"resourceType": "Bundle", "entry": []} - with pytest.raises(RuntimeError): - client._extract_single_search_result(bundle_no_entries) # noqa SLF001 (testing private method) - - # 2) Patient has no NHS number (Patient.id missing/blank). - patient_missing_nhs_number: Any = { - "resourceType": "Patient", - "name": [ - { - "use": "official", - "family": "Smith", - "given": ["Jane"], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "generalPractitioner": [], - } - with pytest.raises(RuntimeError): - client._extract_single_search_result(patient_missing_nhs_number) # noqa SLF001 (testing private method) - - # 3) Bundle entry exists with NHS number, but no current name record. - bundle_no_current_name: Any = { - "resourceType": "Bundle", - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "9000000009", - "name": [ - { - "use": "official", - "family": "Smith", - "given": ["Jane"], - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - } - ], - "generalPractitioner": [], - } - } - ], - } - - # No current name record is tolerated by PdsClient; names are returned as empty. - result = client._extract_single_search_result(bundle_no_current_name) # noqa SLF001 (testing private method) - assert result is not None - assert result.nhs_number == "9000000009" - assert result.given_names == "" - assert result.family_name == "" - - -def test_find_current_name_record_ignore_dates_returns_last_or_none() -> None: - """ - If ignore_dates=True: - * returns the last name record even if none are current - * returns None when the list is empty - """ - pds_ignore = PdsClient("test-token", "A12345", ignore_dates=True) - - records = cast( - "ResultList", - [ - { - "use": "official", - "family": "Old", - "given": ["First"], - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - }, - { - "use": "official", - "family": "Newer", - "given": ["Second"], - "period": {"start": "1901-01-01", "end": "1901-12-31"}, - }, - ], - ) - - # Pick a date that is not covered by any record; ignore_dates should still pick last - chosen = pds_ignore.find_current_name_record(records, today=date(2026, 1, 1)) - assert chosen == records[-1] - - assert pds_ignore.find_current_name_record(cast("ResultList", [])) is None - - -def test_find_current_gp_ignore_dates_returns_last_or_none() -> None: - """ - If ignore_dates=True: - * returns the last GP record even if none are current - * returns None when the list is empty - """ - pds_ignore = PdsClient("test-token", "A12345", ignore_dates=True) - - records = cast( - "ResultList", - [ - { - "identifier": { - "value": "GP-OLD", - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - } - }, - { - "identifier": { - "value": "GP-NEWER", - "period": {"start": "1901-01-01", "end": "1901-12-31"}, - } - }, - ], - ) - - # Pick a date that is not covered by any record; ignore_dates should still pick last - chosen = pds_ignore.find_current_gp(records, today=date(2026, 1, 1)) - assert chosen == records[-1] - - assert pds_ignore.find_current_gp(cast("ResultList", [])) is None diff --git a/gateway-api/stubs/stubs/data/__init__.py b/gateway-api/stubs/stubs/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/stubs/stubs/data/bundles/__init__.py b/gateway-api/stubs/stubs/data/bundles/__init__.py new file mode 100644 index 00000000..d714c29d --- /dev/null +++ b/gateway-api/stubs/stubs/data/bundles/__init__.py @@ -0,0 +1,20 @@ +from typing import Any + +from stubs.data.patients import Patients + + +class Bundles: + @staticmethod + def _wrap_patient_in_bundle(patient: dict[str, Any]) -> dict[str, Any]: + return { + "resourceType": "Bundle", + "type": "collection", + "meta": { + "profile": [ + "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1" + ] + }, + "entry": [{"resource": patient}], + } + + ALICE_JONES_9999999999 = _wrap_patient_in_bundle(Patients.ALICE_JONES_9999999999) diff --git a/gateway-api/stubs/stubs/data/patients/__init__.py b/gateway-api/stubs/stubs/data/patients/__init__.py new file mode 100644 index 00000000..a1595f52 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/__init__.py @@ -0,0 +1,28 @@ +import json +import pathlib +from typing import Any + + +def _path_to_here() -> pathlib.Path: + return pathlib.Path(__file__).parent + + +class Patients: + @staticmethod + def load_patient(filename: str) -> dict[str, Any]: + with open(_path_to_here() / filename, encoding="utf-8") as f: + patient: dict[str, Any] = json.load(f) + return patient + + JANE_SMITH_9000000009 = load_patient("jane_smith_9000000009.json") + NO_SDS_RESULT_9000000010 = load_patient("no_sds_result_9000000010.json") + BLANK_ASID_SDS_RESULT_9000000011 = load_patient( + "blank_asid_sds_result_9000000011.json" + ) + INDUCE_PROVIDER_ERROR_9000000012 = load_patient( + "induce_provider_error_9000000012.json" + ) + BLANK_ENDPOINT_SDS_RESULT_9000000013 = load_patient( + "blank_endpoint_sds_result_9000000013.json" + ) + ALICE_JONES_9999999999 = load_patient("alice_jones_9999999999.json") diff --git a/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json b/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json new file mode 100644 index 00000000..558a4e30 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9999999999", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + ], + "name": [ + { + "use": "official", + "family": "Jones", + "given": ["Alice"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1980-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01", "end": "9999-12-31"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json b/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json new file mode 100644 index 00000000..58b47242 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000011", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000011" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "BlankAsidInSDS", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json b/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json new file mode 100644 index 00000000..1e3645b6 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000013", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000013" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "BlankEndpointInSDS", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json b/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json new file mode 100644 index 00000000..d173540e --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000012", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000012" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "InduceProviderError", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/jane_smith_9000000009.json b/gateway-api/stubs/stubs/data/patients/jane_smith_9000000009.json new file mode 100644 index 00000000..81b0ce5f --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/jane_smith_9000000009.json @@ -0,0 +1,24 @@ +{ + "resourceType": "Patient", + "id": "9000000009", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01" +} diff --git a/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json b/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json new file mode 100644 index 00000000..f43198ba --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000010", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000010" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "DoesNotExistInSDS", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json b/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json new file mode 100644 index 00000000..6834ebe6 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000013", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000013" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "BlankConsumerRequest", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/pds/__init__.py b/gateway-api/stubs/stubs/pds/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/stubs/stubs/stub_pds.py b/gateway-api/stubs/stubs/pds/stub.py similarity index 82% rename from gateway-api/stubs/stubs/stub_pds.py rename to gateway-api/stubs/stubs/pds/stub.py index f8249295..c3c01293 100644 --- a/gateway-api/stubs/stubs/stub_pds.py +++ b/gateway-api/stubs/stubs/pds/stub.py @@ -4,8 +4,6 @@ The stub does **not** implement the full PDS API surface, nor full FHIR validation. """ -from __future__ import annotations - import json import re import uuid @@ -16,6 +14,8 @@ from requests import Response from requests.structures import CaseInsensitiveDict +from stubs.data.patients import Patients + def _create_response( status_code: int, @@ -70,73 +70,20 @@ def __init__(self, strict_headers: bool = True) -> None: # Seed a deterministic example matching the spec's id example. # Tests may overwrite this record via upsert_patient. - self.upsert_patient( - nhs_number="9000000009", - patient={ - "resourceType": "Patient", - "id": "9000000009", - "meta": { - "versionId": "1", - "lastUpdated": "2020-01-01T00:00:00Z", - }, - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9000000009", - } - ], - "name": [ - { - "use": "official", - "family": "Smith", - "given": ["Jane"], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "gender": "female", - "birthDate": "1970-01-01", - }, - version_id=1, - ) - - self.upsert_patient( - nhs_number="9999999999", - patient={ - "resourceType": "Patient", - "id": "9999999999", - "meta": { - "versionId": "1", - "lastUpdated": "2020-01-01T00:00:00Z", - }, - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "name": [ - { - "use": "official", - "family": "Jones", - "given": ["Alice"], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "gender": "female", - "birthDate": "1980-01-01", - "generalPractitioner": [ - { - "id": "1", - "type": "Organization", - "identifier": { - "value": "A12345", - "period": {"start": "2020-01-01", "end": "9999-12-31"}, - }, - } - ], - }, - version_id=1, - ) + test_patients = [ + ("9999999999", Patients.ALICE_JONES_9999999999), + ("9000000009", Patients.JANE_SMITH_9000000009), + ("9000000010", Patients.NO_SDS_RESULT_9000000010), + ("9000000011", Patients.BLANK_ASID_SDS_RESULT_9000000011), + ("9000000012", Patients.INDUCE_PROVIDER_ERROR_9000000012), + ("9000000013", Patients.BLANK_ENDPOINT_SDS_RESULT_9000000013), + ] + for nhs_number, patient in test_patients: + self.upsert_patient( + nhs_number=nhs_number, + patient=patient, + version_id=1, + ) # --------------------------- # Public API for tests @@ -255,7 +202,7 @@ def get_patient( headers_out["ETag"] = f'W/"{version_id}"' return _create_response(status_code=200, headers=headers_out, json_data=patient) - def get( + def post( self, url: str, headers: dict[str, Any] | None = None, @@ -275,8 +222,6 @@ def get( request_id = headers.get("X-Request-ID") correlation_id = headers.get("X-Correlation-ID") authorization = headers.get("Authorization") - role_id = headers.get("NHSD-Session-URID") - end_user_org_ods = headers.get("NHSD-End-User-Organisation-ODS") return self.get_patient( nhs_number=nhs_number, diff --git a/gateway-api/stubs/stubs/provider/__init__.py b/gateway-api/stubs/stubs/provider/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/provider/stub.py similarity index 59% rename from gateway-api/stubs/stubs/stub_provider.py rename to gateway-api/stubs/stubs/provider/stub.py index 2d0c96ba..7f557e61 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/provider/stub.py @@ -28,6 +28,8 @@ from requests import Response from requests.structures import CaseInsensitiveDict +from stubs.data.bundles import Bundles + def _create_response( status_code: int, @@ -64,49 +66,6 @@ class GpProviderStub: # https://simplifier.net/guide/gp-connect-access-record-structured/Home/Examples/Allergy-examples?version=1.6.2 """ - # Example patient resource - patient_bundle = { - "resourceType": "Bundle", - "type": "collection", - "meta": { - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1" - ] - }, - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", - "meta": { - "versionId": "1469448000000", - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" - ], - }, - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "active": True, - "name": [ - { - "use": "official", - "text": "JACKSON Jane (Miss)", - "family": "Jackson", - "given": ["Jane"], - "prefix": ["Miss"], - } - ], - "gender": "female", - "birthDate": "1952-05-31", - } - } - ], - } - def access_record_structured( self, trace_id: str, @@ -119,13 +78,6 @@ def access_record_structured( Response: The stub patient bundle wrapped in a Response object. """ - stub_response = _create_response( - status_code=200, - headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), - content=json.dumps(self.patient_bundle).encode("utf-8"), - reason="OK", - ) - if trace_id == "invalid for test": return _create_response( status_code=400, @@ -138,16 +90,47 @@ def access_record_structured( reason="Bad Request", ) - return stub_response + try: + nhs_number = json.loads(body)["parameter"][0]["valueIdentifier"]["value"] + except (json.JSONDecodeError, KeyError, IndexError): + return _create_response( + status_code=400, + headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + content=( + b'{"resourceType":"OperationOutcome","issue":[' + b'{"severity":"error","code":"invalid",' + b'"diagnostics":"Malformed request body"}]}' + ), + reason="Bad Request", + ) + if nhs_number == "9999999999": + return _create_response( + status_code=200, + headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + content=json.dumps(Bundles.ALICE_JONES_9999999999).encode("utf-8"), + reason="OK", + ) -def stub_post( - url: str, # NOQA ARG001 # NOSONAR S1172 (unused in stub) - headers: dict[str, Any], - data: json_str, - timeout: int, # NOQA ARG001 # NOSONAR S1172 (unused in stub) -) -> Response: - """A stubbed requests.post function that routes to the GPProviderStub.""" - _provider_stub = GpProviderStub() - trace_id = headers.get("Ssp-TraceID", "no-trace-id") - return _provider_stub.access_record_structured(trace_id, data) + return _create_response( + status_code=404, + headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + content=( + b'{"resourceType":"OperationOutcome","issue":[' + b'{"severity":"error","code":"not-found",' + b'"diagnostics":"Patient not found"}]}' + ), + reason="Not Found", + ) + + def post( + self, + url: str, # NOQA ARG001 # NOSONAR S1172 (unused in stub) + headers: dict[str, Any], + data: json_str, + timeout: int, # NOQA ARG001 # NOSONAR S1172 (unused in stub) + ) -> Response: + """A stubbed requests.post function that routes to the GPProviderStub.""" + _provider_stub = GpProviderStub() + trace_id = headers.get("Ssp-TraceID", "no-trace-id") + return _provider_stub.access_record_structured(trace_id, data) diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index 3485f224..bf777cbe 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -6,7 +6,7 @@ import requests from fhir.parameters import Parameters from pytest_bdd import given, parsers, then, when -from stubs.stub_provider import GpProviderStub +from stubs.data.bundles import Bundles from tests.acceptance.conftest import ResponseContext from tests.conftest import Client @@ -60,6 +60,6 @@ def check_status_code(response_context: ResponseContext, expected_status: int) - @then("the response should contain the patient bundle from the provider") def check_response_matches_provider(response_context: ResponseContext) -> None: assert response_context.response, "Response has not been set." - assert response_context.response.json() == GpProviderStub.patient_bundle, ( + assert response_context.response.json() == Bundles.ALICE_JONES_9999999999, ( "Expected response payload does not match actual response payload." ) diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index 12c8a5cf..7c93ed79 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -55,10 +55,22 @@ "entry": [ { "resource": { - "active": true, - "birthDate": "1952-05-31", + "birthDate": "1980-01-01", "gender": "female", - "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", + "generalPractitioner": [ + { + "id": "1", + "identifier": { + "period": { + "end": "9999-12-31", + "start": "2020-01-01" + }, + "value": "A12345" + }, + "type": "Organization" + } + ], + "id": "9999999999", "identifier": [ { "system": "https://fhir.nhs.uk/Id/nhs-number", @@ -66,21 +78,19 @@ } ], "meta": { - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" - ], - "versionId": "1469448000000" + "lastUpdated": "2020-01-01T00:00:00Z", + "versionId": "1" }, "name": [ { - "family": "Jackson", + "family": "Jones", "given": [ - "Jane" - ], - "prefix": [ - "Miss" + "Alice" ], - "text": "JACKSON Jane (Miss)", + "period": { + "end": "9999-12-31", + "start": "1900-01-01" + }, "use": "official" } ], diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index cf1998c3..7c9bffee 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -34,12 +34,10 @@ def test_get_structured_record(self) -> None: { "resource": { "resourceType": "Patient", - "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", + "id": "9999999999", "meta": { - "versionId": "1469448000000", - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" - ], + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z", }, "identifier": [ { @@ -47,18 +45,29 @@ def test_get_structured_record(self) -> None: "value": "9999999999", } ], - "active": True, "name": [ { "use": "official", - "text": "JACKSON Jane (Miss)", - "family": "Jackson", - "given": ["Jane"], - "prefix": ["Miss"], + "family": "Jones", + "given": ["Alice"], + "period": {"start": "1900-01-01", "end": "9999-12-31"}, } ], "gender": "female", - "birthDate": "1952-05-31", + "birthDate": "1980-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": { + "start": "2020-01-01", + "end": "9999-12-31", + }, + }, + } + ], } } ], @@ -128,10 +137,7 @@ def test_get_structured_record(self) -> None: assert body["type"] == "collection" assert len(body["entry"]) == 1 assert body["entry"][0]["resource"]["resourceType"] == "Patient" - assert ( - body["entry"][0]["resource"]["id"] - == "04603d77-1a4e-4d63-b246-d7504f8bd833" - ) + assert body["entry"][0]["resource"]["id"] == "9999999999" assert ( body["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" ) diff --git a/gateway-api/tests/data/patient/pds_fhir_example.json b/gateway-api/tests/data/patient/pds_fhir_example.json new file mode 100644 index 00000000..2c590256 --- /dev/null +++ b/gateway-api/tests/data/patient/pds_fhir_example.json @@ -0,0 +1,390 @@ +{ + "resourceType": "Patient", + "id": "9000000009", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-NHSNumberVerificationStatus", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-NHSNumberVerificationStatus", + "version": "1.0.0", + "code": "01", + "display": "Number present and verified" + } + ] + } + } + ] + } + ], + "meta": { + "versionId": "2", + "security": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-Confidentiality", + "code": "U", + "display": "unrestricted" + } + ] + }, + "name": [ + { + "id": "123", + "use": "usual", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "given": [ + "Jane" + ], + "family": "Smith", + "prefix": [ + "Mrs" + ] + } + ], + "gender": "female", + "birthDate": "2010-10-22", + "multipleBirthInteger": 1, + "deceasedDateTime": "2010-10-22T00:00:00+00:00", + "generalPractitioner": [ + { + "id": "254406A3", + "type": "Organization", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y12345", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + } + } + } + ], + "managingOrganization": { + "type": "Organization", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y12345", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + } + } + }, + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-NominatedPharmacy", + "valueReference": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y12345" + } + } + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-PreferredDispenserOrganization", + "valueReference": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y23456" + } + } + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-MedicalApplianceSupplier", + "valueReference": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y34567" + } + } + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-DeathNotificationStatus", + "extension": [ + { + "url": "deathNotificationStatus", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-DeathNotificationStatus", + "version": "1.0.0", + "code": "2", + "display": "Formal - death notice received from Registrar of Deaths" + } + ] + } + }, + { + "url": "systemEffectiveDate", + "valueDateTime": "2010-10-22T00:00:00+00:00" + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-NHSCommunication", + "extension": [ + { + "url": "language", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-HumanLanguage", + "version": "1.0.0", + "code": "fr", + "display": "French" + } + ] + } + }, + { + "url": "interpreterRequired", + "valueBoolean": true + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-ContactPreference", + "extension": [ + { + "url": "PreferredWrittenCommunicationFormat", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-PreferredWrittenCommunicationFormat", + "code": "12", + "display": "Braille" + } + ] + } + }, + { + "url": "PreferredContactMethod", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-PreferredContactMethod", + "code": "1", + "display": "Letter" + } + ] + } + }, + { + "url": "PreferredContactTimes", + "valueString": "Not after 7pm" + } + ] + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", + "valueAddress": { + "city": "Manchester", + "district": "Greater Manchester", + "country": "GBR" + } + }, + { + "url": "https://fhir.nhs.uk/StructureDefinition/Extension-PDS-RemovalFromRegistration", + "extension": [ + { + "url": "removalFromRegistrationCode", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/PDS-RemovalReasonExitCode", + "code": "SCT", + "display": "Transferred to Scotland" + } + ] + } + }, + { + "url": "effectiveTime", + "valuePeriod": { + "start": "2020-01-01T00:00:00+00:00", + "end": "2021-12-31T00:00:00+00:00" + } + } + ] + } + ], + "telecom": [ + { + "id": "789", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "system": "phone", + "value": "01632960587", + "use": "home" + }, + { + "id": "790", + "period": { + "start": "2019-01-01", + "end": "2022-12-31" + }, + "system": "email", + "value": "jane.smith@example.com", + "use": "home" + }, + { + "id": "OC789", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "system": "other", + "value": "01632960587", + "use": "home", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-OtherContactSystem", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-OtherContactSystem", + "code": "textphone", + "display": "Minicom (Textphone)" + } + } + ] + } + ], + "contact": [ + { + "id": "C123", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "relationship": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0131", + "code": "C", + "display": "Emergency Contact" + } + ] + } + ], + "telecom": [ + { + "system": "phone", + "value": "01632960587" + } + ] + } + ], + "address": [ + { + "id": "456", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "use": "home", + "line": [ + "1 Trevelyan Square", + "Boar Lane", + "City Centre", + "Leeds", + "West Yorkshire" + ], + "postalCode": "LS1 6AE", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "PAF" + } + }, + { + "url": "value", + "valueString": "12345678" + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "UPRN" + } + }, + { + "url": "value", + "valueString": "123456789012" + } + ] + } + ] + }, + { + "id": "T456", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "use": "temp", + "text": "Student Accommodation", + "line": [ + "1 Trevelyan Square", + "Boar Lane", + "City Centre", + "Leeds", + "West Yorkshire" + ], + "postalCode": "LS1 6AE", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "PAF" + } + }, + { + "url": "value", + "valueString": "12345678" + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "UPRN" + } + }, + { + "url": "value", + "valueString": "123456789012" + } + ] + } + ] + } + ] +} diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index 32151f2d..2402a651 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -1,9 +1,12 @@ """Integration tests for the gateway API using pytest.""" import json +from collections.abc import Callable +import pytest from fhir.parameters import Parameters -from stubs.stub_provider import GpProviderStub +from requests import Response +from stubs.data.bundles import Bundles from tests.conftest import Client @@ -12,7 +15,6 @@ class TestGetStructuredRecord: def test_happy_path_returns_200( self, client: Client, simple_request_payload: Parameters ) -> None: - """Test that the root endpoint returns a 200 status code.""" response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) @@ -23,17 +25,330 @@ def test_happy_path_returns_correct_message( client: Client, simple_request_payload: Parameters, ) -> None: - """Test that the root endpoint returns the correct message.""" response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) - assert response.json() == GpProviderStub.patient_bundle + assert response.json() == Bundles.ALICE_JONES_9999999999 def test_happy_path_content_type( self, client: Client, simple_request_payload: Parameters ) -> None: - """Test that the response has the correct content type.""" response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) assert "application/fhir+json" in response.headers["Content-Type"] + + def test_empty_request_body_returns_400_status_code( + self, response_from_sending_request_with_empty_body: Response + ) -> None: + assert response_from_sending_request_with_empty_body.status_code == 400 + + def test_empty_request_body_returns_invalid_request_json_message( + self, response_from_sending_request_with_empty_body: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": "Invalid JSON body sent in request", + } + ], + } + assert response_from_sending_request_with_empty_body.json() == expected + + def test_patient_without_gp_returns_404_status_code( + self, response_from_requesting_patient_without_gp: Response + ) -> None: + assert response_from_requesting_patient_without_gp.status_code == 404 + + def test_patient_without_gp_returns_no_current_provider_message( + self, response_from_requesting_patient_without_gp: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ( + "PDS patient 9000000009 did not contain a " + "current provider ODS code" + ), + } + ], + } + assert response_from_requesting_patient_without_gp.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_no_provider_from_sds_returns_404_status_code( + self, response_when_sds_returns_no_provider: Response + ) -> None: + assert response_when_sds_returns_no_provider.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_no_provider_from_sds_returns_no_organisation_found_error( + self, response_when_sds_returns_no_provider: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ( + "No organisation found for ODS code DoesNotExistInSDS" + ), + } + ], + } + assert response_when_sds_returns_no_provider.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_provider_asid_from_sds_returns_404_status_code( + self, response_when_sds_returns_blank_provider_asid: Response + ) -> None: + assert response_when_sds_returns_blank_provider_asid.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_provider_asid_from_sds_returns_no_asid_found_error( + self, response_when_sds_returns_blank_provider_asid: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ("No ASID found for ODS code DoesNotExistInSDS"), + } + ], + } + assert response_when_sds_returns_blank_provider_asid.json() == expected + + def test_502_status_code_return_when_provider_returns_error( + self, response_when_provider_returns_error: Response + ) -> None: + assert response_when_provider_returns_error.status_code == 502 + + def test_internal_server_error_message_returned_when_provider_returns_error( + self, response_when_provider_returns_error: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": "Provider request failed: Not Found", + } + ], + } + assert response_when_provider_returns_error.json() == expected + + def test_nhs_number_that_does_not_exist_returns_502_status_code( + self, response_when_nhs_number_does_not_exist: Response + ) -> None: + assert response_when_nhs_number_does_not_exist.status_code == 502 + + def test_nhs_number_that_does_not_exist_returns_no_patient_found_error( + self, response_when_nhs_number_does_not_exist: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": "PDS FHIR API request failed: Not Found", + } + ], + } + assert response_when_nhs_number_does_not_exist.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_sds_endpoint_blank_returns_404_status_code( + self, response_when_sds_endpoint_blank: Response + ) -> None: + assert response_when_sds_endpoint_blank.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_sds_endpoint_blank_returns_no_current_endpoint_error( + self, response_when_sds_endpoint_blank: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ( + "No current endpoint found for ODS code DoesNotExistInSDS" + ), + } + ], + } + assert response_when_sds_endpoint_blank.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_consumer_is_none_from_sds_returns_404_status_code( + self, response_when_consumer_is_none_from_sds: Response + ) -> None: + assert response_when_consumer_is_none_from_sds.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_consumer_is_none_from_sds_returns_no_organisation_found_error( + self, response_when_consumer_is_none_from_sds: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ("No SDS org found for consumer ODS code CONSUMER"), + } + ], + } + assert response_when_consumer_is_none_from_sds.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_consumer_asid_from_sds_returns_404_status_code( + self, response_when_blank_consumer_asid_from_sds: Response + ) -> None: + assert response_when_blank_consumer_asid_from_sds.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_consumer_asid_from_sds_returns_no_asid_found_error( + self, response_when_blank_consumer_asid_from_sds: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ("No ASID found for consumer ODS code CONSUMER"), + } + ], + } + assert response_when_blank_consumer_asid_from_sds.json() == expected + + @pytest.fixture + def response_from_sending_request_with_empty_body(self, client: Client) -> Response: + response = client.send_to_get_structured_record_endpoint(payload="") + return response + + @pytest.fixture + def response_from_requesting_patient_without_gp( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_unregistered_patient = "9000000009" + response = get_structured_record_requestor(nhs_number_for_unregistered_patient) + return response + + @pytest.fixture + def response_when_sds_returns_no_provider( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_patient_with_gp_not_in_sds = "9000000010" + response = get_structured_record_requestor( + nhs_number_for_patient_with_gp_not_in_sds + ) + return response + + @pytest.fixture + def response_when_sds_returns_blank_provider_asid( + self, client: Client, simple_request_payload: Parameters + ) -> Response: + ods_from_for_consumer_with_blank_provider_asid_in_sds = "BlankProviderAsidInSDS" + headers = {"Ods-From": ods_from_for_consumer_with_blank_provider_asid_in_sds} + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload), headers=headers + ) + return response + + @pytest.fixture + def response_when_sds_returns_blank_consumer_asid( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_patient_with_gp_with_blank_consumer_asid_in_sds = "9000000015" + response = get_structured_record_requestor( + nhs_number_for_patient_with_gp_with_blank_consumer_asid_in_sds + ) + return response + + @pytest.fixture + def response_when_provider_returns_error( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_inducing_error_in_provider = "9000000012" + response = get_structured_record_requestor( + nhs_number_for_inducing_error_in_provider + ) + return response + + @pytest.fixture + def response_when_nhs_number_does_not_exist( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_that_does_not_exist = "1234567890" + response = get_structured_record_requestor(nhs_number_that_does_not_exist) + return response + + @pytest.fixture + def response_when_sds_endpoint_blank( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_patient_with_gp_with_blank_endpoint = "9000000013" + response = get_structured_record_requestor( + nhs_number_for_patient_with_gp_with_blank_endpoint + ) + return response + + @pytest.fixture + def response_when_consumer_is_none_from_sds( + self, client: Client, simple_request_payload: Parameters + ) -> Response: + ods_from_for_consumer_with_none_consumer_in_sds = "ConsumerWithNoneInSDS" + headers = {"Ods-From": ods_from_for_consumer_with_none_consumer_in_sds} + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload), headers=headers + ) + return response + + @pytest.fixture + def get_structured_record_requestor( + self, client: Client, simple_request_payload: Parameters + ) -> Callable[[str], Response]: + def requestor(nhs_number: str) -> Response: + simple_request_payload["parameter"][0]["valueIdentifier"]["value"] = ( + nhs_number + ) + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + return response + + return requestor diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 54824a4b..d67dfe4e 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -12,6 +12,8 @@ WORKDIR /resources/build/gateway-api ENV PYTHONPATH=/resources/build/gateway-api ENV FLASK_HOST="0.0.0.0" ENV FLASK_PORT="8080" +ENV STUB_PDS="true" +ENV STUB_PROVIDER="true" ARG COMMIT_VERSION ENV COMMIT_VERSION=$COMMIT_VERSION