Dentolize · DHS (NPHIES) Integration Walkthrough
On this pageEnvironment prerequisitesExisting automated coverage1. Setup and secrets2. Insurance discovery3. Eligibility4. Approval submission5. Polling, cancel, manual update6. Guards on existing flows7. Permissions and tenancy8. Migrations9. Known issues — confirm, don't re-file10. Translations

For Quality

What to test, and where the edges are. Every case below is derived from reading the code on this branch; file:line references let you check the expected behaviour yourself.


Environment prerequisites

You cannot meaningfully test this without:

  1. FEATURE_DHS_INTEGRATION enabled for the test company (positive rule is

Rule('isBeta','EQUALS',true) — so the company needs isBeta: true).

  1. A valid DHS client secret for a staging provider. Without one, everything past

authentication is untestable — this is what limited the sandbox walkthrough.

  1. DHS_AUTH_URL, DHS_PREAUTH_URL, DHS_ELIGIBILITY_URL, ENCRYPTION_MASTER_KEY, plus

S3_BUCKET_AWS and S3_REGION_AWS (missing from .env.example — the server will not boot without them).

  1. At least two companies, for cross-tenant probes.
  2. At least one patient with un-invoiced operations. Seeded data is fully invoiced, which

silently disables GET Approval (ChartTable.js:264-280).


Existing automated coverage

FileCasesNotes
dhsStatusMapping.test.js18'queued' is mapped in source (:17) but not covered
dhsErrorUtils.test.js10Pins exact strings incl. the 204/401 empty-body path
dhsFieldGuards.test.js8CANCELED unlock, order-insensitive diagnosis, undefined-skipping

No automated coverage of resolvers, the cron, or the UI. Everything below is manual.


1. Setup and secrets

#TestExpectedRef
1.1Save an invalid secretsuccess:false, nothing written to DHSIntegrationsaveDHSIntegration.js:32-47
1.2Save a valid secretRow created; response clientSecretMask is ****+last 4:49-76
1.3Inspect the DB rowOnly ciphertext/iv/authTag — no plaintext columnschema.prisma:5544-5546
1.4Inspect the GraphQL response for raw bytesCiphertext must not appear (SDL drops it)types.graphql:5468-5476
1.5Rotate with an invalid secret"New secret failed DHS validation. Existing secret has not been changed." Old secret still worksrotateDHSClientSecret.js:22-27
1.6Rotate with no existing row"No DHS integration found for this company" (P2025):45-50
1.7Rotate successfullyReturns {success:true} with no message — check the UI handles that:41-43
1.8Change ENCRYPTION_MASTER_KEY, then read the secretgetDHSClientSecret returns success:false, not hasSecret:trueencryption.js:24-28
1.9Start the server with a 63-char keyThrows at import: must be 64 hex chars:12-14
1.10Unset any DHS_*_URLProcess fails to bootdhsConfig.js:1-5
1.11Set a DHS_*_URL with a trailing slashStripped, exactly one:4

Cross-tenant: as company A, call updateBranchesNphiesCodes with a company-B branch id → Branches not found or unauthorized: <id> (updateBranchesNphiesCodes.js:28-40).

Edge: pass the same branch id twice in one call. branchIds.length inflates and the count check trips — verify the message is comprehensible.

Edge: force a branchCache.resetDetails failure. The DB write has already committed but the mutation reports failure (:59). Confirm the UI does not leave a stale mapping on screen.


2. Insurance discovery

#TestExpectedRef
2.1Branch without nphiesCode"Branch NPHIES code is not configured"checkInsurance.js:28-45
2.2Company-B branch id from company ASame message — indistinguishable by design:28-45
2.3Successful checkRead-only: no DB writeswhole resolver
2.4Payer returns PascalCase keysAll 22 coverage fields still normalise:94-117
2.5Payer returns top-level insurance[]Handled as well as data.insurance[]:89-92
2.6Payer returns no insurance arraysuccess:true, coverages: []:89-92

resolveInsuranceCoverages cascade

#TestExpectedRef
2.7All three inputs emptysuccess:true, all-exists:false, no queries run:39-45
2.8Valid policy number, unknown insurerPolicy reports exists:false because level 2 never ran:74-86
2.9Force a Prisma errorNo try/catch — raw GraphQL error, not success:falsewhole file

Wizard

