On this page
Environment 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. TranslationsFor 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:
FEATURE_DHS_INTEGRATIONenabled for the test company (positive rule is
Rule('isBeta','EQUALS',true) — so the company needs isBeta: true).
- A valid DHS client secret for a staging provider. Without one, everything past
authentication is untestable — this is what limited the sandbox walkthrough.
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).
- At least two companies, for cross-tenant probes.
- 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
| File | Cases | Notes |
|---|---|---|
dhsStatusMapping.test.js | 18 | 'queued' is mapped in source (:17) but not covered |
dhsErrorUtils.test.js | 10 | Pins exact strings incl. the 204/401 empty-body path |
dhsFieldGuards.test.js | 8 | CANCELED unlock, order-insensitive diagnosis, undefined-skipping |
No automated coverage of resolvers, the cron, or the UI. Everything below is manual.
1. Setup and secrets
| # | Test | Expected | Ref |
|---|---|---|---|
| 1.1 | Save an invalid secret | success:false, nothing written to DHSIntegration | saveDHSIntegration.js:32-47 |
| 1.2 | Save a valid secret | Row created; response clientSecretMask is ****+last 4 | :49-76 |
| 1.3 | Inspect the DB row | Only ciphertext/iv/authTag — no plaintext column | schema.prisma:5544-5546 |
| 1.4 | Inspect the GraphQL response for raw bytes | Ciphertext must not appear (SDL drops it) | types.graphql:5468-5476 |
| 1.5 | Rotate with an invalid secret | "New secret failed DHS validation. Existing secret has not been changed." Old secret still works | rotateDHSClientSecret.js:22-27 |
| 1.6 | Rotate with no existing row | "No DHS integration found for this company" (P2025) | :45-50 |
| 1.7 | Rotate successfully | Returns {success:true} with no message — check the UI handles that | :41-43 |
| 1.8 | Change ENCRYPTION_MASTER_KEY, then read the secret | getDHSClientSecret returns success:false, not hasSecret:true | encryption.js:24-28 |
| 1.9 | Start the server with a 63-char key | Throws at import: must be 64 hex chars | :12-14 |
| 1.10 | Unset any DHS_*_URL | Process fails to boot | dhsConfig.js:1-5 |
| 1.11 | Set a DHS_*_URL with a trailing slash | Stripped, 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
| # | Test | Expected | Ref |
|---|---|---|---|
| 2.1 | Branch without nphiesCode | "Branch NPHIES code is not configured" | checkInsurance.js:28-45 |
| 2.2 | Company-B branch id from company A | Same message — indistinguishable by design | :28-45 |
| 2.3 | Successful check | Read-only: no DB writes | whole resolver |
| 2.4 | Payer returns PascalCase keys | All 22 coverage fields still normalise | :94-117 |
| 2.5 | Payer returns top-level insurance[] | Handled as well as data.insurance[] | :89-92 |
| 2.6 | Payer returns no insurance array | success:true, coverages: [] | :89-92 |
resolveInsuranceCoverages cascade
| # | Test | Expected | Ref |
|---|---|---|---|
| 2.7 | All three inputs empty | success:true, all-exists:false, no queries run | :39-45 |
| 2.8 | Valid policy number, unknown insurer | Policy reports exists:false because level 2 never ran | :74-86 |
| 2.9 | Force a Prisma error | No try/catch — raw GraphQL error, not success:false | whole file |
Wizard
| # | Test | Expected | Ref |
|---|---|---|---|
| 2.10 | All three entities exist | Jumps straight to Review | CheckInsuranceModal.tsx:184-207 |
| 2.11 | Only insurer exists | Starts at the policy step | same |
| 2.12 | Skip policy | Class is also skipped; jumps to Review | :771-775 |
| 2.13 | Change the coverage radio mid-flow | All resolution state resets, back to step 0 | :209-216 |
| 2.14 | Next at step 2 with no choice made | Blocked; error_choosePolicyAction | :525-528, :696-697 |
| 2.15 | Auto-create policy | Creates policy and class, jumps to Review | :433-477 |
| 2.16 | Deductible rate "20%" | New company prefills 80% | utils.ts:24-33 |
| 2.17 | Deductible rate null/garbage | Prefills 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
| # | Test | Expected | Ref |
|---|---|---|---|
| 3.1 | Patient missing any of 12 fields | Modal opens | useDHSCheckEligibility.ts:187-232 |
| 3.2 | Patient with all 12 | No modal — check runs immediately | same |
| 3.3 | Fill modal, force the DHS call to fail | Patient record still updated | :281-307 |
| 3.4 | Successful check | DHSEligibilityCheck row + patient.currentEligibilityId set | dhsCheckEligibility.js:129-158 |
| 3.5 | Payer succeeded:false | No DB write at all | :107-114 |
| 3.6 | Omit patientId | Call succeeds, nothing persisted | :120 |
| 3.7 | Cross-tenant patientId | Writes silently skipped, still success:true | :121-128 |
| 3.8 | Payer returns eligibilityCode.code: "active" | DB says NON_ELIGIBLE, badge may say eligible — confirm and file | :116 vs utils.ts:199-201 |
| 3.9 | Payer returns membership number | Written to patient.insuranceNumber | :149-158 |
| 3.10 | Force the patient update to fail after the check row is created | Orphan row; success:false — not transactional | :129-158 |
| 3.11 | Change the patient's insurer after a check | Badge disappears | useDHSCheckEligibility.ts:67-83 |
| 3.12 | Run twice | Only the latest is currentEligibility (@unique) | schema.prisma:965 |
National ID validation
| Country | Type | Input | Expected |
|---|---|---|---|
| SA | national_id | 1234567890 | pass |
| SA | national_id | 2234567890 | fail — must start with 1 |
| SA | iqama/residency | 1234567890 | fail — must start with 2 |
| SA | any of the four | 123456789 | fail — must be 10 digits |
| EG | any | 14 digits starting 2/3, valid month & day | pass |
| EG | any | 14 digits, month 13 | fail |
(DHSCheckEligibility.tsx:196-245; legacy form NationalIdField.js:52.)
updatePatientDetails
| # | Test | Expected | Ref |
|---|---|---|---|
| 3.13 | Send policyNumber, coverageType, nphiesCode | Silently discarded — not on the allow-list | :21-37 vs inputs.graphql:911-924 |
| 3.14 | Cross-tenant patientId | updateMany count 0 → "Update failed. Please try again." | :72-79, :90-97 |
| 3.15 | identifierType: 'iqama' | Stored as residency | :52-66 |
| 3.16 | identifierType: 'nonsense' | Stored as OTHER + log.warn, not rejected | :52-66 |
| 3.17 | Future birthDate | Rejected | :39-45 |
| 3.18 | With the feature flag off | Still works — the only DHS mutation not flag-gated | permissions.js:2782 |
4. Approval submission
Pre-flight refusals (client side)
| # | Selection | Expected | Ref |
|---|---|---|---|
| 4.1 | Nothing | msg_select_operations_first | useDHSApprovalSubmission.tsx:57-60 |
| 4.2 | Operation with tooth.length > 1 | Grouped-teeth notification listing teeth | :62-81 |
| 4.3 | Operation with amount > 1 | Quantity notification | :83-102 |
| 4.4 | Two branches | Mixed-branch notification with branch names | :104-134 |
| 4.5 | Already-invoiced operation | Excluded from the count entirely | ChartTable.js:264-280 |
| 4.6 | insuranceDiscount === false | Excluded from the count | same |
| 4.7 | Operation with PENDING/APPROVED approval | Excluded | same |
Server guards
| # | Test | Expected | Ref |
|---|---|---|---|
| 4.8 | Include an insuranceDiscount:false operation via the API | "Operations not subject to insurance cannot be submitted" | dhsApprovalSubmission.js:47-60 |
| 4.9 | Empty operationIds | "No linked operations; cannot determine the filing branch" — even though optional in SDL | :71-84 |
| 4.10 | Operations from two branches via the API | "Operations span multiple branches…" | dhsApprovalBranch.js:28-30 |
| 4.11 | Branch without nphiesCode | "Branch NPHIES code is not configured" | :34-36 |
| 4.12 | Pass a branchId for a different branch | Ignored — the filing branch comes from the operations | :62-70 |
| 4.13 | Mix in a company-B operation id | Count 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
| # | Test | Expected | Ref |
|---|---|---|---|
| 4.14 | http:// URL | "Attachment URL must use HTTPS" | validateAttachmentUrl.js:34-36 |
| 4.15 | Non-allow-listed host | "Attachment host … is not allowed" | :38-40 |
| 4.16 | Allowed host with a port | Rejected — check uses .host | :38-40 |
| 4.17 | Host resolving to 169.254.169.254 | "resolves to a private IP address" | :15-24, :49-53 |
| 4.18 | Host resolving to 10.x, 172.16-31.x, 192.168.x, 127.x, ::1, fc00:, fe80: | All rejected | same |
| 4.19 | URL that 302s to an internal host | Rejected — maxRedirects: 0 | dhsApprovalSubmission.js:206 |
| 4.20 | Attachment > 10 MiB | Rejected by both size checks | :221-228 |
| 4.21 | Unset S3_BUCKET_AWS | Module throws at import | validateAttachmentUrl.js:9-11 |
| 4.22 | Inspect the stored requestPayload | Every AttachmentBinary is '[redacted]' | :257-265 |
4.19 and 4.22 are the two highest-value security assertions in this PR.
Wizard behaviour
| # | Test | Expected | Ref |
|---|---|---|---|
| 4.23 | Operations with no files | Six steps, no Attachments | DHSApprovalSubmissionModal.tsx:168-173 |
| 4.24 | Operations with files | Seven steps | same |
| 4.25 | Next with an invalid field on an earlier step | Silently does nothing — console only | :73-80 |
| 4.26 | Try to leave Diagnosis with zero diagnoses | Blocked | DiagnosisStep.tsx:42-50 |
| 4.27 | ICD search with no match | "Use this code: <typed>" option offered | :77-94 |
| 4.28 | Change price/qty/discount on Services | VAT recomputes at 15% | ServicesStep.tsx:110-144 |
| 4.29 | Admission date after the earliest service date | Blocked | EncounterInfoStep.tsx:86-92 |
| 4.30 | Discharge before admission, or before the latest service date | Blocked | :105-116 |
| 4.31 | Select a practitioner | SCFHS + speciality auto-fill (default 08.26 / General) | :161-171 |
5. Polling, cancel, manual update
The cron
| # | Test | Expected | Ref |
|---|---|---|---|
| 5.1 | 150 pending approvals | Only 100 per tick, oldest updatedAt first | dhsApprovalsCron.js:77-107 |
| 5.2 | Approval with approvalNumber: null | Never picked up | :80-83 |
| 5.3 | Company with the flag off | Skipped; approvals stay PENDING | :124-145 |
| 5.4 | Branch or insurer missing nphiesCode | Silently skipped — no log, no error | :165-168 |
| 5.5 | Force a 401 mid-tick | Token cache cleared, re-auth, retry once | :192-217 |
| 5.6 | 401 on the retry too | Propagates to the task catch → approval set to ERROR | :306-322 |
| 5.7 | Two workers concurrently | Redis NX lock — one runs, one skips | :52-63 |
| 5.8 | Throw mid-tick | Lock released in finally | :343-346 |
| 5.9 | Zero pending approvals | No summary log emitted at all | :339 |
| 5.10 | Payer succeeded:false | Approval → 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 line | Expected | Ref |
|---|---|---|---|
| 5.11 | approvedQuantity: 2 | approved = true | :264-269 |
| 5.12 | approvedQuantity: 0, status rejected | approved = false | same |
| 5.13 | approvedQuantity: 0, status absent | approved not written (null) | same |
| 5.14 | approvedAmount.amount: 123.6 | insuranceValue = 124 (rounded) | :275-276 |
| 5.15 | Any response | tax and initialDiscount never written | :273 |
| 5.16 | Two operations sharing a code | First match wins — both get the same line. Confirm and assess | :251-256 |
| 5.17 | Operation with code: null | Skipped | :251 |
Cancel
| # | Test | Expected | Ref |
|---|---|---|---|
| 5.18 | Cancel a PENDING approval | status=CANCELED, canceledAt, canceledById set | :125-147 |
| 5.19 | Cancel PARTIALLY_APPROVED | Refused | :58-64 |
| 5.20 | Cancel ERROR / REJECTED / DRAFT | Refused | same |
| 5.21 | After cancelling | All linked operations insuranceValue=0, approved=false | :125-147 |
| 5.22 | After cancelling | preAuth not cleared | same |
| 5.23 | After cancelling | Price/tooth/doctor/diagnosis editable again; preAuth still locked | dhsFieldGuards.js:36-39 |
| 5.24 | Payer replies isCancelled (double-l) | Accepted | :118-122 |
| 5.25 | Re-poll a CANCELED approval | Status is overwritten by the payer's current answer — confirm and assess | dhsGetApproval.js:206 |
Manual update
| # | Test | Expected | Ref |
|---|---|---|---|
| 5.26 | On an APPROVED / REJECTED / CANCELED approval | "already been finalized" | :55-67 |
| 5.27 | On PENDING / PARTIALLY_APPROVED / DENIED / ERROR / DRAFT | Allowed | same |
| 5.28 | All approved: true | Approval → APPROVED | :69-80 |
| 5.29 | All approved: false | → REJECTED | same |
| 5.30 | Mixed | → PARTIALLY_APPROVED | same |
| 5.31 | All approved omitted | .every is vacuously true → APPROVED. Confirm this is intended | :69-80 |
| 5.32 | Duplicate operation ids | Count assertion fails | :46-51 |
| 5.33 | Cross-tenant operation id | Same failure | same |
| 5.34 | Any successful update | manualUpdateById set; UI shows the name | :95-105 |
| 5.35 | Send discount / tax | Never persisted | :82-93 |
| 5.36 | Operations spanning two approvals | Only the first approval's status updates. Confirm and assess | :53 |
6. Guards on existing flows
| # | Test | Expected | Ref |
|---|---|---|---|
| 6.1 | Invoice an operation with a PENDING approval | app.waitingApproval | dhsGuards.js:40-53 |
| 6.2 | Same, with the flag off | Allowed — flag checked before blocking | same |
| 6.3 | Convert a quotation containing a PENDING operation | Disabled, tooltip shown | ConvertInvoiceButton.js:15,43,50 |
| 6.4 | Edit price/tooth/amount/doctorId/createdAt/diagnosis with a live approval | app.cannotModifyDhsOperation | dhsFieldGuards.js:3,46 |
| 6.5 | Edit preAuth in any status | Always refused | :2 |
| 6.6 | Reorder a diagnosis array without changing membership | Allowed — multiset comparison | :5-20 |
| 6.7 | Partial update omitting protected fields | Allowed | :42 |
| 6.8 | Manual approve/deny on any NPHIES-branch operation | Disabled, even with no DHS approval | OperationApprovalStatus.js:25-26 |
| 6.9 | Mobile: invoice an operation with a PENDING approval | Blocked (not flag-gated on mobile) | AddInvoiceOperationsDrawer.js:82-87 |
7. Permissions and tenancy
Run each with the permission absent, then present:
| Operation | Permission |
|---|---|
| Settings → Integrations → DHS tab | VIEW_DHS_INTEGRATION |
| Save / rotate / test / branch codes | EDIT_DHS_INTEGRATION |
| Approvals tab, approval detail | VIEW_DHS_APPROVALS |
| Check Insurance, Check Status | CHECK_DHS_INSURANCE |
| Check Eligibility | CHECK_DHS_ELIGIBILITY |
| GET Approval | CREATE_DHS_APPROVAL |
| Cancel | CANCEL_DHS_APPROVAL |
| Retry | GET_DHS_APPROVAL |
| Manual Update | CREATE_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:
| Target | Guard |
|---|---|
dhsApprovalSubmission with a B patient | isSameCompanyAsDhsInputPatient |
dhsGetApproval / dhsCancelApproval with a B approval | isSameCompanyAsDhsApprovalId |
dhsManualUpdateOperations with B operations | isSameCompanyAsDhsManualOperations |
dhsApprovalDetails with a B approval | Resolver check → 'Not Authorised' |
getDHSApprovals | Always AND: [{companyId}] |
updateBranchesNphiesCodes with B branches | Pre-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:
| # | Test | Expected |
|---|---|---|
| 8.1 | Run 20260507183115 with existing approvals | companyId backfilled from patient.companyId, then NOT NULL |
| 8.2 | Same, with an approval whose patient was deleted | Migration raises, does not silently coerce (lines 25–38) |
| 8.3 | Run 20260508120000 with isCanceled = true rows | status = 'CANCELED' backfilled before the column drop |
| 8.4 | Fresh database, full migrate deploy | Clean; 20260616145425 is an intentional comment-only no-op |
9. Known issues — confirm, don't re-file
These are already identified (see Known Gaps):
Logs → Approvalsrenders nothing — the component does not exist.- The DHS Settings permission tab is not feature-flagged (
Group.js:251). getInsuranceCompanyDhsInsuranceHierarchythrowsTypeErroron a non-existent id.input CoverageInputis declared twice, identically (inputs.graphql:950,:957).S3_BUCKET_AWS/S3_REGION_AWSmissing 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.tsxandDHSCheckEligibility.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.