On this page
Invariants that must always holdPayroll math — targeted casesLeave & attendanceSecurity & tenancy (test as an adversary)Input validation (Yup + imperative)Bulk importCron & notificationsReportsRegression seams worth a smoke test each releaseFor Quality
What to test, and where the edges are. Organized by risk. The highest-value tests are the money invariants — a payroll defect is real money and lost trust.
Invariants that must always hold
These are the "if this ever breaks, it's a Sev-1" checks.
- Trial balance stays balanced after every processed run. Journal entries are
posted on functional-currency totals; a processed run must not unbalance the GL.
- WPS control total == run Total Net. The export builds the deductions column
as basic + housing + other − net, so basic + housing + other − deductions = net by construction. If the SCR trailer total ≠ the run's net, that's a defect.
- Each payroll line pays exactly once. Settlement uses a guarded
updateMany(status: PENDING → PAID); re-processing or concurrent processing must not double-pay. Test: process, retry/replay, assert one payout.
- Subledgers reconcile to the GL (Δ0). Loans (1260), GOSI payable (2130),
end-of-service provision (2620) should match their control accounts.
computeLineNetis the only net formula. Draft, line-edit, and
process-time loan re-check all call it (payrollMath.js:15). A regression test should assert that a component added to one path shows up in all three.
Payroll math — targeted cases
Proration (payrollMath.js:35, employmentFactor):
- Joiner mid-month, leaver mid-month, both — inclusive of both endpoints,
factor = employedDays / daysInMonth capped at 1.
- Fully-outside-period employee → skipped; **except a terminated doctor with
outstanding commission** → commission-only line (base 0).
- **Fixed base and EARNING allowances are prorated; hourly base and fixed
DEDUCTION components are NOT.** Test all four combinations.
Overtime (attendanceSummary.js):
- With approval required on, unapproved overtime is dropped and capped at
scheduled minutes (can't leak into 1× base). Approve → it appears.
- Regular-day OT at
overtimeMultiplier(default 1.5) vs rest-day/holiday work at
restDayOvertimeMultiplier (default 2) — separate buckets, no double count.
Missing-checkout guard (userMutations.js:816):
- A session longer than
maxDailyWorkedHours(default 16h) is capped and
autoClosed set. Verify the cap value is company-configurable (4–24).
GOSI (payrollDraft.js:210):
- Only non-doctors with
gosiApplicable. `gosiBase = override ?? base +
GOSI-flagged EARNING components`. Employee share withheld in net, employer share accrued (not withheld).
- Rates are settings (0–50), not hard-coded. Test that changing the company
rate changes the amounts; there are no statutory constants to assert against.
End-of-service (payrollRun.js:212):
- Accrues only when
eosEnabled, only for lines with base > 0 and a hire date. monthsFactor = serviceYears < 5 ? 0.5 : 1;amount = base × factor / 12.- Edge to verify honestly: the 5-year cliff flips the entire monthly accrual
at once (no within-year blend); it uses current base and base only (ignores allowances). Confirm this is the intended approximation.
- Re-processing that adds accrual rows triggers a reverse-and-repost of the
aggregate JE — test idempotency and the growth case.
Loans (employeeLoanMutations.js, payrollRun.js:107):
- Disbursement posts Dr 1260 / Cr Cash; requires a treasury.
- Payroll withholding is re-checked against the live balance at process time —
test: manual repayment between draft and process → the payroll deduction shrinks to match (no over-withholding).
- Manual repayment requires a treasury (else GL cash leg drifts); oldest loan
settles first; fully repaid → Paid.
- Loan deduction is capped so net never goes negative.
Leave & attendance
- Self-approval blocked (
hrMutations.js:320). Also self-review and
self-warn.
- Paid-leave cap, including the hardening that a paid type with **no balance
entry counts as 0 remaining** (hrMutations.js:247). Unpaid uncapped.
- Tenure leave day-accurate blend across the anniversary year
(hrQueries.js:108) — test just-before / on / just-after the anniversary.
- Hourly vs day leave: hourly requires start/end on a single day; day types
use a range and exclude holidays/off-days.
- Reduced-hours periods (
schedule.js:120): later start + shorter day, clamped
to ≥ 0, company-wide vs per-branch precedence.
- Coverage warning scoping: same department ∩ branch, falls back to branch
if no department, excludes the target, counts APPROVED overlaps only.
Security & tenancy (test as an adversary)
- Cross-tenant by-id access — every upsert/delete and every referenced FK is
company-scoped. Try to edit/delete another company's department, loan, vacancy, etc. Expect rejection.
- Personal-data auto-scoping — a non-manager querying
leaveRequests,
employeeLoans, timesheet, performanceReviews, etc. is pinned to self. Try to pass someone else's user id → expect notAuthorizedForUser.
- Masking — as a viewer with only e.g.
VIEW_ATTENDANCE, open another
employee's profile: IBAN/national ID/passport/bank/SWIFT must come back null. As the employee themselves, or DO_ALL/MANAGE_HR/MANAGE_SALARY_HUB, they're visible.
withUserNamesresolves names via a company-scopedin— a foreign id
should resolve to null, not leak a name.
- Permission gates — verify each mutation/query is behind the permission in the
table in Security & Permissions.
Input validation (Yup + imperative)
Spot-check the limits (inputRules.js): IBAN regex ^[A-Za-z]{2}[0-9A-Za-z]{13,32}$, loan principal/installment 0.01–10,000,000, installment ≤ principal, salary component amount 0.01–1,000,000, job-grade band min ≤ mid ≤ max, ratings 1–5, progress 0–100, GOSI rates 0–50, tenure years 1–40, max daily hours 4–24.
Bulk import
bulkUpdateEmployeeCompis per-row independent (not transactional) — one bad
row must not block the rest; each row returns {row, name, ok, error}. Test a mix of valid/invalid rows. Max 500 rows.
- Departments/positions are auto-created by name and cached per batch — verify
no duplicate department creation within one import.
Cron & notifications
- Expiry cron (
hrExpiryCron.js): fires only on exact milestones (30/14/7/3/1/0
days). Test that a document at 29 or 8 days is not pinged. Only managers with DO_ALL/MANAGE_HR + push tokens get it. Body shows first 4, "+N more".
- WhatsApp: leave notifications default on, payslip opt-in. A notify failure
must never roll back the triggering action (they're best-effort in try/catch).
Reports
- Reports render the table shell even with no data for the selected period
(verified live — "No data" is expected, not a bug). Test with a period that has a processed run to confirm numbers populate.
- Leave liability values remaining paid day leave at
base/30; excludes
zero-balance and disabled users.
Regression seams worth a smoke test each release
- Re-drafting an edited run keeps edits (idempotent on company+year+month).
- A processed run cannot be cancelled; a Paid line cannot be un-Paid.
- Mobile parity gaps (documented, not defects): no date-clearing in HR record
form; loan repayment dates to today; no post-upload expiry edit; assign-leave is web-only. Confirm these are still the only mobile gaps.