#TestExpectedRef
2.10All three entities existJumps straight to ReviewCheckInsuranceModal.tsx:184-207
2.11Only insurer existsStarts at the policy stepsame
2.12Skip policyClass is also skipped; jumps to Review:771-775
2.13Change the coverage radio mid-flowAll resolution state resets, back to step 0:209-216
2.14Next at step 2 with no choice madeBlocked; error_choosePolicyAction:525-528, :696-697
2.15Auto-create policyCreates policy and class, jumps to Review:433-477
2.16Deductible rate "20%"New company prefills 80%utils.ts:24-33
2.17Deductible rate null/garbagePrefills 100%:24-33

Bug to confirm: call getInsuranceCompanyDhsInsuranceHierarchy with a non-existent id. Expected per code: a TypeError, not the intended "Insurance company not found" — the tenancy check dereferences before the null check (getInsuranceCompanyDhsInsuranceHierarchy.js:75-85). Also verify a cross-tenant id correctly yields 'Not Authorised'.


3. Eligibility

#TestExpectedRef
3.1Patient missing any of 12 fieldsModal opensuseDHSCheckEligibility.ts:187-232
3.2Patient with all 12No modal — check runs immediatelysame
3.3Fill modal, force the DHS call to failPatient record still updated:281-307
3.4Successful checkDHSEligibilityCheck row + patient.currentEligibilityId setdhsCheckEligibility.js:129-158
3.5Payer succeeded:falseNo DB write at all:107-114
3.6Omit patientIdCall succeeds, nothing persisted:120
3.7Cross-tenant patientIdWrites silently skipped, still success:true:121-128
3.8Payer returns eligibilityCode.code: "active"DB says NON_ELIGIBLE, badge may say eligible — confirm and file:116 vs utils.ts:199-201
3.9Payer returns membership numberWritten to patient.insuranceNumber:149-158
3.10Force the patient update to fail after the check row is createdOrphan row; success:falsenot transactional:129-158
3.11Change the patient's insurer after a checkBadge disappearsuseDHSCheckEligibility.ts:67-83
3.12Run twiceOnly the latest is currentEligibility (@unique)schema.prisma:965

National ID validation

CountryTypeInputExpected
SAnational_id1234567890pass
SAnational_id2234567890fail — must start with 1
SAiqama/residency1234567890fail — must start with 2
SAany of the four123456789fail — must be 10 digits
EGany14 digits starting 2/3, valid month & daypass
EGany14 digits, month 13fail

(DHSCheckEligibility.tsx:196-245; legacy form NationalIdField.js:52.)

updatePatientDetails

#TestExpectedRef
3.13Send policyNumber, coverageType, nphiesCodeSilently discarded — not on the allow-list:21-37 vs inputs.graphql:911-924
3.14Cross-tenant patientIdupdateMany count 0 → "Update failed. Please try again.":72-79, :90-97
3.15identifierType: 'iqama'Stored as residency:52-66
3.16identifierType: 'nonsense'Stored as OTHER + log.warn, not rejected:52-66
3.17Future birthDateRejected:39-45
3.18With the feature flag offStill works — the only DHS mutation not flag-gatedpermissions.js:2782

4. Approval submission

Pre-flight refusals (client side)

#SelectionExpectedRef
4.1Nothingmsg_select_operations_firstuseDHSApprovalSubmission.tsx:57-60
4.2Operation with tooth.length > 1Grouped-teeth notification listing teeth:62-81
4.3Operation with amount > 1Quantity notification:83-102
4.4Two branchesMixed-branch notification with branch names:104-134
4.5Already-invoiced operationExcluded from the count entirelyChartTable.js:264-280
4.6insuranceDiscount === falseExcluded from the countsame
4.7Operation with PENDING/APPROVED approvalExcludedsame

Server guards

#TestExpectedRef
4.8Include an insuranceDiscount:false operation via the API"Operations not subject to insurance cannot be submitted"dhsApprovalSubmission.js:47-60
4.9Empty operationIds"No linked operations; cannot determine the filing branch" — even though optional in SDL:71-84
4.10Operations from two branches via the API"Operations span multiple branches…"dhsApprovalBranch.js:28-30
4.11Branch without nphiesCode"Branch NPHIES code is not configured":34-36
4.12Pass a branchId for a different branchIgnored — the filing branch comes from the operations:62-70
4.13Mix in a company-B operation idCount assertion throws, whole transaction rolls back:305-312

Priority case — 4.13. Confirm that when the rollback fires, the payer did accept the submission. This leaves the approval upstream and not locally. Verify the user-visible message and log a runbook item.

Attachments

#TestExpectedRef
4.14http:// URL"Attachment URL must use HTTPS"validateAttachmentUrl.js:34-36
4.15Non-allow-listed host"Attachment host … is not allowed":38-40
4.16Allowed host with a portRejected — check uses .host:38-40
4.17Host resolving to 169.254.169.254"resolves to a private IP address":15-24, :49-53
4.18Host resolving to 10.x, 172.16-31.x, 192.168.x, 127.x, ::1, fc00:, fe80:All rejectedsame
4.19URL that 302s to an internal hostRejected — maxRedirects: 0dhsApprovalSubmission.js:206
4.20Attachment > 10 MiBRejected by both size checks:221-228
4.21Unset S3_BUCKET_AWSModule throws at importvalidateAttachmentUrl.js:9-11
4.22Inspect the stored requestPayloadEvery AttachmentBinary is '[redacted]':257-265

4.19 and 4.22 are the two highest-value security assertions in this PR.

Wizard behaviour

#TestExpectedRef
4.23Operations with no filesSix steps, no AttachmentsDHSApprovalSubmissionModal.tsx:168-173
4.24Operations with filesSeven stepssame
4.25Next with an invalid field on an earlier stepSilently does nothing — console only:73-80
4.26Try to leave Diagnosis with zero diagnosesBlockedDiagnosisStep.tsx:42-50
4.27ICD search with no match"Use this code: <typed>" option offered:77-94
4.28Change price/qty/discount on ServicesVAT recomputes at 15%ServicesStep.tsx:110-144
4.29Admission date after the earliest service dateBlockedEncounterInfoStep.tsx:86-92
4.30Discharge before admission, or before the latest service dateBlocked:105-116
4.31Select a practitionerSCFHS + speciality auto-fill (default 08.26 / General):161-171

5. Polling, cancel, manual update

The cron

#TestExpectedRef
5.1150 pending approvalsOnly 100 per tick, oldest updatedAt firstdhsApprovalsCron.js:77-107
5.2Approval with approvalNumber: nullNever picked up:80-83
5.3Company with the flag offSkipped; approvals stay PENDING:124-145
5.4Branch or insurer missing nphiesCodeSilently skipped — no log, no error:165-168
5.5Force a 401 mid-tickToken cache cleared, re-auth, retry once:192-217
5.6401 on the retry tooPropagates to the task catch → approval set to ERROR:306-322
5.7Two workers concurrentlyRedis NX lock — one runs, one skips:52-63
5.8Throw mid-tickLock released in finally:343-346
5.9Zero pending approvalsNo summary log emitted at all:339
5.10Payer succeeded:falseApproval → ERROR with the payload:221-234

Status mapping

Drive every row of the table in Approvals Lifecycle. Specifically verify 'queued' → PENDING (mapped at dhsStatusMapping.js:17, not covered by a test), and that an unknown string → ERROR.

Amount reconciliation

#Payer service lineExpectedRef
5.11approvedQuantity: 2approved = true:264-269
5.12approvedQuantity: 0, status rejectedapproved = falsesame
5.13approvedQuantity: 0, status absentapproved not written (null)same
5.14approvedAmount.amount: 123.6insuranceValue = 124 (rounded):275-276
5.15Any responsetax and initialDiscount never written:273
5.16Two operations sharing a codeFirst match wins — both get the same line. Confirm and assess:251-256
5.17Operation with code: nullSkipped:251

Cancel

#TestExpectedRef
5.18Cancel a PENDING approvalstatus=CANCELED, canceledAt, canceledById set:125-147
5.19Cancel PARTIALLY_APPROVEDRefused:58-64
5.20Cancel ERROR / REJECTED / DRAFTRefusedsame
5.21After cancellingAll linked operations insuranceValue=0, approved=false:125-147
5.22After cancellingpreAuth not clearedsame
5.23After cancellingPrice/tooth/doctor/diagnosis editable again; preAuth still lockeddhsFieldGuards.js:36-39
5.24Payer replies isCancelled (double-l)Accepted:118-122
5.25Re-poll a CANCELED approvalStatus is overwritten by the payer's current answer — confirm and assessdhsGetApproval.js:206

Manual update

#TestExpectedRef
5.26On an APPROVED / REJECTED / CANCELED approval"already been finalized":55-67
5.27On PENDING / PARTIALLY_APPROVED / DENIED / ERROR / DRAFTAllowedsame
5.28All approved: trueApproval → APPROVED:69-80
5.29All approved: false→ REJECTEDsame
5.30Mixed→ PARTIALLY_APPROVEDsame
5.31All approved omitted.every is vacuously true → APPROVED. Confirm this is intended:69-80
5.32Duplicate operation idsCount assertion fails:46-51
5.33Cross-tenant operation idSame failuresame
5.34Any successful updatemanualUpdateById set; UI shows the name:95-105
5.35Send discount / taxNever persisted:82-93
5.36Operations spanning two approvalsOnly the first approval's status updates. Confirm and assess:53

6. Guards on existing flows

#TestExpectedRef
6.1Invoice an operation with a PENDING approvalapp.waitingApprovaldhsGuards.js:40-53
6.2Same, with the flag offAllowed — flag checked before blockingsame
6.3Convert a quotation containing a PENDING operationDisabled, tooltip shownConvertInvoiceButton.js:15,43,50
6.4Edit price/tooth/amount/doctorId/createdAt/diagnosis with a live approvalapp.cannotModifyDhsOperationdhsFieldGuards.js:3,46
6.5Edit preAuth in any statusAlways refused:2
6.6Reorder a diagnosis array without changing membershipAllowed — multiset comparison:5-20
6.7Partial update omitting protected fieldsAllowed:42
6.8Manual approve/deny on any NPHIES-branch operationDisabled, even with no DHS approvalOperationApprovalStatus.js:25-26
6.9Mobile: invoice an operation with a PENDING approvalBlocked (not flag-gated on mobile)AddInvoiceOperationsDrawer.js:82-87

7. Permissions and tenancy

Run each with the permission absent, then present:

OperationPermission
Settings → Integrations → DHS tabVIEW_DHS_INTEGRATION
Save / rotate / test / branch codesEDIT_DHS_INTEGRATION
Approvals tab, approval detailVIEW_DHS_APPROVALS
Check Insurance, Check StatusCHECK_DHS_INSURANCE
Check EligibilityCHECK_DHS_ELIGIBILITY
GET ApprovalCREATE_DHS_APPROVAL
CancelCANCEL_DHS_APPROVAL
RetryGET_DHS_APPROVAL
Manual UpdateCREATE_MANUAL_DHS_APPROVAL

Feature-flag matrix: with the flag off, all 18 DHS operations must return "Feature not enabled", every UI surface must be hidden, and the cron must skip the company — but invoicing must be unaffected.

Cross-tenant probes. As company A, attempt each of these against a company-B record. All must be denied:

TargetGuard
dhsApprovalSubmission with a B patientisSameCompanyAsDhsInputPatient
dhsGetApproval / dhsCancelApproval with a B approvalisSameCompanyAsDhsApprovalId
dhsManualUpdateOperations with B operationsisSameCompanyAsDhsManualOperations
dhsApprovalDetails with a B approvalResolver check → 'Not Authorised'
getDHSApprovalsAlways AND: [{companyId}]
updateBranchesNphiesCodes with B branchesPre-flight count

Also probe the permissive-on-absence paths. All three shield rules return true when the relevant id is missing (rules.js:1927-1957). Call each mutation omitting the id and confirm the resolver's own guard catches it — dhsGetApproval.js:30-31 and dhsCancelApproval.js:29-37 have explicit checks; verify dhsApprovalSubmission is covered by its branch-derivation failure.


8. Migrations

Test against a database that already holds DHS rows:

#TestExpected
8.1Run 20260507183115 with existing approvalscompanyId backfilled from patient.companyId, then NOT NULL
8.2Same, with an approval whose patient was deletedMigration raises, does not silently coerce (lines 25–38)
8.3Run 20260508120000 with isCanceled = true rowsstatus = 'CANCELED' backfilled before the column drop
8.4Fresh database, full migrate deployClean; 20260616145425 is an intentional comment-only no-op

9. Known issues — confirm, don't re-file

These are already identified (see Known Gaps):

  • Logs → Approvals renders nothing — the component does not exist.
  • The DHS Settings permission tab is not feature-flagged (Group.js:251).
  • getInsuranceCompanyDhsInsuranceHierarchy throws TypeError on a non-existent id.
  • input CoverageInput is declared twice, identically (inputs.graphql:950, :957).
  • S3_BUCKET_AWS / S3_REGION_AWS missing from .env.example.
  • No HTTP timeouts on /api/Login, CheckInsurance, Checkeligibility,

SubmitApprovalRequest.

  • Approvals-tab refetch uses hardcoded page-1 variables (Approvals.js:53-72).
  • DHSApprovalSubmission.queries.tsx, .constants.tsx and DHSCheckEligibility.queries.tsx

are empty files.

10. Translations

Run node packages/clinic-mobile/src/shared/i18n/auditTranslations.js dhs. Per the PR, no missing DHS keys should remain; the wider pre-existing drift is out of scope. Spot-check the Arabic RTL rendering of the approval wizard and the Approvals tab — neither was exercised during this walkthrough.